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:
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
detectDateTimeFormat,
|
||||
convertFormDateTimeToISO,
|
||||
DATETIME_WITH_SECONDS_LENGTH,
|
||||
DATETIME_FORMAT_WITH_SECONDS,
|
||||
DATETIME_FORMAT_WITHOUT_SECONDS,
|
||||
} from "../../../../src/components/Event/utils/dateTimeHelpers";
|
||||
|
||||
describe("dateTimeHelpers", () => {
|
||||
describe("Constants", () => {
|
||||
it("should have correct constant values", () => {
|
||||
expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19);
|
||||
expect(DATETIME_FORMAT_WITH_SECONDS).toBe("YYYY-MM-DDTHH:mm:ss");
|
||||
expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe("YYYY-MM-DDTHH:mm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDateTimeFormat", () => {
|
||||
it("should return format with seconds for length >= 19", () => {
|
||||
expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe(
|
||||
DATETIME_FORMAT_WITH_SECONDS
|
||||
);
|
||||
expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe(
|
||||
"YYYY-MM-DDTHH:mm:ss"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return format without seconds for length < 19", () => {
|
||||
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe(
|
||||
DATETIME_FORMAT_WITHOUT_SECONDS
|
||||
);
|
||||
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe("YYYY-MM-DDTHH:mm");
|
||||
});
|
||||
|
||||
it("should return format with seconds for length exactly 19", () => {
|
||||
const datetime = "2024-01-15T10:30:45";
|
||||
expect(datetime.length).toBe(19);
|
||||
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS);
|
||||
});
|
||||
|
||||
it("should return format without seconds for length 16", () => {
|
||||
const datetime = "2024-01-15T10:30";
|
||||
expect(datetime.length).toBe(16);
|
||||
expect(detectDateTimeFormat(datetime)).toBe(
|
||||
DATETIME_FORMAT_WITHOUT_SECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it("should return format with seconds for length > 19", () => {
|
||||
const datetime = "2024-01-15T10:30:45.123";
|
||||
expect(datetime.length).toBeGreaterThan(19);
|
||||
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(detectDateTimeFormat("")).toBe(DATETIME_FORMAT_WITHOUT_SECONDS);
|
||||
});
|
||||
|
||||
it("should handle very short strings", () => {
|
||||
expect(detectDateTimeFormat("2024")).toBe(
|
||||
DATETIME_FORMAT_WITHOUT_SECONDS
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertFormDateTimeToISO", () => {
|
||||
const originalConsoleWarn = console.warn;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should convert valid datetime without seconds to ISO string", () => {
|
||||
const result = convertFormDateTimeToISO(
|
||||
"2024-01-15T10:30",
|
||||
"America/New_York"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should convert valid datetime with seconds to ISO string", () => {
|
||||
const result = convertFormDateTimeToISO(
|
||||
"2024-01-15T10:30:45",
|
||||
"America/New_York"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use Etc/UTC as default timezone when timezone is empty", () => {
|
||||
const result = convertFormDateTimeToISO("2024-01-15T10:30", "");
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("should return empty string for empty datetime input", () => {
|
||||
const result = convertFormDateTimeToISO("", "America/New_York");
|
||||
expect(result).toBe("");
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return empty string and log warning for invalid datetime", () => {
|
||||
const result = convertFormDateTimeToISO(
|
||||
"invalid-date",
|
||||
"America/New_York"
|
||||
);
|
||||
expect(result).toBe("");
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[convertFormDateTimeToISO] Invalid datetime:")
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"invalid-date"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty string and log warning for invalid format", () => {
|
||||
const result = convertFormDateTimeToISO(
|
||||
"2024-13-45T25:99:99",
|
||||
"America/New_York"
|
||||
);
|
||||
expect(result).toBe("");
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle different timezones correctly", () => {
|
||||
const result1 = convertFormDateTimeToISO(
|
||||
"2024-01-15T10:30",
|
||||
"America/New_York"
|
||||
);
|
||||
const result2 = convertFormDateTimeToISO(
|
||||
"2024-01-15T10:30",
|
||||
"Europe/London"
|
||||
);
|
||||
expect(result1).toBeTruthy();
|
||||
expect(result2).toBeTruthy();
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
it("should handle edge case with null/undefined timezone", () => {
|
||||
const result = convertFormDateTimeToISO(
|
||||
"2024-01-15T10:30",
|
||||
// @ts-ignore - testing edge case
|
||||
null
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should convert correctly for UTC timezone", () => {
|
||||
const result = convertFormDateTimeToISO("2024-01-15T10:30", "Etc/UTC");
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("T10:30:00");
|
||||
});
|
||||
|
||||
it("should handle datetime at boundary (exactly 19 characters)", () => {
|
||||
const datetime = "2024-01-15T10:30:45";
|
||||
expect(datetime.length).toBe(19);
|
||||
const result = convertFormDateTimeToISO(datetime, "Etc/UTC");
|
||||
expect(result).toBeTruthy();
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle datetime at boundary (exactly 16 characters)", () => {
|
||||
const datetime = "2024-01-15T10:30";
|
||||
expect(datetime.length).toBe(16);
|
||||
const result = convertFormDateTimeToISO(datetime, "Etc/UTC");
|
||||
expect(result).toBeTruthy();
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user