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.transp).toBe("OPAQUE");
|
||||||
expect(result.class).toBe("PUBLIC");
|
expect(result.class).toBe("PUBLIC");
|
||||||
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
||||||
|
expect(result.timezone).toBe("Etc/UTC");
|
||||||
|
|
||||||
expect(result.organizer).toEqual({
|
expect(result.organizer).toEqual({
|
||||||
cn: "Alice",
|
cn: "Alice",
|
||||||
@@ -120,6 +121,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
expect(result.transp).toBe("OPAQUE");
|
expect(result.transp).toBe("OPAQUE");
|
||||||
expect(result.class).toBe("PUBLIC");
|
expect(result.class).toBe("PUBLIC");
|
||||||
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
||||||
|
expect(result.timezone).toBe("Etc/UTC");
|
||||||
|
|
||||||
expect(result.organizer).toEqual({
|
expect(result.organizer).toEqual({
|
||||||
cn: "Alice",
|
cn: "Alice",
|
||||||
@@ -211,7 +213,11 @@ describe("parseCalendarEvent", () => {
|
|||||||
calendarId,
|
calendarId,
|
||||||
"/calendars/test.ics"
|
"/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", () => {
|
it("returns error if end and duration is missing", () => {
|
||||||
@@ -259,6 +265,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
const rawData = [
|
const rawData = [
|
||||||
["UID", {}, "text", "event-4"],
|
["UID", {}, "text", "event-4"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
|
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
||||||
["ATTENDEE", {}, "cal-address", "john@example.com"],
|
["ATTENDEE", {}, "cal-address", "john@example.com"],
|
||||||
["ORGANIZER", {}, "cal-address", "jane@example.com"],
|
["ORGANIZER", {}, "cal-address", "jane@example.com"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as unknown as [string, Record<string, string>, string, any];
|
||||||
@@ -286,6 +293,66 @@ describe("parseCalendarEvent", () => {
|
|||||||
cal_address: "jane@example.com",
|
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", () => {
|
describe("calendarEventToJCal", () => {
|
||||||
@@ -304,8 +371,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
URL: "/calendars/test.ics",
|
URL: "/calendars/test.ics",
|
||||||
calId: "test/test",
|
calId: "test/test",
|
||||||
title: "Team Meeting",
|
title: "Team Meeting",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T08:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T09:00:00.000Z",
|
||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
transp: "OPAQUE",
|
transp: "OPAQUE",
|
||||||
class: "PUBLIC",
|
class: "PUBLIC",
|
||||||
@@ -336,18 +403,24 @@ describe("calendarEventToJCal", () => {
|
|||||||
expect(vevent[0]).toBe("vevent");
|
expect(vevent[0]).toBe("vevent");
|
||||||
|
|
||||||
const props = vevent[1];
|
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(props).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
["uid", {}, "text", "event-123"],
|
["uid", {}, "text", "event-123"],
|
||||||
["summary", {}, "text", "Team Meeting"],
|
["summary", {}, "text", "Team Meeting"],
|
||||||
["transp", {}, "text", "OPAQUE"],
|
["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"],
|
["class", {}, "text", "PUBLIC"],
|
||||||
["location", {}, "text", "Room 101"],
|
["location", {}, "text", "Room 101"],
|
||||||
["description", {}, "text", "Discuss project roadmap."],
|
["description", {}, "text", "Discuss project roadmap."],
|
||||||
@@ -416,8 +489,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent: any = {
|
const mockEvent: any = {
|
||||||
uid: "event-10",
|
uid: "event-10",
|
||||||
title: "Alarm Event",
|
title: "Alarm Event",
|
||||||
start: new Date("2025-07-20T09:00:00"),
|
start: "2025-07-20T07:00:00.000Z",
|
||||||
end: new Date("2025-07-20T10:00:00"),
|
end: "2025-07-20T08:00:00.000Z",
|
||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
allday: false,
|
allday: false,
|
||||||
alarm: { trigger: "-PT10M", action: "DISPLAY" },
|
alarm: { trigger: "-PT10M", action: "DISPLAY" },
|
||||||
@@ -440,8 +513,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent: any = {
|
const mockEvent: any = {
|
||||||
uid: "event-11",
|
uid: "event-11",
|
||||||
title: "All Day",
|
title: "All Day",
|
||||||
start: new Date("2025-07-21"),
|
start: "2025-07-21T00:00:00.000Z",
|
||||||
end: new Date("2025-07-21"),
|
end: "2025-07-21T00:00:00.000Z",
|
||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
allday: true,
|
allday: true,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -464,8 +537,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
URL: "/calendars/test.ics",
|
URL: "/calendars/test.ics",
|
||||||
calId: "test/test",
|
calId: "test/test",
|
||||||
title: "Team Meeting",
|
title: "Team Meeting",
|
||||||
start: new Date("2025-07-23"),
|
start: "2025-07-23T00:00:00.000Z",
|
||||||
end: new Date("2025-07-23"),
|
end: "2025-07-23T00:00:00.000Z",
|
||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
transp: "OPAQUE",
|
transp: "OPAQUE",
|
||||||
class: "PUBLIC",
|
class: "PUBLIC",
|
||||||
@@ -572,8 +645,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
uid: "event-invalid-tz",
|
uid: "event-invalid-tz",
|
||||||
title: "Invalid Timezone Event",
|
title: "Invalid Timezone Event",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T10:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T11:00:00.000Z",
|
||||||
timezone: "Invalid/Timezone",
|
timezone: "Invalid/Timezone",
|
||||||
allday: false,
|
allday: false,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -607,8 +680,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
uid: "event-null-tz",
|
uid: "event-null-tz",
|
||||||
title: "Null Timezone Event",
|
title: "Null Timezone Event",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T10:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T11:00:00.000Z",
|
||||||
timezone: null,
|
timezone: null,
|
||||||
allday: false,
|
allday: false,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -642,8 +715,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
uid: "event-undefined-tz",
|
uid: "event-undefined-tz",
|
||||||
title: "Undefined Timezone Event",
|
title: "Undefined Timezone Event",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T10:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T11:00:00.000Z",
|
||||||
timezone: undefined,
|
timezone: undefined,
|
||||||
allday: false,
|
allday: false,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -677,8 +750,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
uid: "event-empty-tz",
|
uid: "event-empty-tz",
|
||||||
title: "Empty Timezone Event",
|
title: "Empty Timezone Event",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T10:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T11:00:00.000Z",
|
||||||
timezone: "",
|
timezone: "",
|
||||||
allday: false,
|
allday: false,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -712,8 +785,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
uid: "event-valid-tz",
|
uid: "event-valid-tz",
|
||||||
title: "Valid Timezone Event",
|
title: "Valid Timezone Event",
|
||||||
start: new Date("2025-07-23T10:00:00"),
|
start: "2025-07-23T08:00:00.000Z",
|
||||||
end: new Date("2025-07-23T11:00:00"),
|
end: "2025-07-23T09:00:00.000Z",
|
||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
allday: false,
|
allday: false,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
@@ -792,8 +865,9 @@ describe("combineMasterDateWithFormTime", () => {
|
|||||||
mockFormatDateTime
|
mockFormatDateTime
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.startDate).toBe("2025-10-14");
|
// Function now returns ISO UTC strings for all-day events to avoid timezone offset issues
|
||||||
expect(result.endDate).toBe("2025-10-14");
|
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", () => {
|
it("should handle all-day events with missing end date", () => {
|
||||||
@@ -811,8 +885,10 @@ describe("combineMasterDateWithFormTime", () => {
|
|||||||
mockFormatDateTime
|
mockFormatDateTime
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.startDate).toBe("2025-10-14");
|
// Function now returns ISO UTC strings for all-day events to avoid timezone offset issues
|
||||||
expect(result.endDate).toBe("2025-10-14");
|
// 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", () => {
|
it("should combine master date with form time for timed events", () => {
|
||||||
|
|||||||
@@ -7,6 +7,34 @@ import moment from "moment-timezone";
|
|||||||
import { refreshSingularCalendar } from "../../Event/utils/eventUtils";
|
import { refreshSingularCalendar } from "../../Event/utils/eventUtils";
|
||||||
import { ThunkDispatch } from "@reduxjs/toolkit";
|
import { ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
|
import { detectDateTimeFormat } from "../../Event/utils/dateTimeHelpers";
|
||||||
|
|
||||||
|
function convertEventDateTimeToISO(
|
||||||
|
datetime: string,
|
||||||
|
eventTimezone: string,
|
||||||
|
isAllDay: boolean
|
||||||
|
): string {
|
||||||
|
if (!datetime || isAllDay) return datetime;
|
||||||
|
|
||||||
|
if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) {
|
||||||
|
return datetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||||
|
if (dateOnlyRegex.test(datetime)) {
|
||||||
|
return datetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = detectDateTimeFormat(datetime);
|
||||||
|
const momentDate = moment.tz(datetime, format, eventTimezone);
|
||||||
|
if (!momentDate.isValid()) {
|
||||||
|
console.warn(
|
||||||
|
`[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"`
|
||||||
|
);
|
||||||
|
return datetime;
|
||||||
|
}
|
||||||
|
return momentDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export const updateSlotLabelVisibility = (
|
export const updateSlotLabelVisibility = (
|
||||||
currentTime: Date,
|
currentTime: Date,
|
||||||
@@ -72,20 +100,39 @@ export const eventToFullCalendarFormat = (
|
|||||||
return filteredEvents
|
return filteredEvents
|
||||||
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
|
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
if (e.calId.split("/")[0] === userId) {
|
const eventTimezone = e.timezone || "Etc/UTC";
|
||||||
return {
|
const isAllDay = e.allday ?? false;
|
||||||
...e,
|
|
||||||
title: formatEventChipTitle(e, t),
|
const convertedEvent: any = {
|
||||||
colors: e.color,
|
|
||||||
editable: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...e,
|
...e,
|
||||||
title: formatEventChipTitle(e, t),
|
title: formatEventChipTitle(e, t),
|
||||||
colors: e.color,
|
colors: e.color,
|
||||||
editable: false,
|
editable: e.calId.split("/")[0] === userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAllDay && e.start && eventTimezone) {
|
||||||
|
const startISO = convertEventDateTimeToISO(
|
||||||
|
e.start,
|
||||||
|
eventTimezone,
|
||||||
|
isAllDay
|
||||||
|
);
|
||||||
|
if (startISO) {
|
||||||
|
convertedEvent.start = startISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllDay && e.end && eventTimezone) {
|
||||||
|
const endISO = convertEventDateTimeToISO(
|
||||||
|
e.end,
|
||||||
|
eventTimezone,
|
||||||
|
isAllDay
|
||||||
|
);
|
||||||
|
if (endISO) {
|
||||||
|
convertedEvent.end = endISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedEvent;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper functions for date/time string manipulation
|
* Helper functions for date/time string manipulation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export const DATETIME_WITH_SECONDS_LENGTH = 19;
|
||||||
|
export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss";
|
||||||
|
export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect datetime format based on string length
|
||||||
|
* @param datetime - Datetime string to analyze
|
||||||
|
* @returns Format string for moment parsing
|
||||||
|
*/
|
||||||
|
export function detectDateTimeFormat(datetime: string): string {
|
||||||
|
return datetime.length >= DATETIME_WITH_SECONDS_LENGTH
|
||||||
|
? DATETIME_FORMAT_WITH_SECONDS
|
||||||
|
: DATETIME_FORMAT_WITHOUT_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split datetime string (YYYY-MM-DDTHH:mm) into date and time parts
|
* Split datetime string (YYYY-MM-DDTHH:mm) into date and time parts
|
||||||
* @param datetime - ISO datetime string
|
* @param datetime - ISO datetime string
|
||||||
@@ -30,3 +47,24 @@ export function combineDateTime(date: string, time: string): string {
|
|||||||
if (!time) return date; // Date only for all-day
|
if (!time) return date; // Date only for all-day
|
||||||
return `${date}T${time}`;
|
return `${date}T${time}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a local form datetime string to ISO string in a specific timezone
|
||||||
|
* Assumes input format YYYY-MM-DDTHH:mm (24h)
|
||||||
|
*/
|
||||||
|
export function convertFormDateTimeToISO(
|
||||||
|
datetime: string,
|
||||||
|
timezone: string
|
||||||
|
): string {
|
||||||
|
if (!datetime) return "";
|
||||||
|
const tz = timezone || "Etc/UTC";
|
||||||
|
const format = detectDateTimeFormat(datetime);
|
||||||
|
const momentDate = moment.tz(datetime, format, tz);
|
||||||
|
if (!momentDate.isValid()) {
|
||||||
|
console.warn(
|
||||||
|
`[convertFormDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${tz}"`
|
||||||
|
);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return momentDate.toDate().toISOString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -793,8 +793,10 @@ const CalendarSlice = createSlice({
|
|||||||
state[type][action.payload.calId].color;
|
state[type][action.payload.calId].color;
|
||||||
state[type][action.payload.calId].events[id].calId =
|
state[type][action.payload.calId].events[id].calId =
|
||||||
action.payload.calId;
|
action.payload.calId;
|
||||||
state[type][action.payload.calId].events[id].timezone =
|
if (!state[type][action.payload.calId].events[id].timezone) {
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
state[type][action.payload.calId].events[id].timezone =
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -827,8 +829,10 @@ const CalendarSlice = createSlice({
|
|||||||
state[type][action.payload.calId].color;
|
state[type][action.payload.calId].color;
|
||||||
state[type][action.payload.calId].events[id].calId =
|
state[type][action.payload.calId].events[id].calId =
|
||||||
action.payload.calId;
|
action.payload.calId;
|
||||||
state[type][action.payload.calId].events[id].timezone =
|
if (!state[type][action.payload.calId].events[id].timezone) {
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
state[type][action.payload.calId].events[id].timezone =
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -872,8 +876,10 @@ const CalendarSlice = createSlice({
|
|||||||
state.list[action.payload.calId].color;
|
state.list[action.payload.calId].color;
|
||||||
state.list[action.payload.calId].events[id].calId =
|
state.list[action.payload.calId].events[id].calId =
|
||||||
action.payload.calId;
|
action.payload.calId;
|
||||||
state.list[action.payload.calId].events[id].timezone =
|
if (!state.list[action.payload.calId].events[id].timezone) {
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
state.list[action.payload.calId].events[id].timezone =
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import {
|
|||||||
parseCalendarEvent,
|
parseCalendarEvent,
|
||||||
} from "./eventUtils";
|
} from "./eventUtils";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers";
|
||||||
|
|
||||||
|
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||||
|
if (!tzid) return undefined;
|
||||||
|
if (TIMEZONES.zones[tzid]) {
|
||||||
|
return tzid;
|
||||||
|
}
|
||||||
|
if (TIMEZONES.aliases[tzid]) {
|
||||||
|
return TIMEZONES.aliases[tzid].aliasTo;
|
||||||
|
}
|
||||||
|
return tzid;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||||
const response = await api.get(`dav${event.URL}`);
|
const response = await api.get(`dav${event.URL}`);
|
||||||
@@ -17,33 +30,130 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
|||||||
const vevents = (eventical[2] || []).filter(
|
const vevents = (eventical[2] || []).filter(
|
||||||
([name]: [string]) => name.toLowerCase() === "vevent"
|
([name]: [string]) => name.toLowerCase() === "vevent"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const vtimezones = (eventical[2] || []).filter(
|
||||||
|
([name]: [string]) => name.toLowerCase() === "vtimezone"
|
||||||
|
);
|
||||||
|
|
||||||
let targetVevent;
|
let targetVevent;
|
||||||
if (isMaster) {
|
if (isMaster) {
|
||||||
// Find master VEVENT (the one without recurrence-id)
|
|
||||||
|
|
||||||
targetVevent = vevents.find(
|
targetVevent = vevents.find(
|
||||||
([, props]: [string, any[]]) =>
|
([, props]: [string, any[]]) =>
|
||||||
!props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
|
!props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
|
||||||
);
|
);
|
||||||
if (!targetVevent) {
|
if (!targetVevent) {
|
||||||
// Fallback to first VEVENT if no master found
|
|
||||||
targetVevent = vevents[0];
|
targetVevent = vevents[0];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For non-master, use first VEVENT as before
|
|
||||||
targetVevent = vevents[0];
|
targetVevent = vevents[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timezoneFromVTimezone: string | undefined;
|
||||||
|
if (vtimezones.length > 0) {
|
||||||
|
const vtimezone = vtimezones[0];
|
||||||
|
const tzidProp = vtimezone[1]?.find(
|
||||||
|
([k]: string[]) => k.toLowerCase() === "tzid"
|
||||||
|
);
|
||||||
|
if (tzidProp && tzidProp[3]) {
|
||||||
|
const resolvedTz = resolveTimezoneId(tzidProp[3]);
|
||||||
|
if (resolvedTz) {
|
||||||
|
timezoneFromVTimezone = resolvedTz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timezoneFromDTSTART: string | undefined;
|
||||||
|
const dtstartProp = targetVevent[1]?.find(
|
||||||
|
([k]: string[]) => k.toLowerCase() === "dtstart"
|
||||||
|
);
|
||||||
|
if (dtstartProp) {
|
||||||
|
const dtstartParams = dtstartProp[1];
|
||||||
|
const dtstartValue = dtstartProp[3];
|
||||||
|
if (dtstartParams) {
|
||||||
|
const tzParam =
|
||||||
|
dtstartParams.tzid ||
|
||||||
|
dtstartParams.TZID ||
|
||||||
|
dtstartParams.Tzid ||
|
||||||
|
dtstartParams.tZid ||
|
||||||
|
dtstartParams.tzId;
|
||||||
|
if (tzParam) {
|
||||||
|
const resolvedTz = resolveTimezoneId(tzParam);
|
||||||
|
if (resolvedTz) {
|
||||||
|
timezoneFromDTSTART = resolvedTz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!timezoneFromDTSTART &&
|
||||||
|
typeof dtstartValue === "string" &&
|
||||||
|
dtstartValue.endsWith("Z")
|
||||||
|
) {
|
||||||
|
timezoneFromDTSTART = "Etc/UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eventjson = parseCalendarEvent(
|
const eventjson = parseCalendarEvent(
|
||||||
targetVevent[1],
|
targetVevent[1],
|
||||||
event.color ?? {},
|
event.color ?? {},
|
||||||
event.calId,
|
event.calId,
|
||||||
event.URL
|
event.URL
|
||||||
);
|
);
|
||||||
if (isMaster) {
|
|
||||||
return { ...event, ...eventjson };
|
const finalTimezone =
|
||||||
|
timezoneFromVTimezone ||
|
||||||
|
timezoneFromDTSTART ||
|
||||||
|
eventjson.timezone ||
|
||||||
|
"Etc/UTC";
|
||||||
|
eventjson.timezone = finalTimezone;
|
||||||
|
|
||||||
|
if (!eventjson.allday && eventjson.start && finalTimezone) {
|
||||||
|
const startISO = convertEventDateTimeToISO(eventjson.start, finalTimezone);
|
||||||
|
if (startISO) {
|
||||||
|
eventjson.start = startISO;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ...eventjson, ...event };
|
|
||||||
|
if (!eventjson.allday && eventjson.end && finalTimezone) {
|
||||||
|
const endISO = convertEventDateTimeToISO(eventjson.end, finalTimezone);
|
||||||
|
if (endISO) {
|
||||||
|
eventjson.end = endISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMaster) {
|
||||||
|
const merged = { ...event, ...eventjson };
|
||||||
|
merged.timezone = finalTimezone;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
const merged = { ...event, ...eventjson };
|
||||||
|
merged.timezone = finalTimezone;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertEventDateTimeToISO(
|
||||||
|
datetime: string,
|
||||||
|
eventTimezone: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!datetime || !eventTimezone) return undefined;
|
||||||
|
|
||||||
|
if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) {
|
||||||
|
return datetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||||
|
if (dateOnlyRegex.test(datetime)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = detectDateTimeFormat(datetime);
|
||||||
|
const momentDate = moment.tz(datetime, format, eventTimezone);
|
||||||
|
if (!momentDate.isValid()) {
|
||||||
|
console.warn(
|
||||||
|
`[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return momentDate.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dlEvent(event: CalendarEvent) {
|
export async function dlEvent(event: CalendarEvent) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
formatLocalDateTime,
|
formatLocalDateTime,
|
||||||
formatDateTimeInTimezone,
|
formatDateTimeInTimezone,
|
||||||
} from "../../components/Event/utils/dateTimeFormatters";
|
} from "../../components/Event/utils/dateTimeFormatters";
|
||||||
|
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
||||||
import { addDays } from "../../components/Event/utils/dateRules";
|
import { addDays } from "../../components/Event/utils/dateRules";
|
||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
|
|
||||||
@@ -82,6 +83,11 @@ function EventPopover({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
|
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
|
||||||
|
const resolvedCalendarTimezone = useMemo(() => {
|
||||||
|
const tz =
|
||||||
|
calendarTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return resolveTimezone(tz);
|
||||||
|
}, [calendarTimezone]);
|
||||||
|
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [showDescription, setShowDescription] = useState(
|
const [showDescription, setShowDescription] = useState(
|
||||||
@@ -97,8 +103,13 @@ function EventPopover({
|
|||||||
const [location, setLocation] = useState(event?.location ?? "");
|
const [location, setLocation] = useState(event?.location ?? "");
|
||||||
const [start, setStart] = useState(event?.start ? event.start : "");
|
const [start, setStart] = useState(event?.start ? event.start : "");
|
||||||
const [end, setEnd] = useState(event?.end ? event.end : "");
|
const [end, setEnd] = useState(event?.end ? event.end : "");
|
||||||
|
const defaultCalendarId = useMemo(
|
||||||
|
() => userPersonalCalendars[0]?.id ?? "",
|
||||||
|
[userPersonalCalendars]
|
||||||
|
);
|
||||||
|
|
||||||
const [calendarid, setCalendarid] = useState(
|
const [calendarid, setCalendarid] = useState(
|
||||||
event?.calId ?? userPersonalCalendars[0]?.id ?? ""
|
event?.calId ?? defaultCalendarId
|
||||||
);
|
);
|
||||||
const [allday, setAllDay] = useState(event?.allday ?? false);
|
const [allday, setAllDay] = useState(event?.allday ?? false);
|
||||||
const [repetition, setRepetition] = useState<RepetitionObject>(
|
const [repetition, setRepetition] = useState<RepetitionObject>(
|
||||||
@@ -113,7 +124,7 @@ function EventPopover({
|
|||||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone
|
event?.timezone ? resolveTimezone(event.timezone) : resolvedCalendarTimezone
|
||||||
);
|
);
|
||||||
const [hasVideoConference, setHasVideoConference] = useState(
|
const [hasVideoConference, setHasVideoConference] = useState(
|
||||||
event?.x_openpass_videoconference ? true : false
|
event?.x_openpass_videoconference ? true : false
|
||||||
@@ -135,10 +146,10 @@ function EventPopover({
|
|||||||
}, [userPersonalCalendars]);
|
}, [userPersonalCalendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!calendarid && userPersonalCalendars.length > 0) {
|
if (!calendarid && defaultCalendarId) {
|
||||||
setCalendarid(userPersonalCalendars[0].id);
|
setCalendarid(defaultCalendarId);
|
||||||
}
|
}
|
||||||
}, [calendarid, userPersonalCalendars]);
|
}, [calendarid, defaultCalendarId]);
|
||||||
|
|
||||||
const resetAllStateToDefault = useCallback(() => {
|
const resetAllStateToDefault = useCallback(() => {
|
||||||
setShowMore(false);
|
setShowMore(false);
|
||||||
@@ -150,22 +161,18 @@ function EventPopover({
|
|||||||
setLocation("");
|
setLocation("");
|
||||||
setStart("");
|
setStart("");
|
||||||
setEnd("");
|
setEnd("");
|
||||||
if (
|
if (defaultCalendarId) {
|
||||||
userPersonalCalendars &&
|
setCalendarid(defaultCalendarId);
|
||||||
userPersonalCalendars.length > 0 &&
|
|
||||||
userPersonalCalendars[0]?.id
|
|
||||||
) {
|
|
||||||
setCalendarid(userPersonalCalendars[0].id);
|
|
||||||
}
|
}
|
||||||
setAllDay(false);
|
setAllDay(false);
|
||||||
setRepetition({} as RepetitionObject);
|
setRepetition({} as RepetitionObject);
|
||||||
setAlarm("");
|
setAlarm("");
|
||||||
setEventClass("PUBLIC");
|
setEventClass("PUBLIC");
|
||||||
setBusy("OPAQUE");
|
setBusy("OPAQUE");
|
||||||
setTimezone(calendarTimezone);
|
setTimezone(resolvedCalendarTimezone);
|
||||||
setHasVideoConference(false);
|
setHasVideoConference(false);
|
||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
}, [calendarTimezone, userPersonalCalendars]);
|
}, [resolvedCalendarTimezone, defaultCalendarId]);
|
||||||
|
|
||||||
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
|
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
|
||||||
const shouldSyncFromRangeRef = useRef(true);
|
const shouldSyncFromRangeRef = useRef(true);
|
||||||
@@ -183,24 +190,22 @@ function EventPopover({
|
|||||||
// Set timezone to calendar timezone for new events when opening
|
// Set timezone to calendar timezone for new events when opening
|
||||||
const isNewEvent = !event || !event.uid;
|
const isNewEvent = !event || !event.uid;
|
||||||
if (isNewEvent) {
|
if (isNewEvent) {
|
||||||
const resolvedTimezone = resolveTimezone(calendarTimezone);
|
setTimezone(resolvedCalendarTimezone);
|
||||||
setTimezone(resolvedTimezone);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update previous open state
|
// Update previous open state
|
||||||
prevOpenRef.current = open;
|
prevOpenRef.current = open;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, event?.uid, calendarTimezone]);
|
}, [open, event?.uid, resolvedCalendarTimezone]);
|
||||||
|
|
||||||
// Separately sync timezone when calendarTimezone changes while modal is open for new events
|
// Separately sync timezone when calendarTimezone changes while modal is open for new events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && (!event || !event.uid)) {
|
if (open && (!event || !event.uid)) {
|
||||||
const resolvedTimezone = resolveTimezone(calendarTimezone);
|
setTimezone(resolvedCalendarTimezone);
|
||||||
setTimezone(resolvedTimezone);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [calendarTimezone, open, event?.uid]);
|
}, [resolvedCalendarTimezone, open, event?.uid]);
|
||||||
|
|
||||||
// Set start/end times when modal opens for new event creation
|
// Set start/end times when modal opens for new event creation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -360,15 +365,18 @@ function EventPopover({
|
|||||||
setDescription(event.description ?? "");
|
setDescription(event.description ?? "");
|
||||||
setLocation(event.location ?? "");
|
setLocation(event.location ?? "");
|
||||||
|
|
||||||
// Get event's timezone for formatting
|
|
||||||
const eventTimezone = event.timezone
|
|
||||||
? resolveTimezone(event.timezone)
|
|
||||||
: calendarTimezone;
|
|
||||||
|
|
||||||
// Handle all-day events properly
|
// Handle all-day events properly
|
||||||
const isAllDay = event.allday ?? false;
|
const isAllDay = event.allday ?? false;
|
||||||
setAllDay(isAllDay);
|
setAllDay(isAllDay);
|
||||||
|
|
||||||
|
// Get event's timezone for formatting - prioritize event.timezone from server
|
||||||
|
let eventTimezone: string;
|
||||||
|
if (event.timezone) {
|
||||||
|
eventTimezone = resolveTimezone(event.timezone);
|
||||||
|
} else {
|
||||||
|
eventTimezone = resolvedCalendarTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
// Format dates based on all-day status and timezone
|
// Format dates based on all-day status and timezone
|
||||||
if (event.start) {
|
if (event.start) {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
@@ -396,12 +404,8 @@ function EventPopover({
|
|||||||
setEnd("");
|
setEnd("");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (defaultCalendarId) {
|
||||||
userPersonalCalendars &&
|
setCalendarid(defaultCalendarId);
|
||||||
userPersonalCalendars.length > 0 &&
|
|
||||||
userPersonalCalendars[0]?.id
|
|
||||||
) {
|
|
||||||
setCalendarid(userPersonalCalendars[0].id);
|
|
||||||
}
|
}
|
||||||
setRepetition(event.repetition ?? ({} as RepetitionObject));
|
setRepetition(event.repetition ?? ({} as RepetitionObject));
|
||||||
setShowRepeat(event.repetition?.freq ? true : false);
|
setShowRepeat(event.repetition?.freq ? true : false);
|
||||||
@@ -439,7 +443,12 @@ function EventPopover({
|
|||||||
event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
|
event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [event, organizer?.cal_address, calendarTimezone]);
|
}, [
|
||||||
|
event,
|
||||||
|
organizer?.cal_address,
|
||||||
|
resolvedCalendarTimezone,
|
||||||
|
defaultCalendarId,
|
||||||
|
]);
|
||||||
|
|
||||||
// Reset state when creating new event (event is empty object or undefined)
|
// Reset state when creating new event (event is empty object or undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -457,12 +466,8 @@ function EventPopover({
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
setAttendees([]);
|
setAttendees([]);
|
||||||
setLocation("");
|
setLocation("");
|
||||||
if (
|
if (defaultCalendarId) {
|
||||||
userPersonalCalendars &&
|
setCalendarid(defaultCalendarId);
|
||||||
userPersonalCalendars.length > 0 &&
|
|
||||||
userPersonalCalendars[0]?.id
|
|
||||||
) {
|
|
||||||
setCalendarid(userPersonalCalendars[0].id);
|
|
||||||
}
|
}
|
||||||
setAllDay(false);
|
setAllDay(false);
|
||||||
setRepetition({} as RepetitionObject);
|
setRepetition({} as RepetitionObject);
|
||||||
@@ -476,8 +481,7 @@ function EventPopover({
|
|||||||
if (!isCreatingNew) {
|
if (!isCreatingNew) {
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [event, defaultCalendarId]);
|
||||||
}, [event?.uid]);
|
|
||||||
|
|
||||||
const handleStartChange = useCallback(
|
const handleStartChange = useCallback(
|
||||||
(newStart: string) => {
|
(newStart: string) => {
|
||||||
@@ -626,12 +630,21 @@ function EventPopover({
|
|||||||
const endDateOnlyUI = (end || start || "").split("T")[0];
|
const endDateOnlyUI = (end || start || "").split("T")[0];
|
||||||
// For all-day events, API needs end date = UI end date + 1 day
|
// For all-day events, API needs end date = UI end date + 1 day
|
||||||
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
||||||
const startDateObj = new Date(`${startDateOnly}T00:00:00`);
|
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
|
||||||
const endDateObj = new Date(`${endDateOnlyAPI}T00:00:00`);
|
const [startYear, startMonth, startDay] = startDateOnly
|
||||||
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
const [endYear, endMonth, endDay] = endDateOnlyAPI.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)
|
||||||
|
);
|
||||||
newEvent.start = startDateObj.toISOString();
|
newEvent.start = startDateObj.toISOString();
|
||||||
newEvent.end = endDateObj.toISOString();
|
newEvent.end = endDateObj.toISOString();
|
||||||
} else {
|
} else {
|
||||||
newEvent.start = new Date(start).toISOString();
|
newEvent.start = convertFormDateTimeToISO(start, timezone);
|
||||||
if (end) {
|
if (end) {
|
||||||
// In normal mode, only override end date when the end date field is not shown
|
// In normal mode, only override end date when the end date field is not shown
|
||||||
if (!showMore && !hasEndDateChanged) {
|
if (!showMore && !hasEndDateChanged) {
|
||||||
@@ -640,10 +653,10 @@ function EventPopover({
|
|||||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||||
: "00:00";
|
: "00:00";
|
||||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||||
newEvent.end = new Date(endDateTime).toISOString();
|
newEvent.end = convertFormDateTimeToISO(endDateTime, timezone);
|
||||||
} else {
|
} else {
|
||||||
// Extended mode or end date explicitly shown in normal mode: use actual end datetime
|
// Extended mode or end date explicitly shown in normal mode: use actual end datetime
|
||||||
newEvent.end = new Date(end).toISOString();
|
newEvent.end = convertFormDateTimeToISO(end, timezone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,7 +682,7 @@ function EventPopover({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (tempList) {
|
if (tempList) {
|
||||||
const calendarRange = getCalendarRange(new Date(start));
|
const calendarRange = getCalendarRange(new Date(newEvent.start));
|
||||||
await updateTempCalendar(tempList, newEvent, dispatch, calendarRange);
|
await updateTempCalendar(tempList, newEvent, dispatch, calendarRange);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
|
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
|
||||||
|
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
||||||
|
|
||||||
const showErrorNotification = (message: string) => {
|
const showErrorNotification = (message: string) => {
|
||||||
console.error(`[ERROR] ${message}`);
|
console.error(`[ERROR] ${message}`);
|
||||||
@@ -157,10 +158,13 @@ function EventUpdateModal({
|
|||||||
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
);
|
);
|
||||||
const [newCalId, setNewCalId] = useState(calId);
|
const [newCalId, setNewCalId] = useState(calId);
|
||||||
const [calendarid, setCalendarid] = useState(
|
const defaultCalendarId = useMemo(
|
||||||
calId ?? userPersonalCalendars[0]?.id ?? ""
|
() => userPersonalCalendars[0]?.id ?? "",
|
||||||
|
[userPersonalCalendars]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId);
|
||||||
|
|
||||||
const [attendees, setAttendees] = useState<userAttendee[]>([]);
|
const [attendees, setAttendees] = useState<userAttendee[]>([]);
|
||||||
const [hasVideoConference, setHasVideoConference] = useState(false);
|
const [hasVideoConference, setHasVideoConference] = useState(false);
|
||||||
const [meetingLink, setMeetingLink] = useState<string | null>(null);
|
const [meetingLink, setMeetingLink] = useState<string | null>(null);
|
||||||
@@ -178,7 +182,9 @@ function EventUpdateModal({
|
|||||||
setLocation("");
|
setLocation("");
|
||||||
setStart("");
|
setStart("");
|
||||||
setEnd("");
|
setEnd("");
|
||||||
setCalendarid(userPersonalCalendars[0].id);
|
if (defaultCalendarId) {
|
||||||
|
setCalendarid(defaultCalendarId);
|
||||||
|
}
|
||||||
setAllDay(false);
|
setAllDay(false);
|
||||||
setRepetition({} as RepetitionObject);
|
setRepetition({} as RepetitionObject);
|
||||||
setAlarm("");
|
setAlarm("");
|
||||||
@@ -189,7 +195,7 @@ function EventUpdateModal({
|
|||||||
);
|
);
|
||||||
setHasVideoConference(false);
|
setHasVideoConference(false);
|
||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
}, []);
|
}, [defaultCalendarId]);
|
||||||
|
|
||||||
// Prevent repeated initialization loops
|
// Prevent repeated initialization loops
|
||||||
const initializedKeyRef = useRef<string | null>(null);
|
const initializedKeyRef = useRef<string | null>(null);
|
||||||
@@ -276,10 +282,15 @@ function EventUpdateModal({
|
|||||||
setEventClass(event.class ?? "PUBLIC");
|
setEventClass(event.class ?? "PUBLIC");
|
||||||
setBusy(event.transp ?? "OPAQUE");
|
setBusy(event.transp ?? "OPAQUE");
|
||||||
|
|
||||||
const resolvedTimezone = event.timezone
|
if (event.timezone) {
|
||||||
? resolveTimezone(event.timezone)
|
const resolvedTimezone = resolveTimezone(event.timezone);
|
||||||
: resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
setTimezone(resolvedTimezone);
|
||||||
setTimezone(resolvedTimezone);
|
} else {
|
||||||
|
const browserTz = resolveTimezone(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
setTimezone(browserTz);
|
||||||
|
}
|
||||||
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||||
setMeetingLink(event.x_openpass_videoconference || null);
|
setMeetingLink(event.x_openpass_videoconference || null);
|
||||||
setNewCalId(event.calId || calId);
|
setNewCalId(event.calId || calId);
|
||||||
@@ -384,15 +395,29 @@ function EventUpdateModal({
|
|||||||
// 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) {
|
||||||
// For all-day events, use date format (YYYY-MM-DD)
|
// For all-day events, use date format (YYYY-MM-DD)
|
||||||
|
// Extract date string directly to avoid timezone conversion issues
|
||||||
|
const startDateOnly = (start || "").split("T")[0];
|
||||||
|
const endDateOnlyUI = (end || start || "").split("T")[0];
|
||||||
// API needs end date = UI end date + 1 day
|
// API needs end date = UI end date + 1 day
|
||||||
const startDateOnly = new Date(start).toISOString().split("T")[0];
|
|
||||||
const endDateOnlyUI = new Date(end).toISOString().split("T")[0];
|
|
||||||
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
||||||
startDate = startDateOnly;
|
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
|
||||||
endDate = endDateOnlyAPI;
|
const [startYear, startMonth, startDay] = startDateOnly
|
||||||
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
const [endYear, endMonth, endDay] = endDateOnlyAPI
|
||||||
|
.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)
|
||||||
|
);
|
||||||
|
startDate = startDateObj.toISOString();
|
||||||
|
endDate = endDateObj.toISOString();
|
||||||
} else {
|
} else {
|
||||||
// For timed events
|
// For timed events
|
||||||
startDate = new Date(start).toISOString();
|
startDate = convertFormDateTimeToISO(start, timezone);
|
||||||
// In normal mode, only override end date when the end date field is not shown
|
// In normal mode, only override end date when the end date field is not shown
|
||||||
if (!showMore && !hasEndDateChanged) {
|
if (!showMore && !hasEndDateChanged) {
|
||||||
const startDateOnly = (start || "").split("T")[0];
|
const startDateOnly = (start || "").split("T")[0];
|
||||||
@@ -400,10 +425,10 @@ function EventUpdateModal({
|
|||||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||||
: "00:00";
|
: "00:00";
|
||||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||||
endDate = new Date(endDateTime).toISOString();
|
endDate = convertFormDateTimeToISO(endDateTime, timezone);
|
||||||
} else {
|
} else {
|
||||||
// Extended mode: use actual end datetime
|
// Extended mode: use actual end datetime
|
||||||
endDate = new Date(end).toISOString();
|
endDate = convertFormDateTimeToISO(end, timezone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +545,7 @@ function EventUpdateModal({
|
|||||||
// Keep modal open on error, user can retry or cancel
|
// Keep modal open on error, user can retry or cancel
|
||||||
}
|
}
|
||||||
if (tempList) {
|
if (tempList) {
|
||||||
const calendarRange = getCalendarRange(new Date(start));
|
const calendarRange = getCalendarRange(new Date(startDate));
|
||||||
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -586,7 +611,7 @@ function EventUpdateModal({
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// STEP 3: Fetch to get new instances with correct timing
|
// STEP 3: Fetch to get new instances with correct timing
|
||||||
const calendarRange = getCalendarRange(new Date(start));
|
const calendarRange = getCalendarRange(new Date(startDate));
|
||||||
await refreshCalendars(
|
await refreshCalendars(
|
||||||
dispatch,
|
dispatch,
|
||||||
Object.values(calendarsList),
|
Object.values(calendarsList),
|
||||||
@@ -695,7 +720,7 @@ function EventUpdateModal({
|
|||||||
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
||||||
}
|
}
|
||||||
if (tempList) {
|
if (tempList) {
|
||||||
const calendarRange = getCalendarRange(new Date(start));
|
const calendarRange = getCalendarRange(new Date(startDate));
|
||||||
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,53 @@ import { userAttendee } from "../User/userDataTypes";
|
|||||||
import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import { TIMEZONES } from "../../utils/timezone-data";
|
import { TIMEZONES } from "../../utils/timezone-data";
|
||||||
import moment from "moment";
|
import moment from "moment-timezone";
|
||||||
|
import {
|
||||||
|
convertFormDateTimeToISO,
|
||||||
|
detectDateTimeFormat,
|
||||||
|
} from "../../components/Event/utils/dateTimeHelpers";
|
||||||
type RawEntry = [string, Record<string, string>, string, any];
|
type RawEntry = [string, Record<string, string>, string, any];
|
||||||
|
|
||||||
|
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||||
|
if (!tzid) return undefined;
|
||||||
|
if (TIMEZONES.zones[tzid]) {
|
||||||
|
return tzid;
|
||||||
|
}
|
||||||
|
const alias = TIMEZONES.aliases[tzid];
|
||||||
|
if (alias) {
|
||||||
|
return alias.aliasTo;
|
||||||
|
}
|
||||||
|
return tzid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTimezoneFromValue(
|
||||||
|
params: Record<string, string> | undefined,
|
||||||
|
value: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!params) {
|
||||||
|
if (typeof value === "string" && value.endsWith("Z")) {
|
||||||
|
return "Etc/UTC";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tzParam =
|
||||||
|
params.tzid || params.TZID || params.Tzid || params.tZid || params.tzId;
|
||||||
|
|
||||||
|
if (tzParam) {
|
||||||
|
const resolved = resolveTimezoneId(tzParam);
|
||||||
|
if (resolved) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.endsWith("Z")) {
|
||||||
|
return "Etc/UTC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseCalendarEvent(
|
export function parseCalendarEvent(
|
||||||
data: RawEntry[],
|
data: RawEntry[],
|
||||||
color: Record<string, string>,
|
color: Record<string, string>,
|
||||||
@@ -25,22 +69,34 @@ export function parseCalendarEvent(
|
|||||||
case "transp":
|
case "transp":
|
||||||
event.transp = value;
|
event.transp = value;
|
||||||
break;
|
break;
|
||||||
case "dtstart":
|
case "dtstart": {
|
||||||
event.start = value;
|
event.start = value;
|
||||||
|
const detectedTz = inferTimezoneFromValue(params, value);
|
||||||
|
if (detectedTz) {
|
||||||
|
event.timezone = detectedTz;
|
||||||
|
}
|
||||||
if (dateRegex.test(value)) {
|
if (dateRegex.test(value)) {
|
||||||
event.allday = true;
|
event.allday = true;
|
||||||
} else {
|
} else {
|
||||||
event.allday = false;
|
event.allday = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "dtend":
|
}
|
||||||
|
case "dtend": {
|
||||||
event.end = value;
|
event.end = value;
|
||||||
|
if (!event.timezone) {
|
||||||
|
const detectedTz = inferTimezoneFromValue(params, value);
|
||||||
|
if (detectedTz) {
|
||||||
|
event.timezone = detectedTz;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dateRegex.test(value)) {
|
if (dateRegex.test(value)) {
|
||||||
event.allday = true;
|
event.allday = true;
|
||||||
} else {
|
} else {
|
||||||
event.allday = false;
|
event.allday = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "class":
|
case "class":
|
||||||
event.class = value;
|
event.class = value;
|
||||||
break;
|
break;
|
||||||
@@ -139,16 +195,60 @@ export function parseCalendarEvent(
|
|||||||
);
|
);
|
||||||
event.error = `missing crucial event param in calendar ${calendarid} `;
|
event.error = `missing crucial event param in calendar ${calendarid} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventTimezone = event.timezone || "Etc/UTC";
|
||||||
|
event.timezone = eventTimezone;
|
||||||
|
|
||||||
if (!event.end) {
|
if (!event.end) {
|
||||||
const start = event.start ? new Date(event.start) : new Date();
|
const start = event.start ? new Date(event.start) : new Date();
|
||||||
const timeToAdd = moment.duration(duration).asMilliseconds();
|
const timeToAdd = moment.duration(duration).asMilliseconds();
|
||||||
const artificialEnd = new Date(start.getTime() + timeToAdd);
|
const artificialEnd = new Date(start.getTime() + timeToAdd);
|
||||||
event.end = formatDateToICal(artificialEnd, false);
|
event.end = formatDateToICal(artificialEnd, false, eventTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.allday && event.start && eventTimezone) {
|
||||||
|
const startISO = convertDateTimeStringToISO(event.start, eventTimezone);
|
||||||
|
if (startISO) {
|
||||||
|
event.start = startISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.allday && event.end && eventTimezone) {
|
||||||
|
const endISO = convertDateTimeStringToISO(event.end, eventTimezone);
|
||||||
|
if (endISO) {
|
||||||
|
event.end = endISO;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return event as CalendarEvent;
|
return event as CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertDateTimeStringToISO(
|
||||||
|
datetime: string,
|
||||||
|
timezone: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!datetime || !timezone) return undefined;
|
||||||
|
|
||||||
|
if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||||
|
if (dateOnlyRegex.test(datetime)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = detectDateTimeFormat(datetime);
|
||||||
|
const momentDate = moment.tz(datetime, format, timezone);
|
||||||
|
if (!momentDate.isValid()) {
|
||||||
|
console.warn(
|
||||||
|
`[convertDateTimeStringToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${timezone}"`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return momentDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function calendarEventToJCal(
|
export function calendarEventToJCal(
|
||||||
event: CalendarEvent,
|
event: CalendarEvent,
|
||||||
calOwnerEmail?: string
|
calOwnerEmail?: string
|
||||||
@@ -194,7 +294,7 @@ export function makeVevent(
|
|||||||
"dtstart",
|
"dtstart",
|
||||||
{ tzid },
|
{ tzid },
|
||||||
event.allday ? "date" : "date-time",
|
event.allday ? "date" : "date-time",
|
||||||
formatDateToICal(new Date(event.start), event.allday ?? false),
|
formatDateToICal(new Date(event.start), event.allday ?? false, tzid),
|
||||||
],
|
],
|
||||||
["class", {}, "text", event.class ?? "PUBLIC"],
|
["class", {}, "text", event.class ?? "PUBLIC"],
|
||||||
[
|
[
|
||||||
@@ -237,7 +337,7 @@ export function makeVevent(
|
|||||||
"dtend",
|
"dtend",
|
||||||
{ tzid },
|
{ tzid },
|
||||||
event.allday ? "date" : "date-time",
|
event.allday ? "date" : "date-time",
|
||||||
formatDateToICal(finalEndDate, event.allday ?? false),
|
formatDateToICal(finalEndDate, event.allday ?? false, tzid),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (event.organizer) {
|
if (event.organizer) {
|
||||||
@@ -301,7 +401,7 @@ export function makeVevent(
|
|||||||
"exdate",
|
"exdate",
|
||||||
{ tzid },
|
{ tzid },
|
||||||
"date-time",
|
"date-time",
|
||||||
formatDateToICal(new Date(ex), false),
|
formatDateToICal(new Date(ex), false, tzid),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,19 +409,29 @@ export function makeVevent(
|
|||||||
return vevent;
|
return vevent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateToICal(date: Date, allday: Boolean) {
|
function formatDateToICal(date: Date, allday: Boolean, timezone?: string) {
|
||||||
// Format date like: 2025-02-14T11:00:00 (local time)
|
|
||||||
|
|
||||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = pad(date.getMonth() + 1);
|
|
||||||
const day = pad(date.getDate());
|
|
||||||
const hours = pad(date.getHours());
|
|
||||||
const minutes = pad(date.getMinutes());
|
|
||||||
const seconds = pad(date.getSeconds());
|
|
||||||
if (allday) {
|
if (allday) {
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = pad(date.getUTCMonth() + 1);
|
||||||
|
const day = pad(date.getUTCDate());
|
||||||
return `${year}-${month}-${day}`;
|
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}`;
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,12 +448,30 @@ export function combineMasterDateWithFormTime(
|
|||||||
formatDateTimeInTimezone: (iso: string, tz: string) => string
|
formatDateTimeInTimezone: (iso: string, tz: string) => string
|
||||||
): { startDate: string; endDate: string } {
|
): { startDate: string; endDate: string } {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
const startDate = new Date(masterEvent.start).toISOString().split("T")[0];
|
// Extract date string from master event (which is ISO UTC string)
|
||||||
const endDate = masterEvent.end
|
const startDateStr = new Date(masterEvent.start)
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0];
|
||||||
|
const endDateStr = masterEvent.end
|
||||||
? new Date(masterEvent.end).toISOString().split("T")[0]
|
? new Date(masterEvent.end).toISOString().split("T")[0]
|
||||||
: startDate;
|
: startDateStr;
|
||||||
|
|
||||||
return { startDate, endDate };
|
// 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
|
// For timed events: combine master's date with form's time
|
||||||
@@ -372,8 +500,8 @@ export function combineMasterDateWithFormTime(
|
|||||||
const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`;
|
const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`;
|
||||||
|
|
||||||
// Parse and convert to ISO
|
// Parse and convert to ISO
|
||||||
const startDate = new Date(combinedStartStr).toISOString();
|
const startDate = convertFormDateTimeToISO(combinedStartStr, timezone);
|
||||||
const endDate = new Date(combinedEndStr).toISOString();
|
const endDate = convertFormDateTimeToISO(combinedEndStr, timezone);
|
||||||
|
|
||||||
return { startDate, endDate };
|
return { startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { TIMEZONES } from "../../utils/timezone-data";
|
||||||
|
import { resolveTimezone } from "../../components/Calendar/TimezoneSelector";
|
||||||
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
|
import { userAttendee } from "../User/userDataTypes";
|
||||||
|
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
|
||||||
|
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||||
|
|
||||||
|
export interface TimezoneListResult {
|
||||||
|
zones: string[];
|
||||||
|
browserTz: string;
|
||||||
|
getTimezoneOffset: (tzName: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTimezoneList(): TimezoneListResult {
|
||||||
|
const zones = Object.keys(TIMEZONES.zones).sort();
|
||||||
|
const browserTz = resolveTimezone(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTimezoneOffset = (tzName: string): string => {
|
||||||
|
const resolvedTz = resolveTimezone(tzName);
|
||||||
|
const tzData = TIMEZONES.zones[resolvedTz];
|
||||||
|
if (!tzData) return "";
|
||||||
|
|
||||||
|
const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/);
|
||||||
|
if (!icsMatch) return "";
|
||||||
|
|
||||||
|
const offset = icsMatch[1];
|
||||||
|
const hours = parseInt(offset.slice(0, 3));
|
||||||
|
const minutes = parseInt(offset.slice(3));
|
||||||
|
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
||||||
|
}
|
||||||
|
return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { zones, browserTz, getTimezoneOffset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopulateFormFromEventParams {
|
||||||
|
event: CalendarEvent;
|
||||||
|
calendarTimezone?: string;
|
||||||
|
organizerEmail?: string;
|
||||||
|
setTitle: (value: string) => void;
|
||||||
|
setDescription: (value: string) => void;
|
||||||
|
setLocation: (value: string) => void;
|
||||||
|
setStart: (value: string) => void;
|
||||||
|
setEnd: (value: string) => void;
|
||||||
|
setAllDay: (value: boolean) => void;
|
||||||
|
setRepetition: (value: RepetitionObject) => void;
|
||||||
|
setShowRepeat: (value: boolean) => void;
|
||||||
|
setAttendees: (value: userAttendee[]) => void;
|
||||||
|
setAlarm: (value: string) => void;
|
||||||
|
setEventClass: (value: string) => void;
|
||||||
|
setBusy: (value: string) => void;
|
||||||
|
setTimezone: (value: string) => void;
|
||||||
|
setHasVideoConference: (value: boolean) => void;
|
||||||
|
setMeetingLink: (value: string | null) => void;
|
||||||
|
setCalendarid?: (value: string) => void;
|
||||||
|
calendarsList?: Record<string, any>;
|
||||||
|
calId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function populateFormFromEvent(
|
||||||
|
params: PopulateFormFromEventParams
|
||||||
|
): void {
|
||||||
|
const {
|
||||||
|
event,
|
||||||
|
calendarTimezone,
|
||||||
|
organizerEmail,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setLocation,
|
||||||
|
setStart,
|
||||||
|
setEnd,
|
||||||
|
setAllDay,
|
||||||
|
setRepetition,
|
||||||
|
setShowRepeat,
|
||||||
|
setAttendees,
|
||||||
|
setAlarm,
|
||||||
|
setEventClass,
|
||||||
|
setBusy,
|
||||||
|
setTimezone,
|
||||||
|
setHasVideoConference,
|
||||||
|
setMeetingLink,
|
||||||
|
setCalendarid,
|
||||||
|
calendarsList,
|
||||||
|
calId,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// Basic fields
|
||||||
|
setTitle(event.title ?? "");
|
||||||
|
setDescription(event.description ?? "");
|
||||||
|
setLocation(event.location ?? "");
|
||||||
|
|
||||||
|
// Handle all-day events
|
||||||
|
const isAllDay = event.allday ?? false;
|
||||||
|
setAllDay(isAllDay);
|
||||||
|
|
||||||
|
// Get event's timezone for formatting
|
||||||
|
const eventTimezone = event.timezone
|
||||||
|
? resolveTimezone(event.timezone)
|
||||||
|
: calendarTimezone ||
|
||||||
|
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
|
|
||||||
|
// Format dates based on all-day status and timezone
|
||||||
|
if (event.start) {
|
||||||
|
if (isAllDay) {
|
||||||
|
const startDate = new Date(event.start);
|
||||||
|
setStart(startDate.toISOString().split("T")[0]);
|
||||||
|
} else {
|
||||||
|
setStart(formatDateTimeInTimezone(event.start, eventTimezone));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStart("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.end) {
|
||||||
|
if (isAllDay) {
|
||||||
|
const endDate = new Date(event.end);
|
||||||
|
setEnd(endDate.toISOString().split("T")[0]);
|
||||||
|
} else {
|
||||||
|
setEnd(formatDateTimeInTimezone(event.end, eventTimezone));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEnd("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
if (setCalendarid && calId) {
|
||||||
|
setCalendarid(calId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle repetition - check both current event and base event (for update modal)
|
||||||
|
let repetitionSource = event.repetition;
|
||||||
|
if (calendarsList && calId && event.uid) {
|
||||||
|
const baseEventId = event.uid.split("/")[0];
|
||||||
|
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||||
|
if (baseEvent?.repetition) {
|
||||||
|
repetitionSource = baseEvent.repetition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repetitionSource && repetitionSource.freq) {
|
||||||
|
const repetitionData: RepetitionObject = {
|
||||||
|
freq: repetitionSource.freq,
|
||||||
|
interval: repetitionSource.interval || 1,
|
||||||
|
occurrences: repetitionSource.occurrences,
|
||||||
|
endDate: repetitionSource.endDate,
|
||||||
|
byday: repetitionSource.byday || null,
|
||||||
|
};
|
||||||
|
setRepetition(repetitionData);
|
||||||
|
setShowRepeat(true);
|
||||||
|
} else {
|
||||||
|
setRepetition({} as RepetitionObject);
|
||||||
|
setShowRepeat(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendees - filter out organizer
|
||||||
|
const organizerAddress = organizerEmail || event.organizer?.cal_address;
|
||||||
|
setAttendees(
|
||||||
|
event.attendee
|
||||||
|
? event.attendee.filter(
|
||||||
|
(a: userAttendee) => a.cal_address !== organizerAddress
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
setAlarm(event.alarm?.trigger ?? "");
|
||||||
|
setEventClass(event.class ?? "PUBLIC");
|
||||||
|
setBusy(event.transp ?? "OPAQUE");
|
||||||
|
setTimezone(eventTimezone);
|
||||||
|
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||||
|
setMeetingLink(event.x_openpass_videoconference || null);
|
||||||
|
|
||||||
|
// Update description to include video conference footer if exists
|
||||||
|
if (event.x_openpass_videoconference && event.description) {
|
||||||
|
const hasVideoFooter = event.description.includes("Visio:");
|
||||||
|
if (!hasVideoFooter) {
|
||||||
|
setDescription(
|
||||||
|
addVideoConferenceToDescription(
|
||||||
|
event.description,
|
||||||
|
event.x_openpass_videoconference
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDescription(event.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user