From 1ed0ba7206ea1b2d554fe334a886cc4e72616064 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 29 Jan 2026 11:13:22 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(front)=20fix=20timezone=20double?= =?UTF-8?q?=20conversion=20in=20scheduler=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix events displaying with +1h offset (e.g. 14h→15h) caused by icsDateToJsDate() returning fake UTC (local.date) which then got the browser offset applied again by dateToLocalISOString(). Changes: - Return true UTC (icsDate.date) from icsDateToJsDate() instead of fake UTC (local.date) - Add getDateComponentsInTimezone() using Intl.DateTimeFormat with formatter caching for correct cross-timezone write path - Refactor jsDateToIcsDate() to use Intl instead of isFakeUtc flag - Replace optimistic updates with refetchEvents() after modal save to avoid fake UTC leaking into the display path - Align standalone helper jsDateToIcsDate() with adapter conversion - Narrow useCallback dependencies in useSchedulerHandlers - Remove dead code addDurationToDate() - Add 42 timezone conversion tests covering DST transitions, cross-timezone round-trips, half-hour/45min offsets Co-Authored-By: Claude Opus 4.5 --- .../components/scheduler/Scheduler.tsx | 1 - .../scheduler/hooks/useSchedulerHandlers.ts | 51 +- .../services/dav/EventCalendarAdapter.ts | 204 +++--- .../__tests__/event-calendar-helper.test.ts | 50 +- .../dav/__tests__/timezone-conversion.test.ts | 632 ++++++++++++++++++ .../dav/helpers/event-calendar-helper.ts | 47 +- 6 files changed, 847 insertions(+), 138 deletions(-) create mode 100644 src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx index 5b69f0e..e5c4e84 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx @@ -89,7 +89,6 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => { } = useSchedulerHandlers({ adapter, caldavService, - davCalendarsRef, calendarRef, calendarUrl, modalState, diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts index 3ba9953..fd24b2f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts @@ -17,7 +17,6 @@ import type { } from "../../../services/dav/types/event-calendar"; import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../services/dav/EventCalendarAdapter"; import type { CalDavService } from "../../../services/dav/CalDavService"; -import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; import type { EventModalState, RecurringDeleteOption } from "../types"; // Get browser timezone @@ -36,7 +35,6 @@ interface CalendarApi { interface UseSchedulerHandlersProps { adapter: EventCalendarAdapter; caldavService: CalDavService; - davCalendarsRef: MutableRefObject; calendarRef: MutableRefObject; calendarUrl: string; modalState: EventModalState; @@ -46,7 +44,6 @@ interface UseSchedulerHandlersProps { export const useSchedulerHandlers = ({ adapter, caldavService, - davCalendarsRef, calendarRef, calendarUrl, modalState, @@ -257,23 +254,11 @@ export const useSchedulerHandlers = ({ throw new Error(result.error || "Failed to create event"); } - // Add to calendar UI - if (calendarRef.current && result.data) { - // For recurring events, refetch all events to ensure proper timezone conversion - if (event.recurrenceRule) { - calendarRef.current.refetchEvents(); - } else { - // Non-recurring event, add normally - const calendarColors = adapter.createCalendarColorMap( - davCalendarsRef.current - ); - const ecEvents = adapter.toEventCalendarEvents([result.data], { - calendarColors, - }); - if (ecEvents.length > 0) { - calendarRef.current.addEvent(ecEvents[0] as ECEvent); - } - } + // Refetch events to ensure proper timezone conversion + // (optimistic add would use fake UTC dates from the modal, + // causing a +1h offset until refresh) + if (calendarRef.current) { + calendarRef.current.refetchEvents(); } } else { // Update existing event @@ -291,27 +276,15 @@ export const useSchedulerHandlers = ({ throw new Error(result.error || "Failed to update event"); } - // Update in calendar UI - if (calendarRef.current && result.data) { - // If this is a recurring event, refetch all events to update all instances - if (event.recurrenceRule) { - calendarRef.current.refetchEvents(); - } else { - // Non-recurring event, update normally - const calendarColors = adapter.createCalendarColorMap( - davCalendarsRef.current - ); - const ecEvents = adapter.toEventCalendarEvents([result.data], { - calendarColors, - }); - if (ecEvents.length > 0) { - calendarRef.current.updateEvent(ecEvents[0] as ECEvent); - } - } + // Refetch events to ensure proper timezone conversion + // (optimistic update would use fake UTC dates from the modal, + // causing a +1h offset until refresh) + if (calendarRef.current) { + calendarRef.current.refetchEvents(); } } }, - [adapter, caldavService, calendarRef, davCalendarsRef, modalState] + [caldavService, calendarRef, modalState.mode, modalState.eventUrl, modalState.etag] ); /** @@ -422,7 +395,7 @@ export const useSchedulerHandlers = ({ } } }, - [caldavService, modalState, calendarRef] + [caldavService, calendarRef, modalState.eventUrl, modalState.etag] ); /** diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts index 5190d7a..116cce1 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/EventCalendarAdapter.ts @@ -43,6 +43,24 @@ type ExtendedOrganizer = { displayName?: string } +// ============================================================================ +// Timezone Conversion Types +// ============================================================================ + +/** + * Date components extracted from a Date in a specific timezone. + * Used by getDateComponentsInTimezone() to convert UTC instants + * to local time components in any IANA timezone. + */ +export type DateComponents = { + year: number + month: number // 1-12 (not 0-11 like JS Date) + day: number + hours: number + minutes: number + seconds: number +} + // ============================================================================ // Extended Types for conversion metadata // ============================================================================ @@ -105,6 +123,10 @@ export class EventCalendarAdapter { includeRecurringInstances: true, } + private static readonly FORMATTER_CACHE_MAX_SIZE = 50 + private dateComponentsFormatterCache = new Map() + private offsetFormatterCache = new Map() + // ============================================================================ // CalDAV -> EventCalendar Conversions // ============================================================================ @@ -343,16 +365,15 @@ export class EventCalendarAdapter { // Determine timezone const timezone = extProps.timezone ?? opts.defaultTimezone - // If event has extProps.timezone, it came from the server and dates are already "fake UTC" - const isFakeUtc = !!extProps.timezone - // Build IcsEvent + // jsDateToIcsDate uses Intl.DateTimeFormat to convert the date to the + // target timezone — no need for isFakeUtc flag anymore. // Note: EventCalendar already uses exclusive end dates for all-day events const icsEvent: IcsEvent = { uid, stamp: { date: new Date() }, - start: this.jsDateToIcsDate(startDate, isAllDay, timezone, isFakeUtc), - end: this.jsDateToIcsDate(endDate, isAllDay, timezone, isFakeUtc), + start: this.jsDateToIcsDate(startDate, isAllDay, timezone), + end: this.jsDateToIcsDate(endDate, isAllDay, timezone), summary: typeof ecEvent.title === 'string' ? ecEvent.title : '', sequence: (extProps.sequence ?? 0) + 1, } @@ -366,13 +387,13 @@ export class EventCalendarAdapter { if (extProps.categories) icsEvent.categories = extProps.categories if (extProps.priority != null) icsEvent.priority = String(extProps.priority) if (extProps.url) icsEvent.url = extProps.url - if (extProps.created) icsEvent.created = this.jsDateToIcsDate(extProps.created, false, timezone, isFakeUtc) + if (extProps.created) icsEvent.created = this.jsDateToIcsDate(extProps.created, false, timezone) if (extProps.recurrenceRule) icsEvent.recurrenceRule = extProps.recurrenceRule // Convert recurrence ID for recurring instances if (extProps.recurrenceId) { icsEvent.recurrenceId = { - value: this.jsDateToIcsDate(extProps.recurrenceId, ecEvent.allDay ?? false, timezone, isFakeUtc), + value: this.jsDateToIcsDate(extProps.recurrenceId, ecEvent.allDay ?? false, timezone), } } @@ -727,41 +748,42 @@ export class EventCalendarAdapter { } /** - * Convert IcsDateObject to JavaScript Date + * Convert IcsDateObject to JavaScript Date. + * + * Always returns icsDate.date (true UTC) so that downstream code using + * getHours()/getMinutes() gets correct browser-local time automatically. + * + * Previously returned icsDate.local.date ("fake UTC" where UTC components + * encode local time), which caused a double timezone offset when combined + * with getHours() in dateToLocalISOString(). */ private icsDateToJsDate(icsDate: IcsDateObject): Date { - // Use local date if available, otherwise use UTC date - if (icsDate.local?.date) { - return icsDate.local.date - } return icsDate.date } /** - * Convert JavaScript Date to IcsDateObject + * Convert JavaScript Date to IcsDateObject. * - * IMPORTANT: EventCalendar returns dates in browser local time. - * ts-ics uses date.getUTCHours() etc. to generate ICS, so we need to - * create a "fake UTC" date where UTC components match the local time we want. + * For timed events, uses Intl.DateTimeFormat to extract the date/time + * components in the TARGET timezone, then creates a "fake UTC" Date + * where getUTCHours() etc. return those target-timezone components. + * This is required because ts-ics uses getUTCHours() to generate ICS output. * - * Example: User in Paris (UTC+1) drags event to 15:00 local - * - Input date: Date representing 15:00 local (14:00 UTC internally) - * - We want ICS: DTSTART:20260121T150000Z or DTSTART;TZID=Europe/Paris:20260121T150000 - * - Solution: Create date where getUTCHours() = 15 + * The fake UTC pattern is confined to this method — all other code + * works with real UTC dates and lets the browser handle local conversion. * - * @param date - The date to convert + * @param date - Any Date object (real UTC instant from icsDateToJsDate, or browser-local from EventCalendar) * @param allDay - Whether this is an all-day event - * @param timezone - The timezone to use - * @param isFakeUtc - If true, date is already "fake UTC" (use getUTC* methods) + * @param timezone - Target IANA timezone for the ICS output (e.g., "Europe/Paris") */ - private jsDateToIcsDate(date: Date, allDay: boolean, timezone?: string, isFakeUtc = false): IcsDateObject { + private jsDateToIcsDate(date: Date, allDay: boolean, timezone?: string): IcsDateObject { if (allDay) { // For all-day events, use DATE type (no time component) - // Create a UTC date with the local date components + // Extract date components in browser local time const utcDate = new Date(Date.UTC( - isFakeUtc ? date.getUTCFullYear() : date.getFullYear(), - isFakeUtc ? date.getUTCMonth() : date.getMonth(), - isFakeUtc ? date.getUTCDate() : date.getDate() + date.getFullYear(), + date.getMonth(), + date.getDate() )) return { type: 'DATE', @@ -769,27 +791,26 @@ export class EventCalendarAdapter { } } - // For timed events, create a "fake UTC" date where UTC components = local components - // This ensures ts-ics generates the correct time in the ICS output - const fakeUtcDate = isFakeUtc - ? date // Already fake UTC, use as-is - : new Date(Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds() - )) - + // Resolve the target timezone const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone - const tzOffset = this.getTimezoneOffset(isFakeUtc ? date : new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes() - ), tz) + + // Extract date/time components in the target timezone using Intl API. + // This correctly handles cross-timezone events (e.g., NY event viewed from Paris) + // and DST transitions. + const components = this.getDateComponentsInTimezone(date, tz) + + // Create a "fake UTC" date where UTC components = target timezone components. + // ts-ics uses getUTCHours() to generate ICS, so this ensures correct output. + const fakeUtcDate = new Date(Date.UTC( + components.year, + components.month - 1, // DateComponents uses 1-12, Date.UTC uses 0-11 + components.day, + components.hours, + components.minutes, + components.seconds + )) + + const tzOffset = this.getTimezoneOffset(date, tz) return { type: 'DATE-TIME', @@ -802,17 +823,70 @@ export class EventCalendarAdapter { } } + /** + * Extract date/time components from a UTC instant in a specific timezone. + * + * Uses Intl.DateTimeFormat to correctly handle DST transitions and + * non-standard offsets (e.g., UTC+5:30 for India, UTC+5:45 for Nepal). + * + * @param date - Any Date object (interpreted as a UTC instant) + * @param timezone - IANA timezone identifier (e.g., "Europe/Paris", "America/New_York") + * @returns DateComponents with year, month (1-12), day, hours, minutes, seconds in the target timezone + */ + public getDateComponentsInTimezone(date: Date, timezone: string): DateComponents { + let formatter = this.dateComponentsFormatterCache.get(timezone) + if (!formatter) { + if (this.dateComponentsFormatterCache.size >= EventCalendarAdapter.FORMATTER_CACHE_MAX_SIZE) { + const firstKey = this.dateComponentsFormatterCache.keys().next().value + this.dateComponentsFormatterCache.delete(firstKey as string) + } + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + this.dateComponentsFormatterCache.set(timezone, formatter) + } + const parts = formatter.formatToParts(date) + + const get = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((p) => p.type === type) + return part ? parseInt(part.value, 10) : 0 + } + + return { + year: get('year'), + month: get('month'), + day: get('day'), + hours: get('hour') === 24 ? 0 : get('hour'), // Intl may return 24 for midnight + minutes: get('minute'), + seconds: get('second'), + } + } + /** * Get timezone offset string for a date and timezone * Returns format like "+0200" or "-0500" */ public getTimezoneOffset(date: Date, timezone: string): string { try { - // Create formatter for the timezone - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset', - }) + let formatter = this.offsetFormatterCache.get(timezone) + if (!formatter) { + if (this.offsetFormatterCache.size >= EventCalendarAdapter.FORMATTER_CACHE_MAX_SIZE) { + const firstKey = this.offsetFormatterCache.keys().next().value + this.offsetFormatterCache.delete(firstKey as string) + } + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'longOffset', + }) + this.offsetFormatterCache.set(timezone, formatter) + } const parts = formatter.formatToParts(date) const tzPart = parts.find((p) => p.type === 'timeZoneName') @@ -847,30 +921,6 @@ export class EventCalendarAdapter { return result } - /** - * Add string duration to a date (ISO 8601 format) - */ - private addDurationToDate(date: Date, duration: string): Date { - // Parse ISO 8601 duration (P1D, PT1H, etc.) - const result = new Date(date) - - const regex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/ - const match = duration.match(regex) - - if (!match) return result - - const [, years, months, weeks, days, hours, minutes, seconds] = match - - if (years) result.setFullYear(result.getFullYear() + parseInt(years)) - if (months) result.setMonth(result.getMonth() + parseInt(months)) - if (weeks) result.setDate(result.getDate() + parseInt(weeks) * 7) - if (days) result.setDate(result.getDate() + parseInt(days)) - if (hours) result.setHours(result.getHours() + parseInt(hours)) - if (minutes) result.setMinutes(result.getMinutes() + parseInt(minutes)) - if (seconds) result.setSeconds(result.getSeconds() + parseInt(seconds)) - - return result - } } // ============================================================================ diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts index 010f284..e47664e 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts @@ -512,14 +512,16 @@ describe('event-calendar-helper', () => { // ============================================================================ describe('ICS Conversion Helpers', () => { describe('icsDateToJsDate', () => { - it('returns local date when present', () => { + it('returns true UTC date (icsDate.date) when local is present', () => { + const utcDate = new Date('2025-01-15T10:00:00.000Z') const localDate = new Date('2025-01-15T11:00:00.000Z') const icsDate: IcsDateObject = { type: 'DATE-TIME', - date: new Date('2025-01-15T10:00:00.000Z'), + date: utcDate, local: { date: localDate, timezone: 'Europe/Paris', tzoffset: '+0100' }, } - expect(icsDateToJsDate(icsDate)).toBe(localDate) + expect(icsDateToJsDate(icsDate)).toBe(utcDate) + expect(icsDateToJsDate(icsDate)).not.toBe(localDate) }) it('returns UTC date when no local', () => { @@ -537,6 +539,8 @@ describe('event-calendar-helper', () => { const date = new Date('2025-01-15T00:00:00.000Z') const result = jsDateToIcsDate(date, true) expect(result.type).toBe('DATE') + expect(result.date.getUTCDate()).toBe(15) + expect(result.local).toBeUndefined() }) it('creates DATE-TIME type with timezone', () => { @@ -545,6 +549,46 @@ describe('event-calendar-helper', () => { expect(result.type).toBe('DATE-TIME') expect(result.local?.timezone).toBe('Europe/Paris') }) + + it('produces correct fake UTC for Europe/Paris winter (CET, UTC+1)', () => { + const date = new Date('2026-01-29T14:00:00Z') // 14:00 UTC = 15:00 Paris + const result = jsDateToIcsDate(date, false, 'Europe/Paris') + expect(result.date.getUTCHours()).toBe(15) + expect(result.date.getUTCMinutes()).toBe(0) + expect(result.local?.timezone).toBe('Europe/Paris') + expect(result.local?.tzoffset).toBe('+0100') + }) + + it('produces correct fake UTC for America/New_York winter (EST, UTC-5)', () => { + const date = new Date('2026-01-29T15:00:00Z') // 15:00 UTC = 10:00 NY + const result = jsDateToIcsDate(date, false, 'America/New_York') + expect(result.date.getUTCHours()).toBe(10) + expect(result.local?.timezone).toBe('America/New_York') + expect(result.local?.tzoffset).toBe('-0500') + }) + + it('produces correct fake UTC for Europe/Paris summer (CEST, UTC+2)', () => { + const date = new Date('2026-07-15T13:00:00Z') // 13:00 UTC = 15:00 Paris CEST + const result = jsDateToIcsDate(date, false, 'Europe/Paris') + expect(result.date.getUTCHours()).toBe(15) + expect(result.local?.tzoffset).toBe('+0200') + }) + + it('preserves minutes and seconds in fake UTC', () => { + const date = new Date('2026-01-29T14:37:42Z') + const result = jsDateToIcsDate(date, false, 'Europe/Paris') + expect(result.date.getUTCHours()).toBe(15) + expect(result.date.getUTCMinutes()).toBe(37) + expect(result.date.getUTCSeconds()).toBe(42) + }) + + it('uses browser timezone when no timezone specified', () => { + const date = new Date('2026-01-29T14:00:00Z') + const result = jsDateToIcsDate(date, false) + expect(result.type).toBe('DATE-TIME') + expect(result.local?.timezone).toBeDefined() + expect(result.local?.tzoffset).toBeDefined() + }) }) describe('isIcsEventAllDay', () => { diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts new file mode 100644 index 0000000..691b584 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts @@ -0,0 +1,632 @@ +/** + * Tests for timezone conversion utilities. + * + * All tests use explicit UTC dates (new Date("...Z")) and assert on + * getUTCHours()/getUTCMinutes() for fake UTC values, ensuring deterministic + * results regardless of the CI machine's timezone. + */ +import { EventCalendarAdapter } from '../EventCalendarAdapter' +import { icsDateToJsDate } from '../helpers/event-calendar-helper' +import type { IcsDateObject } from 'ts-ics' + +const adapter = new EventCalendarAdapter() + +// ============================================================================ +// 4. getDateComponentsInTimezone +// ============================================================================ +describe('getDateComponentsInTimezone', () => { + // 4.1 Europe/Paris winter (CET, UTC+1) + it('converts UTC to Europe/Paris winter time (CET, UTC+1)', () => { + const date = new Date('2026-01-29T14:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }) + }) + + // 4.2 Europe/Paris summer (CEST, UTC+2) + it('converts UTC to Europe/Paris summer time (CEST, UTC+2)', () => { + const date = new Date('2026-07-15T13:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result).toMatchObject({ year: 2026, month: 7, day: 15, hours: 15, minutes: 0, seconds: 0 }) + }) + + // 4.3 America/New_York winter (EST, UTC-5) + it('converts UTC to America/New_York winter time (EST, UTC-5)', () => { + const date = new Date('2026-01-29T15:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'America/New_York') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 10, minutes: 0, seconds: 0 }) + }) + + // 4.4 America/New_York summer (EDT, UTC-4) + it('converts UTC to America/New_York summer time (EDT, UTC-4)', () => { + const date = new Date('2026-07-15T14:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'America/New_York') + expect(result).toMatchObject({ year: 2026, month: 7, day: 15, hours: 10, minutes: 0, seconds: 0 }) + }) + + // 4.5 Asia/Tokyo (JST, UTC+9, no DST) + it('converts UTC to Asia/Tokyo (JST, UTC+9, no DST)', () => { + const date = new Date('2026-01-29T06:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Asia/Tokyo') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }) + }) + + // 4.6 UTC + it('returns same components for UTC timezone', () => { + const date = new Date('2026-01-29T15:30:45Z') + const result = adapter.getDateComponentsInTimezone(date, 'UTC') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 45 }) + }) + + // 4.7 Day change forward (UTC late → next day in ahead timezone) + it('handles day change forward (UTC 23:00 → next day in Asia/Tokyo)', () => { + const date = new Date('2026-01-29T23:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Asia/Tokyo') + expect(result).toMatchObject({ year: 2026, month: 1, day: 30, hours: 8, minutes: 0, seconds: 0 }) + }) + + // 4.8 Day change backward (UTC early → previous day in behind timezone) + it('handles day change backward (UTC 03:00 → previous day in America/New_York)', () => { + const date = new Date('2026-01-29T03:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'America/New_York') + expect(result).toMatchObject({ year: 2026, month: 1, day: 28, hours: 22, minutes: 0, seconds: 0 }) + }) + + // 4.9 Year change + it('handles year change (Jan 1 UTC midnight → Dec 31 in America/Los_Angeles)', () => { + const date = new Date('2026-01-01T00:30:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'America/Los_Angeles') + expect(result).toMatchObject({ year: 2025, month: 12, day: 31, hours: 16, minutes: 30, seconds: 0 }) + }) + + // 4.10 Half-hour offset (India, UTC+5:30) + it('handles half-hour offset (Asia/Kolkata, UTC+5:30)', () => { + const date = new Date('2026-01-29T10:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Asia/Kolkata') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 0 }) + }) + + // 4.11 45-minute offset (Nepal, UTC+5:45) + it('handles 45-minute offset (Asia/Kathmandu, UTC+5:45)', () => { + const date = new Date('2026-01-29T10:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Asia/Kathmandu') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 45, seconds: 0 }) + }) + + // 4.12 DST transition CET→CEST (last Sunday of March 2026 = March 29) + it('handles DST transition CET→CEST (before transition)', () => { + // March 29 2026 at 00:30 UTC → 01:30 CET (still winter time) + const date = new Date('2026-03-29T00:30:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result.hours).toBe(1) + expect(result.minutes).toBe(30) + }) + + it('handles DST transition CET→CEST (after transition)', () => { + // March 29 2026 at 02:00 UTC → 04:00 CEST (summer time, clocks jumped 2→3) + const date = new Date('2026-03-29T02:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result.hours).toBe(4) + expect(result.minutes).toBe(0) + }) + + // 4.13 DST transition CEST→CET (last Sunday of October 2026 = October 25) + it('handles DST transition CEST→CET (before transition)', () => { + // October 25 2026 at 00:00 UTC → 02:00 CEST (still summer time) + const date = new Date('2026-10-25T00:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result.hours).toBe(2) + expect(result.minutes).toBe(0) + }) + + it('handles DST transition CEST→CET (after transition)', () => { + // October 25 2026 at 02:00 UTC → 02:00 CET (winter time, clocks fell back) + // At 01:00 UTC, clocks go from 03:00 CEST back to 02:00 CET + // So at 02:00 UTC, it's 03:00 CET + const date = new Date('2026-10-25T02:00:00Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result.hours).toBe(3) + expect(result.minutes).toBe(0) + }) + + // 4.14 Non-zero minutes and seconds + it('preserves non-zero minutes and seconds', () => { + const date = new Date('2026-01-29T14:37:42Z') + const result = adapter.getDateComponentsInTimezone(date, 'Europe/Paris') + expect(result).toMatchObject({ year: 2026, month: 1, day: 29, hours: 15, minutes: 37, seconds: 42 }) + }) +}) + +// ============================================================================ +// 5. icsDateToJsDate (bug fix) +// ============================================================================ +describe('icsDateToJsDate', () => { + // 5.1 Returns true UTC when local is present + it('returns icsDate.date (true UTC) when local is present, not local.date', () => { + const utcDate = new Date('2026-01-29T14:00:00.000Z') + const fakeUtcDate = new Date('2026-01-29T15:00:00.000Z') + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: utcDate, + local: { date: fakeUtcDate, timezone: 'Europe/Paris', tzoffset: '+0100' }, + } + const result = icsDateToJsDate(icsDate) + expect(result).toBe(utcDate) + expect(result).not.toBe(fakeUtcDate) + expect(result.getUTCHours()).toBe(14) + }) + + // 5.2 Returns date when local is absent (UTC pure events) + it('returns icsDate.date when local is absent', () => { + const utcDate = new Date('2026-01-29T14:00:00.000Z') + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: utcDate, + } + expect(icsDateToJsDate(icsDate)).toBe(utcDate) + }) + + // 5.3 Returns date for all-day events (DATE type) + it('returns icsDate.date for all-day events (DATE type)', () => { + const utcDate = new Date('2026-01-29T00:00:00.000Z') + const icsDate: IcsDateObject = { + type: 'DATE', + date: utcDate, + } + const result = icsDateToJsDate(icsDate) + expect(result).toBe(utcDate) + expect(result.getUTCDate()).toBe(29) + }) +}) + +// ============================================================================ +// 6. jsDateToIcsDate (timezone conversion) +// ============================================================================ +describe('jsDateToIcsDate', () => { + // Access private method via adapter for testing + // We use toIcsEvent indirectly, but for unit tests we test via a + // minimal EventCalendar event round-trip through the adapter. + // Instead, we directly test the public-facing behavior through toIcsEvent. + + const makeEcEvent = (start: string, timezone?: string) => ({ + id: 'test', + start, + end: start, + title: 'Test', + allDay: false, + extendedProps: { + uid: 'test-uid', + calendarUrl: '/cal/test/', + ...(timezone ? { timezone } : {}), + }, + }) + + const makeAllDayEcEvent = (start: string) => ({ + id: 'test', + start, + end: start, + title: 'Test', + allDay: true, + extendedProps: { + uid: 'test-uid', + calendarUrl: '/cal/test/', + }, + }) + + // 6.1 All-day event produces DATE type + it('produces DATE type for all-day events', () => { + const ecEvent = makeAllDayEcEvent('2026-01-29') + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + expect(icsEvent.start.type).toBe('DATE') + expect(icsEvent.start.local).toBeUndefined() + }) + + // 6.2 Europe/Paris winter: UTC 14:00 → fake UTC hours 15 + it('creates correct fake UTC for Europe/Paris winter (CET, UTC+1)', () => { + // This simulates an event displayed at 15:00 Paris local time. + // EventCalendar gives us "2026-01-29T15:00:00.000" as a local time string. + // new Date("2026-01-29T15:00:00.000") creates a browser-local Date. + // The adapter should convert this to 15:00 Paris time in the fake UTC. + const icsEvent = adapter.toIcsEvent( + makeEcEvent('2026-01-29T15:00:00.000', 'Europe/Paris'), + { defaultTimezone: 'Europe/Paris' } + ) + expect(icsEvent.start.type).toBe('DATE-TIME') + expect(icsEvent.start.local?.timezone).toBe('Europe/Paris') + // The fake UTC should have getUTCHours() = 15 (Paris local time) + expect(icsEvent.start.date.getUTCHours()).toBe(15) + expect(icsEvent.start.date.getUTCMinutes()).toBe(0) + }) + + // 6.3 America/New_York winter: event at 10:00 NY + it('creates correct fake UTC for America/New_York winter (EST, UTC-5)', () => { + // An event at 10:00 NY = 15:00 UTC. EventCalendar displays at browser local time. + // If browser is in Paris (UTC+1), this displays at 16:00. + // EC string: "2026-01-29T16:00:00.000" parsed as local Paris → UTC 15:00 + // Adapter converts UTC 15:00 to NY components → hours: 10 + const utcDate = new Date('2026-01-29T15:00:00Z') + // We need to pass a local time string that corresponds to UTC 15:00 + // Since we can't know the CI timezone, use the UTC date directly + // and test via the adapter's internal conversion + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { + uid: 'test-uid', + calendarUrl: '/cal/test/', + timezone: 'America/New_York', + }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'America/New_York' }) + expect(icsEvent.start.date.getUTCHours()).toBe(10) + expect(icsEvent.start.local?.timezone).toBe('America/New_York') + }) + + // 6.4 Asia/Tokyo: UTC 06:00 → fake UTC hours 15 + it('creates correct fake UTC for Asia/Tokyo (JST, UTC+9)', () => { + const utcDate = new Date('2026-01-29T06:00:00Z') + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Asia/Tokyo' }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Asia/Tokyo' }) + expect(icsEvent.start.date.getUTCHours()).toBe(15) + }) + + // 6.5 Europe/Paris summer (DST): UTC 13:00 → fake UTC hours 15 + it('creates correct fake UTC for Europe/Paris summer (CEST, UTC+2)', () => { + const utcDate = new Date('2026-07-15T13:00:00Z') + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Europe/Paris' }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + expect(icsEvent.start.date.getUTCHours()).toBe(15) + }) + + // 6.6 Preserves minutes and seconds + it('preserves non-zero minutes and seconds in fake UTC', () => { + const utcDate = new Date('2026-01-29T14:37:42Z') + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Europe/Paris' }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + expect(icsEvent.start.date.getUTCHours()).toBe(15) + expect(icsEvent.start.date.getUTCMinutes()).toBe(37) + expect(icsEvent.start.date.getUTCSeconds()).toBe(42) + }) + + // 6.7 Day change: UTC 23:00 + Tokyo → next day + it('handles day change in fake UTC (UTC 23:00 → next day in Tokyo)', () => { + const utcDate = new Date('2026-01-29T23:00:00Z') + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Asia/Tokyo' }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Asia/Tokyo' }) + expect(icsEvent.start.date.getUTCDate()).toBe(30) + expect(icsEvent.start.date.getUTCHours()).toBe(8) + }) + + // 6.8 local.timezone is correctly set + it('sets local.timezone on the returned IcsDateObject', () => { + const utcDate = new Date('2026-01-29T14:00:00Z') + const ecEvent = { + id: 'test', + start: utcDate, + end: utcDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'America/New_York' }, + } + const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'America/New_York' }) + expect(icsEvent.start.local?.timezone).toBe('America/New_York') + }) + + // 6.9 local.tzoffset is correctly calculated + it('calculates correct local.tzoffset format', () => { + // Winter Paris = +0100 + const winterDate = new Date('2026-01-29T14:00:00Z') + const winterEvent = { + id: 'test', + start: winterDate, + end: winterDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Europe/Paris' }, + } + const winterIcs = adapter.toIcsEvent(winterEvent, { defaultTimezone: 'Europe/Paris' }) + expect(winterIcs.start.local?.tzoffset).toBe('+0100') + + // Summer Paris = +0200 + const summerDate = new Date('2026-07-15T13:00:00Z') + const summerEvent = { + id: 'test', + start: summerDate, + end: summerDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/test/', timezone: 'Europe/Paris' }, + } + const summerIcs = adapter.toIcsEvent(summerEvent, { defaultTimezone: 'Europe/Paris' }) + expect(summerIcs.start.local?.tzoffset).toBe('+0200') + }) +}) + +// ============================================================================ +// 7. getTimezoneOffset +// ============================================================================ +describe('getTimezoneOffset', () => { + // 7.1 Positive offset winter + it('returns "+0100" for Europe/Paris in winter', () => { + const date = new Date('2026-01-29T14:00:00Z') + expect(adapter.getTimezoneOffset(date, 'Europe/Paris')).toBe('+0100') + }) + + // 7.2 Positive offset summer + it('returns "+0200" for Europe/Paris in summer', () => { + const date = new Date('2026-07-15T13:00:00Z') + expect(adapter.getTimezoneOffset(date, 'Europe/Paris')).toBe('+0200') + }) + + // 7.3 Negative offset winter + it('returns "-0500" for America/New_York in winter', () => { + const date = new Date('2026-01-29T15:00:00Z') + expect(adapter.getTimezoneOffset(date, 'America/New_York')).toBe('-0500') + }) + + // 7.4 Negative offset summer + it('returns "-0400" for America/New_York in summer', () => { + const date = new Date('2026-07-15T14:00:00Z') + expect(adapter.getTimezoneOffset(date, 'America/New_York')).toBe('-0400') + }) + + // 7.5 Zero offset + it('returns "+0000" for UTC', () => { + const date = new Date('2026-01-29T14:00:00Z') + expect(adapter.getTimezoneOffset(date, 'UTC')).toBe('+0000') + }) + + // 7.6 Half-hour offset + it('returns "+0530" for Asia/Kolkata', () => { + const date = new Date('2026-01-29T10:00:00Z') + expect(adapter.getTimezoneOffset(date, 'Asia/Kolkata')).toBe('+0530') + }) + + // 7.7 Invalid timezone falls back gracefully + it('returns "+0000" for invalid timezone', () => { + const date = new Date('2026-01-29T14:00:00Z') + expect(adapter.getTimezoneOffset(date, 'Invalid/Timezone')).toBe('+0000') + }) +}) + +// ============================================================================ +// 8. Round-trip tests (ICS parse → adapter → display → adapter → ICS) +// ============================================================================ +describe('Round-trip conversions', () => { + /** + * Simulate a round-trip: + * 1. Start with an IcsDateObject (as ts-ics would produce from parsing) + * 2. Convert to JS Date via icsDateToJsDate (read path) + * 3. Convert to display string via dateToLocalISOString (what EventCalendar sees) + * 4. Parse the string back (what happens when saving) + * 5. Convert back to IcsDateObject via toIcsEvent (write path) + * 6. Verify the fake UTC has the correct hours for ts-ics + */ + + // 8.1 Europe/Paris winter + it('round-trips Europe/Paris winter event correctly', () => { + // ts-ics produces: date=UTC 14:00, local.date=fakeUTC 15:00 + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-01-29T14:00:00Z'), + local: { + date: new Date('2026-01-29T15:00:00Z'), + timezone: 'Europe/Paris', + tzoffset: '+0100', + }, + } + // Step 1: Read path — get true UTC + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCHours()).toBe(14) // true UTC + + // Step 2: Write path — convert back through adapter + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/', timezone: 'Europe/Paris' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + + // Verify fake UTC has correct Paris local time + expect(result.start.date.getUTCHours()).toBe(15) + expect(result.start.date.getUTCMinutes()).toBe(0) + expect(result.start.local?.timezone).toBe('Europe/Paris') + }) + + // 8.2 Europe/Paris summer (CEST) + it('round-trips Europe/Paris summer event correctly', () => { + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-07-15T13:00:00Z'), + local: { + date: new Date('2026-07-15T15:00:00Z'), + timezone: 'Europe/Paris', + tzoffset: '+0200', + }, + } + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCHours()).toBe(13) + + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/', timezone: 'Europe/Paris' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + expect(result.start.date.getUTCHours()).toBe(15) + }) + + // 8.3 America/New_York + it('round-trips America/New_York event correctly', () => { + // 10:00 NY = 15:00 UTC + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-01-29T15:00:00Z'), + local: { + date: new Date('2026-01-29T10:00:00Z'), + timezone: 'America/New_York', + tzoffset: '-0500', + }, + } + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCHours()).toBe(15) + + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/', timezone: 'America/New_York' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'America/New_York' }) + expect(result.start.date.getUTCHours()).toBe(10) // NY local time preserved + }) + + // 8.4 Asia/Tokyo + it('round-trips Asia/Tokyo event correctly', () => { + // 15:00 Tokyo = 06:00 UTC + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-01-29T06:00:00Z'), + local: { + date: new Date('2026-01-29T15:00:00Z'), + timezone: 'Asia/Tokyo', + tzoffset: '+0900', + }, + } + const jsDate = icsDateToJsDate(icsDate) + + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/', timezone: 'Asia/Tokyo' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Asia/Tokyo' }) + expect(result.start.date.getUTCHours()).toBe(15) // Tokyo local time preserved + }) + + // 8.5 UTC pure (no TZID) + it('round-trips pure UTC event correctly', () => { + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-01-29T14:00:00Z'), + // no local property — pure UTC + } + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCHours()).toBe(14) + + // Without a timezone in extProps, adapter uses defaultTimezone + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'Test', + allDay: false, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'UTC' }) + expect(result.start.date.getUTCHours()).toBe(14) + expect(result.start.local?.timezone).toBe('UTC') + }) + + // 8.6 All-day event + it('round-trips all-day event correctly', () => { + const icsDate: IcsDateObject = { + type: 'DATE', + date: new Date('2026-01-29T00:00:00Z'), + } + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCDate()).toBe(29) + + const ecEvent = { + id: 'test', + start: '2026-01-29', + end: '2026-01-29', + title: 'Test', + allDay: true, + extendedProps: { uid: 'test-uid', calendarUrl: '/cal/' }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' }) + expect(result.start.type).toBe('DATE') + expect(result.start.date.getUTCDate()).toBe(29) + }) + + // 8.7 Cross-timezone (NY event viewed from Paris browser) + it('preserves NY time after round-trip from Paris browser', () => { + // NY event at 10:00 = UTC 15:00 + // Paris browser shows at 16:00 local (UTC+1 winter) + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2026-01-29T15:00:00Z'), // true UTC + local: { + date: new Date('2026-01-29T10:00:00Z'), // fake UTC for NY + timezone: 'America/New_York', + tzoffset: '-0500', + }, + } + + // Read: returns true UTC (15:00 UTC) + const jsDate = icsDateToJsDate(icsDate) + expect(jsDate.getUTCHours()).toBe(15) + + // Write: adapter converts UTC 15:00 back to NY timezone → 10:00 fake UTC + const ecEvent = { + id: 'test', + start: jsDate, + end: jsDate, + title: 'NY Meeting', + allDay: false, + extendedProps: { + uid: 'test-uid', + calendarUrl: '/cal/', + timezone: 'America/New_York', // original timezone preserved in extProps + }, + } + const result = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'America/New_York' }) + + // NY local time preserved despite being viewed from Paris + expect(result.start.date.getUTCHours()).toBe(10) + expect(result.start.date.getUTCMinutes()).toBe(0) + expect(result.start.local?.timezone).toBe('America/New_York') + }) +}) diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts index 1ce9bc8..447ede2 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/helpers/event-calendar-helper.ts @@ -8,6 +8,7 @@ */ import type { IcsEvent, IcsDateObject, IcsRecurrenceRule } from 'ts-ics' +import { getEventCalendarAdapter } from '../EventCalendarAdapter' import type { EventCalendarEvent, EventCalendarEventInput, @@ -441,40 +442,50 @@ export function getEventsForResource( // ============================================================================ /** - * Convert IcsDateObject to JavaScript Date + * Convert IcsDateObject to JavaScript Date. + * + * Always returns icsDate.date (true UTC) so that downstream code using + * getHours()/getMinutes() gets correct browser-local time automatically. */ export function icsDateToJsDate(icsDate: IcsDateObject): Date { - if (icsDate.local?.date) { - return icsDate.local.date - } return icsDate.date } /** - * Convert JavaScript Date to IcsDateObject + * Convert JavaScript Date to IcsDateObject. + * + * Uses the adapter's Intl-based timezone conversion to produce correct + * fake UTC dates (where getUTCHours() = local hours in the target timezone). */ export function jsDateToIcsDate(date: Date, allDay: boolean = false, timezone?: string): IcsDateObject { if (allDay) { + const utcDate = new Date(Date.UTC( + date.getFullYear(), date.getMonth(), date.getDate() + )) return { type: 'DATE', - date, + date: utcDate, } } - const icsDate: IcsDateObject = { + const adapter = getEventCalendarAdapter() + const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone + const components = adapter.getDateComponentsInTimezone(date, tz) + const fakeUtcDate = new Date(Date.UTC( + components.year, components.month - 1, components.day, + components.hours, components.minutes, components.seconds + )) + const tzOffset = adapter.getTimezoneOffset(date, tz) + + return { type: 'DATE-TIME', - date, + date: fakeUtcDate, + local: { + date: fakeUtcDate, + timezone: tz, + tzoffset: tzOffset, + }, } - - if (timezone) { - icsDate.local = { - date, - timezone, - tzoffset: '+0000', // Default, will be recalculated when needed - } - } - - return icsDate } /**