From 5c38b2fd997c76e75a1446bd4935a63b010ffb20 Mon Sep 17 00:00:00 2001 From: lenhanphung Date: Tue, 14 Oct 2025 15:52:21 +0700 Subject: [PATCH] refactor: extract complex logic from EventUpdateModal to testable helpers - Add combineMasterDateWithFormTime helper for date/time combination - Add detectRecurringEventChanges helper for change detection - Add normalizeRepetition and normalizeTimezone utilities - Reduce EventUpdateModal complexity - Add 18 unit tests for new helpers --- __test__/features/Events/eventUtils.test.ts | 441 +++++++++++++++++++- src/features/Events/EventUpdateModal.tsx | 135 +----- src/features/Events/eventUtils.ts | 158 +++++++ 3 files changed, 620 insertions(+), 114 deletions(-) diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 1411b44..020e1c8 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -1,7 +1,14 @@ -import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; +import { + CalendarEvent, + RepetitionObject, +} from "../../../src/features/Events/EventsTypes"; import { calendarEventToJCal, parseCalendarEvent, + combineMasterDateWithFormTime, + normalizeRepetition, + normalizeTimezone, + detectRecurringEventChanges, } from "../../../src/features/Events/eventUtils"; import { TIMEZONES } from "../../../src/utils/timezone-data"; @@ -758,3 +765,435 @@ describe("calendarEventToJCal", () => { ); }); }); + +describe("combineMasterDateWithFormTime", () => { + const mockFormatDateTime = (iso: string, tz: string) => { + const date = new Date(iso); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + const hour = String(date.getUTCHours()).padStart(2, "0"); + const minute = String(date.getUTCMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hour}:${minute}`; + }; + + it("should use master event dates for all-day events", () => { + const masterEvent = { + start: "2025-10-14T07:00:00.000Z", + end: "2025-10-14T08:00:00.000Z", + } as CalendarEvent; + + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15T09:00", + "2025-10-15T10:00", + "UTC", + true, + mockFormatDateTime + ); + + expect(result.startDate).toBe("2025-10-14"); + expect(result.endDate).toBe("2025-10-14"); + }); + + it("should handle all-day events with missing end date", () => { + const masterEvent = { + start: "2025-10-14T07:00:00.000Z", + end: undefined, + } as CalendarEvent; + + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15T09:00", + "2025-10-15T10:00", + "UTC", + true, + mockFormatDateTime + ); + + expect(result.startDate).toBe("2025-10-14"); + expect(result.endDate).toBe("2025-10-14"); + }); + + it("should combine master date with form time for timed events", () => { + const masterEvent = { + start: "2025-10-14T07:00:00.000Z", + end: "2025-10-14T08:00:00.000Z", + } as CalendarEvent; + + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15T09:00", + "2025-10-15T10:00", + "UTC", + false, + mockFormatDateTime + ); + + const resultStartDate = new Date(result.startDate); + expect(resultStartDate.getUTCDate()).toBe(14); // Monday (master's date) + expect(resultStartDate.getUTCHours()).toBe(9); // 9am (form's time) + }); + + it("should preserve timezone when combining date and time", () => { + const masterEvent = { + start: "2025-10-14T00:00:00.000Z", + end: "2025-10-14T01:00:00.000Z", + } as CalendarEvent; + + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15T14:30", + "2025-10-15T15:30", + "Asia/Ho_Chi_Minh", + false, + mockFormatDateTime + ); + + expect(result.startDate).toBeDefined(); + expect(result.endDate).toBeDefined(); + }); + + it("should handle timed events with missing end date", () => { + const masterEvent = { + start: "2025-10-14T07:00:00.000Z", + end: undefined, + } as CalendarEvent; + + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15T09:00", + "2025-10-15T10:00", + "UTC", + false, + mockFormatDateTime + ); + + expect(result.startDate).toBeDefined(); + expect(result.endDate).toBeDefined(); + }); + + it("should handle different date formats from form input", () => { + const masterEvent = { + start: "2025-10-14T07:00:00.000Z", + end: "2025-10-14T08:00:00.000Z", + } as CalendarEvent; + + // Test with different time format + const result = combineMasterDateWithFormTime( + masterEvent, + "2025-10-15 09:00:00", + "2025-10-15 10:00:00", + "UTC", + false, + mockFormatDateTime + ); + + expect(result.startDate).toBeDefined(); + expect(result.endDate).toBeDefined(); + }); +}); + +describe("normalizeRepetition", () => { + it("should normalize repetition with all fields", () => { + const repetition: RepetitionObject = { + freq: "weekly", + interval: 2, + byday: ["MO", "WE", "FR"], + occurrences: 10, + endDate: "2025-12-31", + }; + + const result = normalizeRepetition(repetition); + + expect(result).toEqual({ + freq: "weekly", + interval: 2, + byday: ["FR", "MO", "WE"], // Sorted + occurrences: 10, + endDate: "2025-12-31", + }); + }); + + it("should return null for empty repetition", () => { + expect(normalizeRepetition(undefined)).toBeNull(); + expect(normalizeRepetition({} as RepetitionObject)).toBeNull(); + expect(normalizeRepetition({ freq: "" } as RepetitionObject)).toBeNull(); + }); + + it("should handle repetition with default interval", () => { + const repetition: RepetitionObject = { + freq: "daily", + occurrences: 5, + } as RepetitionObject; + + const result = normalizeRepetition(repetition); + + expect(result?.interval).toBe(1); + }); + + it("should normalize empty byday to null", () => { + const repetition: RepetitionObject = { + freq: "weekly", + interval: 1, + byday: [], + } as RepetitionObject; + + const result = normalizeRepetition(repetition); + + expect(result?.byday).toBeNull(); + }); +}); + +describe("normalizeTimezone", () => { + const mockResolveTimezone = (tz: string) => { + if (tz === "Asia/Saigon") return "Asia/Ho_Chi_Minh"; + return tz; + }; + + it("should resolve timezone aliases", () => { + const result = normalizeTimezone("Asia/Saigon", mockResolveTimezone); + expect(result).toBe("Asia/Ho_Chi_Minh"); + }); + + it("should return null for empty timezone", () => { + expect(normalizeTimezone(undefined, mockResolveTimezone)).toBeNull(); + expect(normalizeTimezone(null, mockResolveTimezone)).toBeNull(); + expect(normalizeTimezone("", mockResolveTimezone)).toBeNull(); + }); + + it("should return same timezone if no alias", () => { + const result = normalizeTimezone("UTC", mockResolveTimezone); + expect(result).toBe("UTC"); + }); +}); + +describe("detectRecurringEventChanges", () => { + const mockResolveTimezone = (tz: string) => tz; + const mockFormatDateTime = (iso: string, tz: string) => { + const date = new Date(iso); + const hour = String(date.getUTCHours()).padStart(2, "0"); + const minute = String(date.getUTCMinutes()).padStart(2, "0"); + return `2025-10-14T${hour}:${minute}`; + }; + + it("should detect when only time changes", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + end: "2025-10-14T08:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + timezone: "UTC", + allday: false, + start: "2025-10-15T09:00", + end: "2025-10-15T10:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.timeChanged).toBe(true); + expect(result.repetitionRulesChanged).toBe(true); + }); + + it("should not detect time change when time is same", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + end: "2025-10-14T08:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + timezone: "UTC", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.timeChanged).toBe(false); + expect(result.repetitionRulesChanged).toBe(false); + }); + + it("should detect timezone changes", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + timezone: "Asia/Ho_Chi_Minh", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.timezoneChanged).toBe(true); + expect(result.repetitionRulesChanged).toBe(true); + }); + + it("should resolve timezone aliases before comparison", () => { + const resolveWithAlias = (tz: string) => { + if (tz === "Asia/Saigon") return "Asia/Ho_Chi_Minh"; + return tz; + }; + + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "Asia/Saigon", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + timezone: "Asia/Ho_Chi_Minh", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + resolveWithAlias, + mockFormatDateTime + ); + + expect(result.timezoneChanged).toBe(false); + }); + + it("should detect frequency changes", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "weekly", interval: 1 } as RepetitionObject, + timezone: "UTC", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.repetitionRulesChanged).toBe(true); + }); + + it("should detect interval changes", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "daily", interval: 2 } as RepetitionObject, + timezone: "UTC", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.repetitionRulesChanged).toBe(true); + }); + + it("should detect byday changes", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { + freq: "weekly", + interval: 1, + byday: ["MO", "WE"], + } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { + freq: "weekly", + interval: 1, + byday: ["MO", "FR"], + } as RepetitionObject, + timezone: "UTC", + allday: false, + start: "2025-10-15T07:00", + end: "2025-10-15T08:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.repetitionRulesChanged).toBe(true); + }); + + it("should detect when multiple properties change", () => { + const oldEvent = { + start: "2025-10-14T07:00:00.000Z", + timezone: "UTC", + allday: false, + repetition: { freq: "daily", interval: 1 } as RepetitionObject, + } as CalendarEvent; + + const result = detectRecurringEventChanges( + oldEvent, + { + repetition: { freq: "weekly", interval: 2 } as RepetitionObject, + timezone: "Asia/Ho_Chi_Minh", + allday: true, + start: "2025-10-15T09:00", + end: "2025-10-15T10:00", + }, + null, + mockResolveTimezone, + mockFormatDateTime + ); + + expect(result.timeChanged).toBe(true); + expect(result.timezoneChanged).toBe(true); + expect(result.repetitionRulesChanged).toBe(true); + }); +}); diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 92446de..31bb817 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -28,6 +28,10 @@ import EventFormFields, { import { getEvent, deleteEvent, putEvent } from "./EventApi"; import { refreshCalendars } from "../../components/Event/utils/eventUtils"; import { getCalendarRange } from "../../utils/dateUtils"; +import { + combineMasterDateWithFormTime, + detectRecurringEventChanges, +} from "./eventUtils"; const showErrorNotification = (message: string) => { console.error(`[ERROR] ${message}`); @@ -347,45 +351,16 @@ function EventUpdateModal({ // For "all events" update, use master event's DATE but apply user's TIME from form if (masterEventData && typeOfAction === "all") { - if (allday) { - // For all-day events, use date from master event - startDate = new Date(masterEventData.start).toISOString().split("T")[0]; - if (masterEventData.end) { - endDate = new Date(masterEventData.end).toISOString().split("T")[0]; - } else { - endDate = startDate; - } - } else { - // For timed events: combine master's date with form's time (both in event timezone) - // Format master's start in event timezone to get proper date - const masterFormattedStart = formatDateTimeInTimezone( - masterEventData.start, - timezone - ); - const masterFormattedEnd = masterEventData.end - ? formatDateTimeInTimezone(masterEventData.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 = start.includes("T") - ? start.split("T")[1] - : start.substring(11); - const formEndTimePart = end.includes("T") - ? end.split("T")[1] - : end.substring(11); - - // Combine master's date + form's time - const combinedStartStr = `${masterDatePart}T${formTimePart}`; - const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; - - // Parse and convert to ISO (assume local timezone matches event timezone for form input) - startDate = new Date(combinedStartStr).toISOString(); - endDate = new Date(combinedEndStr).toISOString(); - } + const combined = combineMasterDateWithFormTime( + masterEventData, + start, + end, + timezone, + allday, + formatDateTimeInTimezone + ); + startDate = combined.startDate; + endDate = combined.endDate; } else { // For single events or "solo" edits, use the edited dates from form if (allday) { @@ -541,80 +516,14 @@ function EventUpdateModal({ // Update all instances - check if repetition rules changed const baseUID = event.uid.split("/")[0]; - // Normalize repetition objects for accurate comparison - const normalizeRepetition = (rep: RepetitionObject | undefined) => { - if (!rep || !rep.freq) return null; - - return { - freq: rep.freq, - interval: rep.interval || 1, - byday: - !rep.byday || rep.byday.length === 0 - ? null - : [...rep.byday].sort(), - occurrences: rep.occurrences || null, - endDate: rep.endDate || null, - }; - }; - - const oldRepetition = normalizeRepetition(event.repetition); - const newRepetition = normalizeRepetition(repetition); - - // Normalize timezone for comparison (undefined, null, "" → null, resolve aliases) - const normalizeTimezone = (tz: string | undefined | null) => { - if (!tz) return null; - // Resolve timezone aliases (e.g., Asia/Saigon → Asia/Ho_Chi_Minh) - return resolveTimezone(tz); - }; - - const oldTimezone = normalizeTimezone(event.timezone); - const newTimezone = normalizeTimezone(timezone); - const timezoneChanged = oldTimezone !== newTimezone; - - // Check if TIME changed (compare time portion only, not date) - // We need to compare against master event's time, not current instance's time - const extractTimeFromForm = (localDateTimeStr: string) => { - // Form input is local datetime string like "2025-10-15T09:00" - // Extract just the time portion - if (!localDateTimeStr) return null; - const timePart = localDateTimeStr.includes("T") - ? localDateTimeStr.split("T")[1] - : localDateTimeStr.substring(11); - return timePart?.substring(0, 5); // HH:MM - }; - - const extractTimeFromISO = ( - isoString: string | undefined, - tz: string - ) => { - // Format ISO datetime in event timezone and extract time - if (!isoString) return null; - const formatted = formatDateTimeInTimezone(isoString, tz); - const timePart = formatted.includes("T") - ? formatted.split("T")[1] - : formatted.substring(11); - return timePart?.substring(0, 5); // HH:MM - }; - - const masterOldStart = masterEventData?.start || event.start; - const masterOldEnd = masterEventData?.end || event.end; - - // Extract time from form input (local time) - const formStartTime = extractTimeFromForm(start); - const formEndTime = extractTimeFromForm(end); - - // Extract time from master event (in event timezone) - const oldStartTime = extractTimeFromISO(masterOldStart, timezone); - const oldEndTime = extractTimeFromISO(masterOldEnd, timezone); - - const timeChanged = - formStartTime !== oldStartTime || formEndTime !== oldEndTime; - - const repetitionRulesChanged = - JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) || - timezoneChanged || - event.allday !== allday || - timeChanged; + const changes = detectRecurringEventChanges( + event, + { repetition, timezone, allday, start, end }, + masterEventData, + resolveTimezone, + formatDateTimeInTimezone + ); + const repetitionRulesChanged = changes.repetitionRulesChanged; if (repetitionRulesChanged) { // Date/time or repetition rules changed - remove all overrides and refresh diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 00b5c7b..e705adb 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -321,3 +321,161 @@ function formatDateToICal(date: Date, allday: Boolean) { } return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; } + +/** + * 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) { + const startDate = new Date(masterEvent.start).toISOString().split("T")[0]; + const endDate = masterEvent.end + ? new Date(masterEvent.end).toISOString().split("T")[0] + : startDate; + + return { startDate, endDate }; + } + + // 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 = new Date(combinedStartStr).toISOString(); + const endDate = new Date(combinedEndStr).toISOString(); + + 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, + formatDateTimeInTimezone: (iso: string, tz: string) => string +): { + timeChanged: boolean; + timezoneChanged: boolean; + repetitionRulesChanged: boolean; +} { + const oldRepetition = normalizeRepetition(oldEvent.repetition); + const newRepetition = normalizeRepetition(newData.repetition); + + const oldTimezone = normalizeTimezone(oldEvent.timezone, resolveTimezone); + const newTimezone = normalizeTimezone(newData.timezone, resolveTimezone); + const timezoneChanged = oldTimezone !== newTimezone; + + // Check if TIME changed (compare time portion only, not date) + const extractTimeFromForm = (localDateTimeStr: string) => { + if (!localDateTimeStr) return null; + const timePart = localDateTimeStr.includes("T") + ? localDateTimeStr.split("T")[1] + : localDateTimeStr.substring(11); + return timePart?.substring(0, 5); // HH:MM + }; + + const extractTimeFromISO = (isoString: string | undefined, tz: string) => { + if (!isoString) return null; + const formatted = formatDateTimeInTimezone(isoString, tz); + const timePart = formatted.includes("T") + ? formatted.split("T")[1] + : formatted.substring(11); + return timePart?.substring(0, 5); // HH:MM + }; + + const masterOldStart = masterEventData?.start || oldEvent.start; + const masterOldEnd = masterEventData?.end || oldEvent.end; + + const formStartTime = extractTimeFromForm(newData.start); + const formEndTime = extractTimeFromForm(newData.end); + + const oldStartTime = extractTimeFromISO(masterOldStart, newData.timezone); + const oldEndTime = extractTimeFromISO(masterOldEnd, newData.timezone); + + const timeChanged = + formStartTime !== oldStartTime || formEndTime !== oldEndTime; + + const repetitionRulesChanged = + JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) || + timezoneChanged || + oldEvent.allday !== newData.allday || + timeChanged; + + return { + timeChanged, + timezoneChanged, + repetitionRulesChanged, + }; +}