From 6014681f294baca63ab53f4fe9348ff9bef5fd94 Mon Sep 17 00:00:00 2001 From: Yadd Date: Mon, 2 Feb 2026 11:20:12 +0100 Subject: [PATCH] Refactor: centralize timezone utilities in src/utils/timezone.ts (#491) Deduplicate resolveTimezone, resolveTimezoneId, convertEventDateTimeToISO, and getTimezoneOffset into a single canonical module, removing copies from EventApi, eventUtils, TimezoneSelector, EventUpdateModal, and calendarUtils. Co-authored-by: Claude Opus 4.5 --- src/components/Calendar/TimezoneSelector.tsx | 32 ++-------- .../Calendar/utils/calendarUtils.ts | 45 +++----------- src/features/Events/EventApi.ts | 40 +------------ src/features/Events/EventDisplayPreview.tsx | 2 +- src/features/Events/EventModal.tsx | 5 +- src/features/Events/EventUpdateModal.tsx | 35 ++--------- src/features/Events/eventUtils.ts | 48 ++------------- src/features/Events/formHelpers.ts | 2 +- src/features/Settings/SettingsPage.tsx | 6 +- src/utils/timezone.ts | 60 +++++++++++++++++++ 10 files changed, 87 insertions(+), 188 deletions(-) diff --git a/src/components/Calendar/TimezoneSelector.tsx b/src/components/Calendar/TimezoneSelector.tsx index 082ba71..5ca4c35 100644 --- a/src/components/Calendar/TimezoneSelector.tsx +++ b/src/components/Calendar/TimezoneSelector.tsx @@ -1,7 +1,10 @@ -import { browserDefaultTimeZone } from "@/utils/timezone"; +import { + browserDefaultTimeZone, + getTimezoneOffset, + resolveTimezone, +} from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import { Button, Popover } from "@linagora/twake-mui"; -import moment from "moment"; import { MouseEvent, useMemo, useState } from "react"; import { useI18n } from "twake-i18n"; import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; @@ -99,28 +102,3 @@ export function useTimeZoneList() { return { zones, browserTz, getTimezoneOffset }; }, []); } - -export function resolveTimezone(tzName: string): string { - if (TIMEZONES.zones[tzName]) { - return tzName; - } - if (TIMEZONES.aliases[tzName]) { - return TIMEZONES.aliases[tzName].aliasTo; - } - return tzName; -} - -export function getTimezoneOffset( - tzName: string, - date: Date = new Date() -): string { - const fmt = new Intl.DateTimeFormat(undefined, { - timeZone: tzName, - timeZoneName: "shortOffset", - }); - - const currentDate = moment(date).isValid() ? date : new Date(); - const parts = fmt.formatToParts(currentDate); - const offsetPart = parts.find((p) => p.type === "timeZoneName"); - return offsetPart?.value.replace("GMT", "UTC") ?? ""; -} diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 8d438b1..ff8a37e 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -1,42 +1,15 @@ -import { detectDateTimeFormat } from "@/components/Event/utils/dateTimeHelpers"; import { refreshSingularCalendar } from "@/components/Event/utils/eventUtils"; import { Calendar } from "@/features/Calendars/CalendarTypes"; import { getCalendarDetailAsync } from "@/features/Calendars/services"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { convertEventDateTimeToISO } from "@/utils/timezone"; import { SlotLabelContentArg } from "@fullcalendar/core"; import { ThunkDispatch } from "@reduxjs/toolkit"; import moment from "moment-timezone"; import { useI18n } from "twake-i18n"; -function convertEventDateTimeToISO( - datetime: string, - eventTimezone: string, - isAllDay: boolean -): string { - if (!datetime || isAllDay) return datetime; - - if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { - return datetime; - } - - const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; - if (dateOnlyRegex.test(datetime)) { - return datetime; - } - - const format = detectDateTimeFormat(datetime); - const momentDate = moment.tz(datetime, format, eventTimezone); - if (!momentDate.isValid()) { - console.warn( - `[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"` - ); - return datetime; - } - return momentDate.toISOString(); -} - export const updateSlotLabelVisibility = ( currentTime: Date, slotLabel: SlotLabelContentArg, @@ -114,22 +87,18 @@ export const eventToFullCalendarFormat = ( }; if (!isAllDay && e.start && eventTimezone) { - const startISO = convertEventDateTimeToISO( - e.start, - eventTimezone, - isAllDay - ); + const startISO = convertEventDateTimeToISO(e.start, eventTimezone, { + isAllDay, + }); if (startISO) { convertedEvent.start = startISO; } } if (!isAllDay && e.end && eventTimezone) { - const endISO = convertEventDateTimeToISO( - e.end, - eventTimezone, - isAllDay - ); + const endISO = convertEventDateTimeToISO(e.end, eventTimezone, { + isAllDay, + }); if (endISO) { convertedEvent.end = endISO; } diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 85dbfe4..e3cd26c 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,9 +1,8 @@ -import { detectDateTimeFormat } from "@/components/Event/utils/dateTimeHelpers"; import { api } from "@/utils/apiUtils"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import ICAL from "ical.js"; -import moment from "moment-timezone"; import { CalDavItem } from "../Calendars/api/types"; import { CalendarEvent } from "./EventsTypes"; import { @@ -13,17 +12,6 @@ import { parseCalendarEvent, } from "./eventUtils"; -function resolveTimezoneId(tzid?: string): string | undefined { - if (!tzid) return undefined; - if (TIMEZONES.zones[tzid]) { - return tzid; - } - if (TIMEZONES.aliases[tzid]) { - return TIMEZONES.aliases[tzid].aliasTo; - } - return tzid; -} - export async function reportEvent( event: CalendarEvent, match: { start: string; end: string } @@ -148,32 +136,6 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { return merged; } -function convertEventDateTimeToISO( - datetime: string, - eventTimezone: string -): string | undefined { - if (!datetime || !eventTimezone) return undefined; - - if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { - return datetime; - } - - const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; - if (dateOnlyRegex.test(datetime)) { - return undefined; - } - - const format = detectDateTimeFormat(datetime); - const momentDate = moment.tz(datetime, format, eventTimezone); - if (!momentDate.isValid()) { - console.warn( - `[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"` - ); - return undefined; - } - return momentDate.toISOString(); -} - export async function dlEvent(event: CalendarEvent) { const response = await api.get(`dav${event.URL}?export=`); const eventData = await response.text(); diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index ae4f3ab..48838a9 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -1,6 +1,6 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { CalendarName } from "@/components/Calendar/CalendarName"; -import { getTimezoneOffset } from "@/components/Calendar/TimezoneSelector"; +import { getTimezoneOffset } from "@/utils/timezone"; import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog"; import { EditModeDialog } from "@/components/Event/EditModeDialog"; diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index d8b7da9..6098874 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -1,8 +1,5 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { - getTimezoneOffset, - resolveTimezone, -} from "@/components/Calendar/TimezoneSelector"; +import { getTimezoneOffset, resolveTimezone } from "@/utils/timezone"; import { updateTempCalendar } from "@/components/Calendar/utils/calendarUtils"; import { ResponsiveDialog } from "@/components/Dialog"; import EventFormFields from "@/components/Event/EventFormFields"; diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 8f77b80..55ccd86 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -25,7 +25,11 @@ import { showErrorNotification, } from "@/utils/eventFormTempStorage"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { browserDefaultTimeZone } from "@/utils/timezone"; +import { + browserDefaultTimeZone, + resolveTimezone, + getTimezoneOffset, +} from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils"; import { Box, Button } from "@linagora/twake-mui"; @@ -108,39 +112,10 @@ function EventUpdateModal({ ); }, [calendarsList, user.userData?.openpaasId]); - // Helper function to resolve timezone aliases - const resolveTimezone = (tzName: string): string => { - if (TIMEZONES.zones[tzName]) { - return tzName; - } - if (TIMEZONES.aliases[tzName]) { - return TIMEZONES.aliases[tzName].aliasTo; - } - return tzName; - }; - const timezoneList = useMemo(() => { const zones = Object.keys(TIMEZONES.zones).sort(); const browserTz = resolveTimezone(browserDefaultTimeZone); - const getTimezoneOffset = (tzName: string): string => { - const resolvedTz = resolveTimezone(tzName); - const tzData = TIMEZONES.zones[resolvedTz]; - if (!tzData) return ""; - - const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/); - if (!icsMatch) return ""; - - const offset = icsMatch[1]; - const hours = parseInt(offset.slice(0, 3)); - const minutes = parseInt(offset.slice(3)); - - if (minutes === 0) { - return `UTC${hours >= 0 ? "+" : ""}${hours}`; - } - return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`; - }; - return { zones, browserTz, getTimezoneOffset }; }, []); diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index acd139d..b6e2c5c 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -1,8 +1,6 @@ -import { - convertFormDateTimeToISO, - detectDateTimeFormat, -} from "@/components/Event/utils/dateTimeHelpers"; +import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { resolveTimezoneId, convertEventDateTimeToISO } from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import ICAL from "ical.js"; import moment from "moment-timezone"; @@ -11,18 +9,6 @@ import { createAttendee } from "../User/models/attendee.mapper"; import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes"; type RawEntry = [string, Record, string, any]; -function resolveTimezoneId(tzid?: string): string | undefined { - if (!tzid) return undefined; - if (TIMEZONES.zones[tzid]) { - return tzid; - } - const alias = TIMEZONES.aliases[tzid]; - if (alias) { - return alias.aliasTo; - } - return tzid; -} - function inferTimezoneFromValue( params: Record | undefined, value: string @@ -213,14 +199,14 @@ export function parseCalendarEvent( } if (!event.allday && event.start && eventTimezone) { - const startISO = convertDateTimeStringToISO(event.start, eventTimezone); + const startISO = convertEventDateTimeToISO(event.start, eventTimezone); if (startISO) { event.start = startISO; } } if (!event.allday && event.end && eventTimezone) { - const endISO = convertDateTimeStringToISO(event.end, eventTimezone); + const endISO = convertEventDateTimeToISO(event.end, eventTimezone); if (endISO) { event.end = endISO; } @@ -229,32 +215,6 @@ export function parseCalendarEvent( return event as CalendarEvent; } -function convertDateTimeStringToISO( - datetime: string, - timezone: string -): string | undefined { - if (!datetime || !timezone) return undefined; - - if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { - return undefined; - } - - const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; - if (dateOnlyRegex.test(datetime)) { - return undefined; - } - - const format = detectDateTimeFormat(datetime); - const momentDate = moment.tz(datetime, format, timezone); - if (!momentDate.isValid()) { - console.warn( - `[convertDateTimeStringToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${timezone}"` - ); - return undefined; - } - return momentDate.toISOString(); -} - export function calendarEventToJCal( event: CalendarEvent, calOwnerEmail?: string diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts index 659e64e..a9a640e 100644 --- a/src/features/Events/formHelpers.ts +++ b/src/features/Events/formHelpers.ts @@ -1,4 +1,4 @@ -import { resolveTimezone } from "@/components/Calendar/TimezoneSelector"; +import { resolveTimezone } from "@/utils/timezone"; import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { browserDefaultTimeZone } from "@/utils/timezone"; diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index de39794..aff22f2 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -1,8 +1,6 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { - getTimezoneOffset, - useTimeZoneList, -} from "@/components/Calendar/TimezoneSelector"; +import { useTimeZoneList } from "@/components/Calendar/TimezoneSelector"; +import { getTimezoneOffset } from "@/utils/timezone"; import { TimezoneAutocomplete } from "@/components/Timezone/TimezoneAutocomplete"; import { browserDefaultTimeZone } from "@/utils/timezone"; import { diff --git a/src/utils/timezone.ts b/src/utils/timezone.ts index 6c7f92f..231534c 100644 --- a/src/utils/timezone.ts +++ b/src/utils/timezone.ts @@ -2,6 +2,8 @@ // Ensure ICAL is imported before using this module. import ICAL from "ical.js"; +import moment from "moment-timezone"; +import { detectDateTimeFormat } from "@/components/Event/utils/dateTimeHelpers"; // TIMEZONES data must be imported or defined separately. import { TIMEZONES } from "./timezone-data"; @@ -39,3 +41,61 @@ function findTimezone(tzid: string): any { throw new Error(`Unknown timezone alias: ${tzid}`); } + +export function resolveTimezone(tzName: string): string { + if (TIMEZONES.zones[tzName]) { + return tzName; + } + if (TIMEZONES.aliases[tzName]) { + return TIMEZONES.aliases[tzName].aliasTo; + } + return tzName; +} + +export function resolveTimezoneId(tzid?: string): string | undefined { + if (!tzid) return undefined; + return resolveTimezone(tzid); +} + +export function convertEventDateTimeToISO( + datetime: string, + timezone: string, + options?: { isAllDay?: boolean } +): string | undefined { + if (!datetime || !timezone) return undefined; + if (options?.isAllDay) return undefined; + + if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { + return datetime; + } + + const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + if (dateOnlyRegex.test(datetime)) { + return undefined; + } + + const format = detectDateTimeFormat(datetime); + const momentDate = moment.tz(datetime, format, timezone); + if (!momentDate.isValid()) { + console.warn( + `[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${timezone}"` + ); + return undefined; + } + return momentDate.toISOString(); +} + +export function getTimezoneOffset( + tzName: string, + date: Date = new Date() +): string { + const fmt = new Intl.DateTimeFormat(undefined, { + timeZone: tzName, + timeZoneName: "shortOffset", + }); + + const currentDate = moment(date).isValid() ? date : new Date(); + const parts = fmt.formatToParts(currentDate); + const offsetPart = parts.find((p) => p.type === "timeZoneName"); + return offsetPart?.value.replace("GMT", "UTC") ?? ""; +}