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
This commit is contained in:
committed by
Benoit TELLIER
parent
af47923478
commit
5c38b2fd99
@@ -1,7 +1,14 @@
|
|||||||
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
|
import {
|
||||||
|
CalendarEvent,
|
||||||
|
RepetitionObject,
|
||||||
|
} from "../../../src/features/Events/EventsTypes";
|
||||||
import {
|
import {
|
||||||
calendarEventToJCal,
|
calendarEventToJCal,
|
||||||
parseCalendarEvent,
|
parseCalendarEvent,
|
||||||
|
combineMasterDateWithFormTime,
|
||||||
|
normalizeRepetition,
|
||||||
|
normalizeTimezone,
|
||||||
|
detectRecurringEventChanges,
|
||||||
} from "../../../src/features/Events/eventUtils";
|
} from "../../../src/features/Events/eventUtils";
|
||||||
import { TIMEZONES } from "../../../src/utils/timezone-data";
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import EventFormFields, {
|
|||||||
import { getEvent, deleteEvent, putEvent } from "./EventApi";
|
import { getEvent, deleteEvent, putEvent } from "./EventApi";
|
||||||
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
|
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
|
||||||
import { getCalendarRange } from "../../utils/dateUtils";
|
import { getCalendarRange } from "../../utils/dateUtils";
|
||||||
|
import {
|
||||||
|
combineMasterDateWithFormTime,
|
||||||
|
detectRecurringEventChanges,
|
||||||
|
} from "./eventUtils";
|
||||||
|
|
||||||
const showErrorNotification = (message: string) => {
|
const showErrorNotification = (message: string) => {
|
||||||
console.error(`[ERROR] ${message}`);
|
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
|
// For "all events" update, use master event's DATE but apply user's TIME from form
|
||||||
if (masterEventData && typeOfAction === "all") {
|
if (masterEventData && typeOfAction === "all") {
|
||||||
if (allday) {
|
const combined = combineMasterDateWithFormTime(
|
||||||
// For all-day events, use date from master event
|
masterEventData,
|
||||||
startDate = new Date(masterEventData.start).toISOString().split("T")[0];
|
start,
|
||||||
if (masterEventData.end) {
|
end,
|
||||||
endDate = new Date(masterEventData.end).toISOString().split("T")[0];
|
timezone,
|
||||||
} else {
|
allday,
|
||||||
endDate = startDate;
|
formatDateTimeInTimezone
|
||||||
}
|
);
|
||||||
} else {
|
startDate = combined.startDate;
|
||||||
// For timed events: combine master's date with form's time (both in event timezone)
|
endDate = combined.endDate;
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// For single events or "solo" edits, use the edited dates from form
|
// For single events or "solo" edits, use the edited dates from form
|
||||||
if (allday) {
|
if (allday) {
|
||||||
@@ -541,80 +516,14 @@ function EventUpdateModal({
|
|||||||
// Update all instances - check if repetition rules changed
|
// Update all instances - check if repetition rules changed
|
||||||
const baseUID = event.uid.split("/")[0];
|
const baseUID = event.uid.split("/")[0];
|
||||||
|
|
||||||
// Normalize repetition objects for accurate comparison
|
const changes = detectRecurringEventChanges(
|
||||||
const normalizeRepetition = (rep: RepetitionObject | undefined) => {
|
event,
|
||||||
if (!rep || !rep.freq) return null;
|
{ repetition, timezone, allday, start, end },
|
||||||
|
masterEventData,
|
||||||
return {
|
resolveTimezone,
|
||||||
freq: rep.freq,
|
formatDateTimeInTimezone
|
||||||
interval: rep.interval || 1,
|
);
|
||||||
byday:
|
const repetitionRulesChanged = changes.repetitionRulesChanged;
|
||||||
!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;
|
|
||||||
|
|
||||||
if (repetitionRulesChanged) {
|
if (repetitionRulesChanged) {
|
||||||
// Date/time or repetition rules changed - remove all overrides and refresh
|
// Date/time or repetition rules changed - remove all overrides and refresh
|
||||||
|
|||||||
@@ -321,3 +321,161 @@ function formatDateToICal(date: Date, allday: Boolean) {
|
|||||||
}
|
}
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user