Fix: Preserve timezone used in create and update modal (#301)

- Fix timezone field display in event modals to show original timezone from server
- Fix formatDateToICal to use moment.utc(date).tz(timezone) for correct UTC instant preservation
- Fix parseCalendarEvent to convert event.start/end to ISO UTC strings after parsing
- Fix eventToFullCalendarFormat to ensure event times are ISO UTC before passing to FullCalendar
- Fix getEvent to normalize event.start/end to ISO UTC strings
- Ensure event.timezone is always set (defaults to Etc/UTC if not detected)
- Update eventUtils.test.ts to match new timezone conversion logic
- Fix event time shift issue on calendar grid display

This ensures:
- Events display in correct time slots on calendar grid
- Event times are correctly converted between timezones
- Original timezone from server is preserved and displayed
- All event times are stored as ISO UTC strings internally

* refactor: eliminate datetime format detection code duplication

- Add constants for datetime format strings and magic number (DATETIME_WITH_SECONDS_LENGTH, DATETIME_FORMAT_WITH_SECONDS, DATETIME_FORMAT_WITHOUT_SECONDS)
- Create shared detectDateTimeFormat() function to replace duplicated format detection logic
- Refactor convertFormDateTimeToISO, convertEventDateTimeToISO, and convertDateTimeStringToISO to use shared helper
- Add console.warn logging when invalid datetime is encountered (addresses silent fallback issue)
- Add comprehensive test coverage for dateTimeHelpers with full test suite
This commit is contained in:
lenhanphung
2025-11-20 15:32:15 +07:00
committed by GitHub
parent 8e7b6e18eb
commit bff1af31bd
10 changed files with 951 additions and 138 deletions
+106 -30
View File
@@ -59,6 +59,7 @@ describe("parseCalendarEvent", () => {
expect(result.transp).toBe("OPAQUE");
expect(result.class).toBe("PUBLIC");
expect(result.x_openpass_videoconference).toBe("https://meet.link");
expect(result.timezone).toBe("Etc/UTC");
expect(result.organizer).toEqual({
cn: "Alice",
@@ -120,6 +121,7 @@ describe("parseCalendarEvent", () => {
expect(result.transp).toBe("OPAQUE");
expect(result.class).toBe("PUBLIC");
expect(result.x_openpass_videoconference).toBe("https://meet.link");
expect(result.timezone).toBe("Etc/UTC");
expect(result.organizer).toEqual({
cn: "Alice",
@@ -211,7 +213,11 @@ describe("parseCalendarEvent", () => {
calendarId,
"/calendars/test.ics"
);
expect(result.end).toBe("2025-07-18T10:00:00");
expect(result.end).toBeDefined();
expect(result.timezone).toBeDefined();
const endDate = new Date(result.end);
const startDate = new Date(result.start);
expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000);
});
it("returns error if end and duration is missing", () => {
@@ -259,6 +265,7 @@ describe("parseCalendarEvent", () => {
const rawData = [
["UID", {}, "text", "event-4"],
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
["ATTENDEE", {}, "cal-address", "john@example.com"],
["ORGANIZER", {}, "cal-address", "jane@example.com"],
] as unknown as [string, Record<string, string>, string, any];
@@ -286,6 +293,66 @@ describe("parseCalendarEvent", () => {
cal_address: "jane@example.com",
});
});
it("converts datetime without timezone to ISO UTC when timezone is detected", () => {
const rawData = [
["UID", {}, "text", "event-tz"],
["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"],
["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"],
] as unknown as [string, Record<string, string>, string, any];
const result = parseCalendarEvent(
rawData,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.timezone).toBeDefined();
expect(result.start).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
);
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(result.allday).toBe(false);
});
it("does not convert datetime with Z suffix", () => {
const rawData = [
["UID", {}, "text", "event-utc"],
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
] as unknown as [string, Record<string, string>, string, any];
const result = parseCalendarEvent(
rawData,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.start).toBe("2025-07-18T09:00:00Z");
expect(result.end).toBe("2025-07-18T10:00:00Z");
expect(result.timezone).toBe("Etc/UTC");
});
it("preserves all-day event dates without conversion", () => {
const rawData = [
["UID", {}, "text", "event-allday"],
["DTSTART", {}, "date", "2025-07-18"],
["DTEND", {}, "date", "2025-07-19"],
] as unknown as [string, Record<string, string>, string, any];
const result = parseCalendarEvent(
rawData,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.allday).toBe(true);
expect(result.start).toBe("2025-07-18");
expect(result.end).toBe("2025-07-19");
});
});
describe("calendarEventToJCal", () => {
@@ -304,8 +371,8 @@ describe("calendarEventToJCal", () => {
URL: "/calendars/test.ics",
calId: "test/test",
title: "Team Meeting",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
transp: "OPAQUE",
class: "PUBLIC",
@@ -336,18 +403,24 @@ describe("calendarEventToJCal", () => {
expect(vevent[0]).toBe("vevent");
const props = vevent[1];
const dtstart = props.find((p: any[]) => p[0] === "dtstart");
const dtend = props.find((p: any[]) => p[0] === "dtend");
expect(dtstart).toBeDefined();
expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" });
expect(dtstart[2]).toBe("date-time");
expect(dtstart[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
expect(dtend).toBeDefined();
expect(dtend[1]).toEqual({ tzid: "Europe/Paris" });
expect(dtend[2]).toBe("date-time");
expect(dtend[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
expect(props).toEqual(
expect.arrayContaining([
["uid", {}, "text", "event-123"],
["summary", {}, "text", "Team Meeting"],
["transp", {}, "text", "OPAQUE"],
[
"dtstart",
{ tzid: "Europe/Paris" },
"date-time",
"2025-07-23T10:00:00",
],
["dtend", { tzid: "Europe/Paris" }, "date-time", "2025-07-23T11:00:00"],
["class", {}, "text", "PUBLIC"],
["location", {}, "text", "Room 101"],
["description", {}, "text", "Discuss project roadmap."],
@@ -416,8 +489,8 @@ describe("calendarEventToJCal", () => {
const mockEvent: any = {
uid: "event-10",
title: "Alarm Event",
start: new Date("2025-07-20T09:00:00"),
end: new Date("2025-07-20T10:00:00"),
start: "2025-07-20T07:00:00.000Z",
end: "2025-07-20T08:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
alarm: { trigger: "-PT10M", action: "DISPLAY" },
@@ -440,8 +513,8 @@ describe("calendarEventToJCal", () => {
const mockEvent: any = {
uid: "event-11",
title: "All Day",
start: new Date("2025-07-21"),
end: new Date("2025-07-21"),
start: "2025-07-21T00:00:00.000Z",
end: "2025-07-21T00:00:00.000Z",
timezone: "Europe/Paris",
allday: true,
attendee: [],
@@ -464,8 +537,8 @@ describe("calendarEventToJCal", () => {
URL: "/calendars/test.ics",
calId: "test/test",
title: "Team Meeting",
start: new Date("2025-07-23"),
end: new Date("2025-07-23"),
start: "2025-07-23T00:00:00.000Z",
end: "2025-07-23T00:00:00.000Z",
timezone: "Europe/Paris",
transp: "OPAQUE",
class: "PUBLIC",
@@ -572,8 +645,8 @@ describe("calendarEventToJCal", () => {
const mockEvent = {
uid: "event-invalid-tz",
title: "Invalid Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T10:00:00.000Z",
end: "2025-07-23T11:00:00.000Z",
timezone: "Invalid/Timezone",
allday: false,
attendee: [],
@@ -607,8 +680,8 @@ describe("calendarEventToJCal", () => {
const mockEvent = {
uid: "event-null-tz",
title: "Null Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T10:00:00.000Z",
end: "2025-07-23T11:00:00.000Z",
timezone: null,
allday: false,
attendee: [],
@@ -642,8 +715,8 @@ describe("calendarEventToJCal", () => {
const mockEvent = {
uid: "event-undefined-tz",
title: "Undefined Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T10:00:00.000Z",
end: "2025-07-23T11:00:00.000Z",
timezone: undefined,
allday: false,
attendee: [],
@@ -677,8 +750,8 @@ describe("calendarEventToJCal", () => {
const mockEvent = {
uid: "event-empty-tz",
title: "Empty Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T10:00:00.000Z",
end: "2025-07-23T11:00:00.000Z",
timezone: "",
allday: false,
attendee: [],
@@ -712,8 +785,8 @@ describe("calendarEventToJCal", () => {
const mockEvent = {
uid: "event-valid-tz",
title: "Valid Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
attendee: [],
@@ -792,8 +865,9 @@ describe("combineMasterDateWithFormTime", () => {
mockFormatDateTime
);
expect(result.startDate).toBe("2025-10-14");
expect(result.endDate).toBe("2025-10-14");
// Function now returns ISO UTC strings for all-day events to avoid timezone offset issues
expect(result.startDate).toBe("2025-10-14T00:00:00.000Z");
expect(result.endDate).toBe("2025-10-14T00:00:00.000Z");
});
it("should handle all-day events with missing end date", () => {
@@ -811,8 +885,10 @@ describe("combineMasterDateWithFormTime", () => {
mockFormatDateTime
);
expect(result.startDate).toBe("2025-10-14");
expect(result.endDate).toBe("2025-10-14");
// Function now returns ISO UTC strings for all-day events to avoid timezone offset issues
// When end is missing, it uses start date for end date
expect(result.startDate).toBe("2025-10-14T00:00:00.000Z");
expect(result.endDate).toBe("2025-10-14T00:00:00.000Z");
});
it("should combine master date with form time for timed events", () => {