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

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

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

* refactor: eliminate datetime format detection code duplication

- Add constants for datetime format strings and magic number (DATETIME_WITH_SECONDS_LENGTH, DATETIME_FORMAT_WITH_SECONDS, DATETIME_FORMAT_WITHOUT_SECONDS)
- Create shared detectDateTimeFormat() function to replace duplicated format detection logic
- Refactor convertFormDateTimeToISO, convertEventDateTimeToISO, and convertDateTimeStringToISO to use shared helper
- Add console.warn logging when invalid datetime is encountered (addresses silent fallback issue)
- Add comprehensive test coverage for dateTimeHelpers with full test suite
This commit is contained in:
lenhanphung
2025-11-20 15:32:15 +07:00
committed by GitHub
parent 8e7b6e18eb
commit bff1af31bd
10 changed files with 951 additions and 138 deletions
@@ -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();
});
});
});
+106 -30
View File
@@ -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", () => {
+57 -10
View File
@@ -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();
}
+12 -6
View File
@@ -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;
}
}); });
} }
) )
+117 -7
View File
@@ -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) {
+58 -45
View File
@@ -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);
} }
}; };
+43 -18
View File
@@ -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);
} }
}; };
+150 -22
View File
@@ -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 };
} }
+192
View File
@@ -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);
}
}
}