From a9e7b1ac82dcfe4ad31e8dd67bf790de1581db65 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:32:27 +0100 Subject: [PATCH] [refactor] preparation for PR adressing #638 (#644) Co-authored-by: Camille Moussu --- .../features/Calendars/CalendarSlice.test.tsx | 4 +- __test__/features/Events/EventApi.test.tsx | 2 +- __test__/features/Events/eventUtils.test.ts | 2 +- __test__/utils/buildDelegatedEvent.test.ts | 2 +- .../Calendars/services/refreshCalendar.ts | 2 +- .../Calendars/utils/extractCalendarEvents.ts | 4 +- src/features/Events/EventApi.ts | 2 +- src/features/Events/EventModal.tsx | 2 +- src/features/Events/EventUpdateModal.tsx | 2 +- src/features/Events/api/updateSeries.ts | 2 +- src/features/Events/eventUtils.ts | 581 ------------------ .../moveEventBetweenCalendars.ts | 2 +- .../Events/utils/buildDelegatedEventURL.ts | 16 + .../Events/utils/calendarEventToJCal.ts | 18 + .../utils/combineMasterDateWithFormTime.ts | 74 +++ .../utils/detectRecurringEventChanges.ts | 59 ++ src/features/Events/utils/formatDateToICal.ts | 37 ++ src/features/Events/utils/index.ts | 11 + .../Events/utils/inferTimezoneFromValue.ts | 20 + src/features/Events/utils/makeTimezone.ts | 21 + src/features/Events/utils/makeVevent.ts | 125 ++++ .../Events/utils/normalizeRepetition.ts | 26 + .../Events/utils/normalizeTimezone.ts | 11 + .../Events/utils/parseCalendarEvent.ts | 216 +++++++ 24 files changed, 646 insertions(+), 595 deletions(-) delete mode 100644 src/features/Events/eventUtils.ts create mode 100644 src/features/Events/utils/buildDelegatedEventURL.ts create mode 100644 src/features/Events/utils/calendarEventToJCal.ts create mode 100644 src/features/Events/utils/combineMasterDateWithFormTime.ts create mode 100644 src/features/Events/utils/detectRecurringEventChanges.ts create mode 100644 src/features/Events/utils/formatDateToICal.ts create mode 100644 src/features/Events/utils/index.ts create mode 100644 src/features/Events/utils/inferTimezoneFromValue.ts create mode 100644 src/features/Events/utils/makeTimezone.ts create mode 100644 src/features/Events/utils/makeVevent.ts create mode 100644 src/features/Events/utils/normalizeRepetition.ts create mode 100644 src/features/Events/utils/normalizeTimezone.ts create mode 100644 src/features/Events/utils/parseCalendarEvent.ts diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index db6ee91..b686e18 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -15,8 +15,6 @@ import { getEventAsync, getTempCalendarsListAsync, patchACLCalendarAsync, - patchCalendarAsync, - removeCalendarAsync, } from "@/features/Calendars/services"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import * as userAPI from "@/features/User/userAPI"; @@ -26,7 +24,7 @@ import { configureStore } from "@reduxjs/toolkit"; jest.mock("@/features/Calendars/CalendarApi"); jest.mock("@/features/User/userAPI"); jest.mock("@/features/Events/EventApi"); -jest.mock("@/features/Events/eventUtils"); +jest.mock("@/features/Events/utils"); jest.mock("@/utils/apiUtils"); describe("CalendarSlice", () => { diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index 2a37a38..f3b907f 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -7,7 +7,7 @@ import { searchEvent, } from "@/features/Events/EventApi"; import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { calendarEventToJCal } from "@/features/Events/eventUtils"; +import { calendarEventToJCal } from "@/features/Events/utils"; import { clientConfig } from "@/features/User/oidcAuth"; import { api } from "@/utils/apiUtils"; diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 822d4e1..b6cda3c 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -8,7 +8,7 @@ import { normalizeRepetition, normalizeTimezone, parseCalendarEvent, -} from "@/features/Events/eventUtils"; +} from "@/features/Events/utils"; describe("parseCalendarEvent", () => { const baseColor = { light: "#00FF00" }; diff --git a/__test__/utils/buildDelegatedEvent.test.ts b/__test__/utils/buildDelegatedEvent.test.ts index f1249f7..0a31073 100644 --- a/__test__/utils/buildDelegatedEvent.test.ts +++ b/__test__/utils/buildDelegatedEvent.test.ts @@ -1,5 +1,5 @@ import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { buildDelegatedEventURL } from "@/features/Events/eventUtils"; +import { buildDelegatedEventURL } from "@/features/Events/utils"; const makeCalendar = (link: string): Calendar => ({ diff --git a/src/features/Calendars/services/refreshCalendar.ts b/src/features/Calendars/services/refreshCalendar.ts index a1f06b6..325ce60 100644 --- a/src/features/Calendars/services/refreshCalendar.ts +++ b/src/features/Calendars/services/refreshCalendar.ts @@ -1,5 +1,5 @@ import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { buildDelegatedEventURL } from "@/features/Events/eventUtils"; +import { buildDelegatedEventURL } from "@/features/Events/utils"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; import pMap from "p-map"; diff --git a/src/features/Calendars/utils/extractCalendarEvents.ts b/src/features/Calendars/utils/extractCalendarEvents.ts index 4dccacb..d7c12df 100644 --- a/src/features/Calendars/utils/extractCalendarEvents.ts +++ b/src/features/Calendars/utils/extractCalendarEvents.ts @@ -1,6 +1,6 @@ -import { defaultColors } from "@/utils/defaultColors"; import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { parseCalendarEvent } from "@/features/Events/eventUtils"; +import { parseCalendarEvent } from "@/features/Events/utils"; +import { defaultColors } from "@/utils/defaultColors"; import { CalDavItem } from "../api/types"; import { Calendar } from "../CalendarTypes"; import { VCalComponent } from "../types/CalendarData"; diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index fcd5e8d..44d8a05 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -16,7 +16,7 @@ import { makeTimezone, makeVevent, parseCalendarEvent, -} from "./eventUtils"; +} from "./utils"; export async function reportEvent( event: CalendarEvent, diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index f0f1c74..9c8fb1c 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -41,8 +41,8 @@ import { Calendar } from "../Calendars/CalendarTypes"; import { putEventAsync } from "../Calendars/services"; import { userAttendee } from "../User/models/attendee"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { buildDelegatedEventURL } from "./eventUtils"; import { useEventOrganizer } from "./useEventOrganizer"; +import { buildDelegatedEventURL } from "./utils/buildDelegatedEventURL"; function EventPopover({ open, diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 5685743..e89e991 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -42,8 +42,8 @@ import { Calendar } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/models/attendee"; import { deleteEvent, getEvent, putEvent } from "./EventApi"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { detectRecurringEventChanges } from "./eventUtils"; import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars"; +import { detectRecurringEventChanges } from "./utils/detectRecurringEventChanges"; function EventUpdateModal({ eventId, diff --git a/src/features/Events/api/updateSeries.ts b/src/features/Events/api/updateSeries.ts index add5a4c..3de5ae1 100644 --- a/src/features/Events/api/updateSeries.ts +++ b/src/features/Events/api/updateSeries.ts @@ -6,7 +6,7 @@ import { } from "../../Calendars/types/CalendarData"; import { getAllRecurrentEvent } from "../EventApi"; import { CalendarEvent } from "../EventsTypes"; -import { makeTimezone, makeVevent } from "../eventUtils"; +import { makeTimezone, makeVevent } from "../utils"; const METADATA_FIELDS = [ "summary", diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts deleted file mode 100644 index 112f5fe..0000000 --- a/src/features/Events/eventUtils.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; -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 { Calendar } from "../Calendars/CalendarTypes"; -import { - RepetitionRule, - VCalComponent, - VObjectProperty, -} from "../Calendars/types/CalendarData"; -import { userAttendee } from "../User/models/attendee"; -import { createAttendee } from "../User/models/attendee.mapper"; -import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes"; - -function inferTimezoneFromValue( - params: Record | undefined -): string | undefined { - if (!params) { - return undefined; - } - - const tzParam = - params.tzid || params.TZID || params.Tzid || params.tZid || params.tzId; - - if (tzParam) { - const resolved = resolveTimezoneId(tzParam); - if (resolved) { - return resolved; - } - } - return undefined; -} - -export function parseCalendarEvent( - data: VObjectProperty[], - color: Record, - calendar: Calendar, - eventURL: string, - valarm?: VCalComponent -): CalendarEvent { - const event: Partial = { color, attendee: [] }; - let recurrenceId; - let duration; - const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; - - for (const [key, params, , value] of data) { - switch (key.toLowerCase()) { - case "uid": - event.uid = String(value); - break; - case "transp": - event.transp = String(value); - break; - case "dtstart": { - event.start = String(value); - const detectedTz = inferTimezoneFromValue( - params as Record - ); - if (detectedTz) { - event.timezone = detectedTz; - } - if (dateRegex.test(String(value))) { - event.allday = true; - } else { - event.allday = false; - } - break; - } - case "dtend": { - event.end = String(value); - if (!event.timezone) { - const detectedTz = inferTimezoneFromValue( - params as Record - ); - if (detectedTz) { - event.timezone = detectedTz; - } - } - if (dateRegex.test(String(value))) { - event.allday = true; - } else { - event.allday = false; - } - break; - } - case "class": - event.class = String(value); - break; - case "x-openpaas-videoconference": - event.x_openpass_videoconference = String(value); - break; - case "summary": - event.title = String(value); - break; - case "description": - event.description = String(value); - break; - case "location": - event.location = String(value); - break; - case "organizer": { - const paramsObj = params as Record; - event.organizer = { - cn: paramsObj?.cn ?? "", - cal_address: String(value).replace(/^mailto:/i, ""), - }; - break; - } - case "attendee": { - const paramsObj = params as Record; - if ( - !event.attendee?.find( - (attendee) => - attendee.cal_address === String(value).replace(/^mailto:/i, "") - ) - ) { - (event.attendee as userAttendee[]).push( - createAttendee({ - cn: paramsObj?.cn, - cal_address: String(value).replace(/^mailto:/i, ""), - partstat: paramsObj?.partstat as userAttendee["partstat"], - rsvp: paramsObj?.rsvp as userAttendee["rsvp"], - role: paramsObj?.role as userAttendee["role"], - cutype: paramsObj?.cutype as userAttendee["cutype"], - }) - ); - } - break; - } - case "dtstamp": - event.stamp = String(value); - break; - case "sequence": - event.sequence = Number(value); - break; - case "recurrence-id": - recurrenceId = String(value); - break; - case "exdate": - if (!event.exdates) event.exdates = []; - event.exdates.push(String(value)); - break; - case "status": - event.status = String(value); - break; - case "duration": - duration = String(value); - break; - case "rrule": { - const ruleValue = value as RepetitionRule; - event.repetition = { freq: ruleValue.freq.toLowerCase() }; - if (ruleValue.byday) { - if (typeof ruleValue.byday === "string") { - event.repetition.byday = [ruleValue.byday]; - } else { - event.repetition.byday = ruleValue.byday; - } - } - if (ruleValue.until) { - event.repetition.endDate = ruleValue.until; - } - if (ruleValue.count) { - event.repetition.occurrences = ruleValue.count; - } - if (ruleValue.interval) { - event.repetition.interval = ruleValue.interval; - } - break; - } - } - } - if (recurrenceId && event.uid) { - event.uid = `${event.uid}/${recurrenceId}`; - event.recurrenceId = recurrenceId; - } - - if (valarm) { - event.alarm = {} as AlarmObject; - for (const [key, , , value] of valarm[1]) { - switch (key.toLowerCase()) { - case "action": - event.alarm.action = String(value); - break; - case "trigger": - event.alarm.trigger = String(value); - break; - } - } - } - event.calId = calendar.id; - event.URL = calendar.delegated - ? buildDelegatedEventURL(calendar, eventURL) - : eventURL; - if (!event.uid || !event.start) { - console.error( - `missing crucial event param in calendar ${calendar.id} `, - data - ); - event.error = `missing crucial event param in calendar ${calendar.id} `; - } - - const eventTimezone = event.timezone; - - if (!event.end) { - const start = event.start ? new Date(event.start) : new Date(); - const timeToAdd = duration - ? moment.duration(duration).asMilliseconds() - : moment.duration(30, "minutes").asMilliseconds(); - const artificialEnd = new Date(start.getTime() + timeToAdd); - event.end = formatDateToICal(artificialEnd, false, eventTimezone); - } - - if (!event.allday && event.start && eventTimezone) { - const startISO = convertEventDateTimeToISO(event.start, eventTimezone); - if (startISO) { - event.start = startISO; - } - } - - if (!event.allday && event.end && eventTimezone) { - const endISO = convertEventDateTimeToISO(event.end, eventTimezone); - if (endISO) { - event.end = endISO; - } - } - - return event as CalendarEvent; -} - -export function calendarEventToJCal( - event: CalendarEvent, - calOwnerEmail?: string -) { - const tzid = event.timezone; // Fallback to UTC if no timezone provided - - const vevent = makeVevent(event, tzid, calOwnerEmail); - - const timezoneData = TIMEZONES.zones[event.timezone]; - const vtimezone = makeTimezone(timezoneData, event); - - return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; -} - -export function makeTimezone( - timezoneData: { ics: string; latitude: string; longitude: string }, - event: CalendarEvent -) { - if (!timezoneData) { - return new ICAL.Timezone({ - component: TIMEZONES.zones["Etc/UTC"].ics, - tzid: "Etc/UTC", - }); - } - return new ICAL.Timezone({ - component: timezoneData.ics, - tzid: event.timezone, - }); -} - -export function makeVevent( - event: CalendarEvent, - tzid: string, - calOwnerEmail: string | undefined, - isMasterEvent?: boolean -) { - const vevent: [string, unknown[]] = [ - "vevent", - [ - ["uid", {}, "text", extractEventBaseUuid(event.uid)], - ["transp", {}, "text", event.transp ?? "OPAQUE"], - [ - "dtstart", - { tzid }, - event.allday ? "date" : "date-time", - formatDateToICal(new Date(event.start), event.allday ?? false, tzid), - ], - ["class", {}, "text", event.class ?? "PUBLIC"], - ["sequence", {}, "integer", event.sequence ?? 1], - [ - "x-openpaas-videoconference", - {}, - "unknown", - event.x_openpass_videoconference ?? null, - ], - ["summary", {}, "text", event.title ?? ""], - ], - ]; - if (event.alarm?.trigger) { - const valarm = [ - ["trigger", {}, "duration", event.alarm.trigger], - ["action", {}, "text", event.alarm.action], - ["attendee", {}, "cal-address", `mailto:${calOwnerEmail}`], - ["summary", {}, "text", event.title], - [ - "description", - {}, - "text", - "This is an automatic alarm sent by OpenPaas", - ], - ]; - vevent.push([["valarm", valarm]]); - } - - if (event.end) { - vevent[1].push([ - "dtend", - { tzid }, - event.allday ? "date" : "date-time", - formatDateToICal(new Date(event.end), event.allday ?? false, tzid), - ]); - } - if (event.organizer) { - vevent[1].push([ - "organizer", - { cn: event.organizer.cn }, - "cal-address", - `mailto:${event.organizer.cal_address}`, - ]); - } - if (event.location) { - vevent[1].push(["location", {}, "text", event.location]); - } - if (event.recurrenceId && !isMasterEvent) { - vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]); - } - if (event.description) { - vevent[1].push(["description", {}, "text", event.description]); - } - if (event.repetition?.freq) { - const repetitionRule: RepetitionRule = { freq: event.repetition.freq }; - if (event.repetition.interval) { - repetitionRule.interval = event.repetition.interval; - } - if (event.repetition.occurrences) { - repetitionRule.count = event.repetition.occurrences; - } - if (event.repetition.endDate) { - repetitionRule.until = event.repetition.endDate; - } - if ( - event.repetition.byday !== null && - event.repetition.byday !== undefined - ) { - repetitionRule.byday = event.repetition.byday; - } - vevent[1].push(["rrule", {}, "recur", repetitionRule]); - } - - event.attendee.forEach((att) => { - const attendee: Record = { - partstat: att.partstat, - rsvp: att.rsvp, - role: att.role, - cutype: att.cutype, - }; - if (att.cn) { - attendee.cn = att.cn; - } - vevent[1].push([ - "attendee", - attendee, - "cal-address", - `mailto:${att.cal_address}`, - ]); - }); - - if (event.exdates && event.exdates.length > 0) { - event.exdates.forEach((ex) => { - vevent[1].push([ - "exdate", - { tzid }, - "date-time", - formatDateToICal(new Date(ex), false, tzid), - ]); - }); - } - - return vevent; -} - -function formatDateToICal(date: Date, allday: boolean, timezone?: string) { - const pad = (n: number) => n.toString().padStart(2, "0"); - - if (allday) { - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - return `${year}-${month}-${day}`; - } - - if (timezone) { - const momentDate = moment.utc(date).tz(timezone); - if (momentDate.isValid()) { - return momentDate.format("YYYY-MM-DDTHH:mm:ss"); - } - } - - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hours = pad(date.getUTCHours()); - const minutes = pad(date.getUTCMinutes()); - const seconds = pad(date.getUTCSeconds()); - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; -} - -/** - * Combine master event's date with form input's time for recurring event updates - * Preserves original start date while applying new time from user input - */ -export function combineMasterDateWithFormTime( - masterEvent: CalendarEvent, - formStart: string, - formEnd: string, - timezone: string, - isAllDay: boolean, - formatDateTimeInTimezone: (iso: string, tz: string) => string -): { startDate: string; endDate: string } { - if (isAllDay) { - // Extract date string from master event (which is ISO UTC string) - const startDateStr = new Date(masterEvent.start) - .toISOString() - .split("T")[0]; - const endDateStr = masterEvent.end - ? new Date(masterEvent.end).toISOString().split("T")[0] - : startDateStr; - - // Parse date string and create Date at UTC midnight to avoid timezone offset issues - const [startYear, startMonth, startDay] = startDateStr - .split("-") - .map(Number); - const [endYear, endMonth, endDay] = endDateStr.split("-").map(Number); - const startDateObj = new Date( - Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) - ); - const endDateObj = new Date( - Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) - ); - - return { - startDate: startDateObj.toISOString(), - endDate: endDateObj.toISOString(), - }; - } - - // For timed events: combine master's date with form's time - const masterFormattedStart = formatDateTimeInTimezone( - masterEvent.start, - timezone - ); - const masterFormattedEnd = masterEvent.end - ? formatDateTimeInTimezone(masterEvent.end, timezone) - : masterFormattedStart; - - // Extract date portion from master (YYYY-MM-DD) - const masterDatePart = masterFormattedStart.split("T")[0]; - const masterEndDatePart = masterFormattedEnd.split("T")[0]; - - // Extract time portion from form input (HH:MM or HH:MM:SS) - const formTimePart = formStart.includes("T") - ? formStart.split("T")[1] - : formStart.substring(11); - const formEndTimePart = formEnd.includes("T") - ? formEnd.split("T")[1] - : formEnd.substring(11); - - // Combine master's date + form's time - const combinedStartStr = `${masterDatePart}T${formTimePart}`; - const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; - - // Parse and convert to ISO - const startDate = convertFormDateTimeToISO(combinedStartStr, timezone); - const endDate = convertFormDateTimeToISO(combinedEndStr, timezone); - - return { startDate, endDate }; -} - -/** - * Normalize repetition object for accurate comparison - */ -export function normalizeRepetition(repetition: RepetitionObject | undefined): { - freq: string; - interval: number; - byday: string[] | null; - occurrences: number | null; - endDate: string | null; -} | null { - if (!repetition || !repetition.freq) return null; - - return { - freq: repetition.freq, - interval: repetition.interval || 1, - byday: - !repetition.byday || repetition.byday.length === 0 - ? null - : [...repetition.byday].sort(), - occurrences: repetition.occurrences || null, - endDate: repetition.endDate || null, - }; -} - -/** - * Normalize timezone by resolving aliases - */ -export function normalizeTimezone( - timezone: string | undefined | null, - resolveTimezone: (tz: string) => string -): string | null { - if (!timezone) return null; - return resolveTimezone(timezone); -} - -/** - * Detect what changed in recurring event update - */ -export function detectRecurringEventChanges( - oldEvent: CalendarEvent, - newData: { - repetition: RepetitionObject; - timezone: string; - allday: boolean; - start: string; - end: string; - }, - masterEventData: CalendarEvent | null, - resolveTimezone: (tz: string) => string -): { - timeChanged: boolean; - timezoneChanged: boolean; - repetitionRulesChanged: boolean; -} { - const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC"); - const newTimezone = resolveTimezone(newData.timezone || "UTC"); - const timezoneChanged = oldTimezone !== newTimezone; - - // Use master event as the source of truth for the "old" times, - // falling back to the clicked instance if master isn't available. - const oldStart = masterEventData?.start || oldEvent.start; - const oldEnd = masterEventData?.end || oldEvent.end; - - // Parse old times (ISO strings from the server) into the event's timezone - // and extract HH:mm for comparison. - const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm"); - const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm"); - // Parse new times from the form. These may be either: - // - local datetime strings like "2025-01-15T10:00" (from the form) - // - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted) - // moment.tz with a format avoids ambiguous parsing in both cases. - const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm"); - const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm"); - - const timeChanged = - oldStartTime !== newStartTime || oldEndTime !== newEndTime; - - const repetitionRulesChanged = - JSON.stringify(normalizeRepetition(oldEvent.repetition)) !== - JSON.stringify(normalizeRepetition(newData.repetition)) || - timezoneChanged || - oldEvent.allday !== newData.allday || - timeChanged; - - return { - timeChanged, - timezoneChanged, - repetitionRulesChanged, - }; -} - -export function buildDelegatedEventURL( - calendar: Calendar, - eventURL: string -): string { - const calendarBasePath = calendar.link.replace(/\.json$/, ""); - const eventFilename = eventURL.split("/").pop(); - if (!eventFilename) { - throw new Error(`Cannot extract filename from event URL: ${eventURL}`); - } - return `${calendarBasePath}/${eventFilename}`; -} diff --git a/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts index 7e8da63..8a21463 100644 --- a/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts +++ b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts @@ -11,7 +11,7 @@ import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { makeDisplayName } from "@/utils/makeDisplayName"; import { CalendarEvent } from "../EventsTypes"; -import { buildDelegatedEventURL } from "../eventUtils"; +import { buildDelegatedEventURL } from "../utils/buildDelegatedEventURL"; export interface MoveEventBetweenCalendarsParams { dispatch: AppDispatch; diff --git a/src/features/Events/utils/buildDelegatedEventURL.ts b/src/features/Events/utils/buildDelegatedEventURL.ts new file mode 100644 index 0000000..b8839c7 --- /dev/null +++ b/src/features/Events/utils/buildDelegatedEventURL.ts @@ -0,0 +1,16 @@ +import { Calendar } from "@/features/Calendars/CalendarTypes"; + +export function buildDelegatedEventURL( + calendar: Calendar, + eventURL: string +): string { + if (!calendar.link || !calendar.link.endsWith(".json")) { + throw new Error(`Invalid calendar link format: ${calendar.link}`); + } + const calendarBasePath = calendar.link.replace(/\.json$/, ""); + const eventFilename = eventURL.split("/").pop(); + if (!eventFilename) { + throw new Error(`Cannot extract filename from event URL: ${eventURL}`); + } + return `${calendarBasePath}/${eventFilename}`; +} diff --git a/src/features/Events/utils/calendarEventToJCal.ts b/src/features/Events/utils/calendarEventToJCal.ts new file mode 100644 index 0000000..81f28e6 --- /dev/null +++ b/src/features/Events/utils/calendarEventToJCal.ts @@ -0,0 +1,18 @@ +import { TIMEZONES } from "@/utils/timezone-data"; +import { CalendarEvent } from "../EventsTypes"; +import { makeVevent } from "./makeVevent"; +import { makeTimezone } from "./makeTimezone"; + +export function calendarEventToJCal( + event: CalendarEvent, + calOwnerEmail?: string +) { + const tzid = event.timezone; + + const vevent = makeVevent(event, tzid, calOwnerEmail); + + const timezoneData = TIMEZONES.zones[event.timezone]; + const vtimezone = makeTimezone(timezoneData, event); + + return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; +} diff --git a/src/features/Events/utils/combineMasterDateWithFormTime.ts b/src/features/Events/utils/combineMasterDateWithFormTime.ts new file mode 100644 index 0000000..e012827 --- /dev/null +++ b/src/features/Events/utils/combineMasterDateWithFormTime.ts @@ -0,0 +1,74 @@ +import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; +import { CalendarEvent } from "../EventsTypes"; + +/** + * Combine master event's date with form input's time for recurring event updates + * Preserves original start date while applying new time from user input + */ + +export function combineMasterDateWithFormTime( + masterEvent: CalendarEvent, + formStart: string, + formEnd: string, + timezone: string, + isAllDay: boolean, + formatDateTimeInTimezone: (iso: string, tz: string) => string +): { startDate: string; endDate: string } { + if (isAllDay) { + // Extract date string from master event (which is ISO UTC string) + const startDateStr = new Date(masterEvent.start) + .toISOString() + .split("T")[0]; + const endDateStr = masterEvent.end + ? new Date(masterEvent.end).toISOString().split("T")[0] + : startDateStr; + + // Parse date string and create Date at UTC midnight to avoid timezone offset issues + const [startYear, startMonth, startDay] = startDateStr + .split("-") + .map(Number); + const [endYear, endMonth, endDay] = endDateStr.split("-").map(Number); + const startDateObj = new Date( + Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) + ); + const endDateObj = new Date( + Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) + ); + + return { + startDate: startDateObj.toISOString(), + endDate: endDateObj.toISOString(), + }; + } + + // For timed events: combine master's date with form's time + const masterFormattedStart = formatDateTimeInTimezone( + masterEvent.start, + timezone + ); + const masterFormattedEnd = masterEvent.end + ? formatDateTimeInTimezone(masterEvent.end, timezone) + : masterFormattedStart; + + // Extract date portion from master (YYYY-MM-DD) + const masterDatePart = masterFormattedStart.split("T")[0]; + const masterEndDatePart = masterFormattedEnd.split("T")[0]; + + // Extract time portion from form input (HH:MM or HH:MM:SS) + const formTimePart = formStart.includes("T") + ? formStart.split("T")[1] + : formStart.substring(11); + const formEndTimePart = formEnd.includes("T") + ? formEnd.split("T")[1] + : formEnd.substring(11); + + // Combine master's date + form's time + const combinedStartStr = `${masterDatePart}T${formTimePart}`; + const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; + + // Parse and convert to ISO + const startDate = convertFormDateTimeToISO(combinedStartStr, timezone); + const endDate = convertFormDateTimeToISO(combinedEndStr, timezone); + + return { startDate, endDate }; +} diff --git a/src/features/Events/utils/detectRecurringEventChanges.ts b/src/features/Events/utils/detectRecurringEventChanges.ts new file mode 100644 index 0000000..d6dec53 --- /dev/null +++ b/src/features/Events/utils/detectRecurringEventChanges.ts @@ -0,0 +1,59 @@ +import moment from "moment-timezone"; +import { CalendarEvent, RepetitionObject } from "../EventsTypes"; +import { normalizeRepetition } from "./normalizeRepetition"; + +/** + * Detect what changed in recurring event update + */ +export function detectRecurringEventChanges( + oldEvent: CalendarEvent, + newData: { + repetition: RepetitionObject; + timezone: string; + allday: boolean; + start: string; + end: string; + }, + masterEventData: CalendarEvent | null, + resolveTimezone: (tz: string) => string +): { + timeChanged: boolean; + timezoneChanged: boolean; + repetitionRulesChanged: boolean; +} { + const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC"); + const newTimezone = resolveTimezone(newData.timezone || "UTC"); + const timezoneChanged = oldTimezone !== newTimezone; + + // Use master event as the source of truth for the "old" times, + // falling back to the clicked instance if master isn't available. + const oldStart = masterEventData?.start || oldEvent.start; + const oldEnd = masterEventData?.end || oldEvent.end; + + // Parse old times (ISO strings from the server) into the event's timezone + // and extract HH:mm for comparison. + const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm"); + const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm"); + // Parse new times from the form. These may be either: + // - local datetime strings like "2025-01-15T10:00" (from the form) + // - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted) + // moment.tz with a format avoids ambiguous parsing in both cases. + const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm"); + const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm"); + + const timeChanged = + oldStartTime !== newStartTime || oldEndTime !== newEndTime; + + const repetitionRulesChanged = + JSON.stringify(normalizeRepetition(oldEvent.repetition)) !== + JSON.stringify(normalizeRepetition(newData.repetition)) || + timezoneChanged || + oldEvent.allday !== newData.allday || + timeChanged; + + return { + timeChanged, + timezoneChanged, + repetitionRulesChanged, + }; +} diff --git a/src/features/Events/utils/formatDateToICal.ts b/src/features/Events/utils/formatDateToICal.ts new file mode 100644 index 0000000..f65b365 --- /dev/null +++ b/src/features/Events/utils/formatDateToICal.ts @@ -0,0 +1,37 @@ +import moment from "moment-timezone"; + +export function formatDateToICal( + date: Date, + allday: boolean, + timezone?: string +) { + const pad = (n: number) => n.toString().padStart(2, "0"); + + if (allday) { + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + return `${year}-${month}-${day}`; + } + + if (timezone) { + if (!moment.tz.zone(timezone)) { + console.warn( + `[formatDateToICal] Unrecognized timezone: "${timezone}", falling back to UTC` + ); + } else { + const momentDate = moment.utc(date).tz(timezone); + if (momentDate.isValid()) { + return momentDate.format("YYYY-MM-DDTHH:mm:ss"); + } + } + } + + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; +} diff --git a/src/features/Events/utils/index.ts b/src/features/Events/utils/index.ts new file mode 100644 index 0000000..4fba4f4 --- /dev/null +++ b/src/features/Events/utils/index.ts @@ -0,0 +1,11 @@ +export { buildDelegatedEventURL } from "./buildDelegatedEventURL"; +export { calendarEventToJCal } from "./calendarEventToJCal"; +export { combineMasterDateWithFormTime } from "./combineMasterDateWithFormTime"; +export { detectRecurringEventChanges } from "./detectRecurringEventChanges"; +export { formatDateToICal } from "./formatDateToICal"; +export { inferTimezoneFromValue } from "./inferTimezoneFromValue"; +export { makeTimezone } from "./makeTimezone"; +export { makeVevent } from "./makeVevent"; +export { normalizeRepetition } from "./normalizeRepetition"; +export { normalizeTimezone } from "./normalizeTimezone"; +export { parseCalendarEvent } from "./parseCalendarEvent"; diff --git a/src/features/Events/utils/inferTimezoneFromValue.ts b/src/features/Events/utils/inferTimezoneFromValue.ts new file mode 100644 index 0000000..33a9967 --- /dev/null +++ b/src/features/Events/utils/inferTimezoneFromValue.ts @@ -0,0 +1,20 @@ +import { resolveTimezoneId } from "@/utils/timezone"; + +export function inferTimezoneFromValue( + params: Record | undefined +): string | undefined { + if (!params) { + return undefined; + } + + const tzKey = Object.keys(params).find((k) => k.toLowerCase() === "tzid"); + const tzParam = tzKey ? params[tzKey] : undefined; + + if (tzParam) { + const resolved = resolveTimezoneId(tzParam); + if (resolved) { + return resolved; + } + } + return undefined; +} diff --git a/src/features/Events/utils/makeTimezone.ts b/src/features/Events/utils/makeTimezone.ts new file mode 100644 index 0000000..d96ee23 --- /dev/null +++ b/src/features/Events/utils/makeTimezone.ts @@ -0,0 +1,21 @@ +import { TIMEZONES } from "@/utils/timezone-data"; +import ICAL from "ical.js"; +import { CalendarEvent } from "../EventsTypes"; + +export function makeTimezone( + timezoneData: + | { ics: string; latitude: string; longitude: string } + | undefined, + event: CalendarEvent +) { + if (!timezoneData) { + return new ICAL.Timezone({ + component: TIMEZONES.zones["Etc/UTC"].ics, + tzid: "Etc/UTC", + }); + } + return new ICAL.Timezone({ + component: timezoneData.ics, + tzid: event.timezone, + }); +} diff --git a/src/features/Events/utils/makeVevent.ts b/src/features/Events/utils/makeVevent.ts new file mode 100644 index 0000000..9398594 --- /dev/null +++ b/src/features/Events/utils/makeVevent.ts @@ -0,0 +1,125 @@ +import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { RepetitionRule } from "../../Calendars/types/CalendarData"; +import { CalendarEvent } from "../EventsTypes"; +import { formatDateToICal } from "./formatDateToICal"; + +export function makeVevent( + event: CalendarEvent, + tzid: string, + calOwnerEmail: string | undefined, + isMasterEvent?: boolean +) { + const vevent: [string, unknown[]] = [ + "vevent", + [ + ["uid", {}, "text", extractEventBaseUuid(event.uid)], + ["transp", {}, "text", event.transp ?? "OPAQUE"], + [ + "dtstart", + { tzid }, + event.allday ? "date" : "date-time", + formatDateToICal(new Date(event.start), event.allday ?? false, tzid), + ], + ["class", {}, "text", event.class ?? "PUBLIC"], + ["sequence", {}, "integer", event.sequence ?? 1], + [ + "x-openpaas-videoconference", + {}, + "unknown", + event.x_openpass_videoconference ?? null, + ], + ["summary", {}, "text", event.title ?? ""], + ], + ]; + if (event.alarm?.trigger) { + const valarm = [ + ["trigger", {}, "duration", event.alarm.trigger], + ["action", {}, "text", event.alarm.action], + ["attendee", {}, "cal-address", `mailto:${calOwnerEmail}`], + ["summary", {}, "text", event.title], + [ + "description", + {}, + "text", + "This is an automatic alarm sent by OpenPaas", + ], + ]; + vevent.push([["valarm", valarm]]); + } + + if (event.end) { + vevent[1].push([ + "dtend", + { tzid }, + event.allday ? "date" : "date-time", + formatDateToICal(new Date(event.end), event.allday ?? false, tzid), + ]); + } + if (event.organizer) { + vevent[1].push([ + "organizer", + { cn: event.organizer.cn }, + "cal-address", + `mailto:${event.organizer.cal_address}`, + ]); + } + if (event.location) { + vevent[1].push(["location", {}, "text", event.location]); + } + if (event.recurrenceId && !isMasterEvent) { + vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]); + } + if (event.description) { + vevent[1].push(["description", {}, "text", event.description]); + } + if (event.repetition?.freq) { + const repetitionRule: RepetitionRule = { freq: event.repetition.freq }; + if (event.repetition.interval) { + repetitionRule.interval = event.repetition.interval; + } + if (event.repetition.occurrences) { + repetitionRule.count = event.repetition.occurrences; + } + if (event.repetition.endDate) { + repetitionRule.until = event.repetition.endDate; + } + if ( + event.repetition.byday !== null && + event.repetition.byday !== undefined + ) { + repetitionRule.byday = event.repetition.byday; + } + vevent[1].push(["rrule", {}, "recur", repetitionRule]); + } + + event.attendee.forEach((att) => { + const attendee: Record = { + partstat: att.partstat, + rsvp: att.rsvp, + role: att.role, + cutype: att.cutype, + }; + if (att.cn) { + attendee.cn = att.cn; + } + vevent[1].push([ + "attendee", + attendee, + "cal-address", + `mailto:${att.cal_address}`, + ]); + }); + + if (event.exdates && event.exdates.length > 0) { + event.exdates.forEach((ex) => { + vevent[1].push([ + "exdate", + { tzid }, + "date-time", + formatDateToICal(new Date(ex), false, tzid), + ]); + }); + } + + return vevent; +} diff --git a/src/features/Events/utils/normalizeRepetition.ts b/src/features/Events/utils/normalizeRepetition.ts new file mode 100644 index 0000000..72a2453 --- /dev/null +++ b/src/features/Events/utils/normalizeRepetition.ts @@ -0,0 +1,26 @@ +import { RepetitionObject } from "../EventsTypes"; + +/** + * Normalize repetition object for accurate comparison + */ + +export function normalizeRepetition(repetition: RepetitionObject | undefined): { + freq: string; + interval: number; + byday: string[] | null; + occurrences: number | null; + endDate: string | null; +} | null { + if (!repetition || !repetition.freq) return null; + + return { + freq: repetition.freq, + interval: repetition.interval || 1, + byday: + !repetition.byday || repetition.byday.length === 0 + ? null + : [...repetition.byday].sort(), + occurrences: repetition.occurrences || null, + endDate: repetition.endDate || null, + }; +} diff --git a/src/features/Events/utils/normalizeTimezone.ts b/src/features/Events/utils/normalizeTimezone.ts new file mode 100644 index 0000000..57d9ad5 --- /dev/null +++ b/src/features/Events/utils/normalizeTimezone.ts @@ -0,0 +1,11 @@ +/** + * Normalize timezone by resolving aliases + */ + +export function normalizeTimezone( + timezone: string | undefined | null, + resolveTimezone: (tz: string) => string +): string | null { + if (!timezone) return null; + return resolveTimezone(timezone); +} diff --git a/src/features/Events/utils/parseCalendarEvent.ts b/src/features/Events/utils/parseCalendarEvent.ts new file mode 100644 index 0000000..ac8e4eb --- /dev/null +++ b/src/features/Events/utils/parseCalendarEvent.ts @@ -0,0 +1,216 @@ +import { convertEventDateTimeToISO } from "@/utils/timezone"; +import moment from "moment-timezone"; +import { Calendar } from "../../Calendars/CalendarTypes"; +import { + RepetitionRule, + VCalComponent, + VObjectProperty, +} from "../../Calendars/types/CalendarData"; +import { userAttendee } from "../../User/models/attendee"; +import { createAttendee } from "../../User/models/attendee.mapper"; +import { AlarmObject, CalendarEvent } from "../EventsTypes"; +import { inferTimezoneFromValue } from "./inferTimezoneFromValue"; +import { buildDelegatedEventURL } from "./buildDelegatedEventURL"; +import { formatDateToICal } from "./formatDateToICal"; + +export function parseCalendarEvent( + data: VObjectProperty[], + color: Record, + calendar: Calendar, + eventURL: string, + valarm?: VCalComponent +): CalendarEvent { + const event: Partial = { color, attendee: [] }; + let recurrenceId; + let duration; + const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + + for (const [key, params, , value] of data) { + switch (key.toLowerCase()) { + case "uid": + event.uid = String(value); + break; + case "transp": + event.transp = String(value); + break; + case "dtstart": { + event.start = String(value); + const detectedTz = inferTimezoneFromValue( + params as Record + ); + if (detectedTz) { + event.timezone = detectedTz; + } + if (dateRegex.test(String(value))) { + event.allday = true; + } else { + event.allday = false; + } + break; + } + case "dtend": { + event.end = String(value); + if (!event.timezone) { + const detectedTz = inferTimezoneFromValue( + params as Record + ); + if (detectedTz) { + event.timezone = detectedTz; + } + } + if (dateRegex.test(String(value))) { + event.allday = true; + } else { + event.allday = false; + } + break; + } + case "class": + if ( + ["PRIVATE", "PUBLIC", "CONFIDENTIAL"].includes( + String(value).toUpperCase() + ) + ) { + event.class = String(value).toUpperCase() as CalendarEvent["class"]; + } + break; + case "x-openpaas-videoconference": + event.x_openpass_videoconference = String(value); + break; + case "summary": + event.title = String(value); + break; + case "description": + event.description = String(value); + break; + case "location": + event.location = String(value); + break; + case "organizer": { + const paramsObj = params as Record; + event.organizer = { + cn: paramsObj?.cn ?? "", + cal_address: String(value).replace(/^mailto:/i, ""), + }; + break; + } + case "attendee": { + const paramsObj = params as Record; + if ( + !event.attendee?.find( + (attendee) => + attendee.cal_address === String(value).replace(/^mailto:/i, "") + ) + ) { + (event.attendee as userAttendee[]).push( + createAttendee({ + cn: paramsObj?.cn, + cal_address: String(value).replace(/^mailto:/i, ""), + partstat: paramsObj?.partstat as userAttendee["partstat"], + rsvp: paramsObj?.rsvp as userAttendee["rsvp"], + role: paramsObj?.role as userAttendee["role"], + cutype: paramsObj?.cutype as userAttendee["cutype"], + }) + ); + } + break; + } + case "dtstamp": + event.stamp = String(value); + break; + case "sequence": + event.sequence = Number(value); + break; + case "recurrence-id": + recurrenceId = String(value); + break; + case "exdate": + if (!event.exdates) event.exdates = []; + event.exdates.push(String(value)); + break; + case "status": + event.status = String(value); + break; + case "duration": + duration = String(value); + break; + case "rrule": { + const ruleValue = value as RepetitionRule; + event.repetition = { freq: ruleValue.freq.toLowerCase() }; + if (ruleValue.byday) { + if (typeof ruleValue.byday === "string") { + event.repetition.byday = [ruleValue.byday]; + } else { + event.repetition.byday = ruleValue.byday; + } + } + if (ruleValue.until) { + event.repetition.endDate = ruleValue.until; + } + if (ruleValue.count) { + event.repetition.occurrences = ruleValue.count; + } + if (ruleValue.interval) { + event.repetition.interval = ruleValue.interval; + } + break; + } + } + } + if (recurrenceId && event.uid) { + event.uid = `${event.uid}/${recurrenceId}`; + event.recurrenceId = recurrenceId; + } + + if (valarm) { + event.alarm = {} as AlarmObject; + for (const [key, , , value] of valarm[1]) { + switch (key.toLowerCase()) { + case "action": + event.alarm.action = String(value); + break; + case "trigger": + event.alarm.trigger = String(value); + break; + } + } + } + event.calId = calendar.id; + event.URL = calendar.delegated + ? buildDelegatedEventURL(calendar, eventURL) + : eventURL; + if (!event.uid || !event.start) { + console.error( + `missing crucial event param in calendar ${calendar.id} `, + data + ); + event.error = `missing crucial event param in calendar ${calendar.id} `; + } + + const eventTimezone = event.timezone; + + if (!event.end) { + const start = event.start ? new Date(event.start) : new Date(); + const timeToAdd = duration + ? moment.duration(duration).asMilliseconds() + : moment.duration(30, "minutes").asMilliseconds(); + const artificialEnd = new Date(start.getTime() + timeToAdd); + event.end = formatDateToICal(artificialEnd, false, eventTimezone); + } + + if (!event.allday && event.start && eventTimezone) { + const startISO = convertEventDateTimeToISO(event.start, eventTimezone); + if (startISO) { + event.start = startISO; + } + } + + if (!event.allday && event.end && eventTimezone) { + const endISO = convertEventDateTimeToISO(event.end, eventTimezone); + if (endISO) { + event.end = endISO; + } + } + + return event as CalendarEvent; +}