🐛(front) fix timezone double conversion in scheduler display

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 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-29 11:13:22 +01:00
parent 9e982a8eb1
commit 1ed0ba7206
6 changed files with 847 additions and 138 deletions

View File

@@ -89,7 +89,6 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
} = useSchedulerHandlers({
adapter,
caldavService,
davCalendarsRef,
calendarRef,
calendarUrl,
modalState,

View File

@@ -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<CalDavCalendar[]>;
calendarRef: MutableRefObject<CalendarApi | null>;
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]
);
/**

View File

@@ -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<string, Intl.DateTimeFormat>()
private offsetFormatterCache = new Map<string, Intl.DateTimeFormat>()
// ============================================================================
// 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
}
}
// ============================================================================

View File

@@ -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', () => {

View File

@@ -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')
})
})

View File

@@ -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
}
/**