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
+43 -18
View File
@@ -30,6 +30,7 @@ import {
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
const showErrorNotification = (message: string) => {
console.error(`[ERROR] ${message}`);
@@ -157,10 +158,13 @@ function EventUpdateModal({
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
);
const [newCalId, setNewCalId] = useState(calId);
const [calendarid, setCalendarid] = useState(
calId ?? userPersonalCalendars[0]?.id ?? ""
const defaultCalendarId = useMemo(
() => userPersonalCalendars[0]?.id ?? "",
[userPersonalCalendars]
);
const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId);
const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [hasVideoConference, setHasVideoConference] = useState(false);
const [meetingLink, setMeetingLink] = useState<string | null>(null);
@@ -178,7 +182,9 @@ function EventUpdateModal({
setLocation("");
setStart("");
setEnd("");
setCalendarid(userPersonalCalendars[0].id);
if (defaultCalendarId) {
setCalendarid(defaultCalendarId);
}
setAllDay(false);
setRepetition({} as RepetitionObject);
setAlarm("");
@@ -189,7 +195,7 @@ function EventUpdateModal({
);
setHasVideoConference(false);
setMeetingLink(null);
}, []);
}, [defaultCalendarId]);
// Prevent repeated initialization loops
const initializedKeyRef = useRef<string | null>(null);
@@ -276,10 +282,15 @@ function EventUpdateModal({
setEventClass(event.class ?? "PUBLIC");
setBusy(event.transp ?? "OPAQUE");
const resolvedTimezone = event.timezone
? resolveTimezone(event.timezone)
: resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
setTimezone(resolvedTimezone);
if (event.timezone) {
const resolvedTimezone = resolveTimezone(event.timezone);
setTimezone(resolvedTimezone);
} else {
const browserTz = resolveTimezone(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
setTimezone(browserTz);
}
setHasVideoConference(event.x_openpass_videoconference ? true : false);
setMeetingLink(event.x_openpass_videoconference || null);
setNewCalId(event.calId || calId);
@@ -384,15 +395,29 @@ function EventUpdateModal({
// For single events or "solo" edits, use the edited dates from form
if (allday) {
// 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
const startDateOnly = new Date(start).toISOString().split("T")[0];
const endDateOnlyUI = new Date(end).toISOString().split("T")[0];
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
startDate = startDateOnly;
endDate = endDateOnlyAPI;
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
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 {
// 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
if (!showMore && !hasEndDateChanged) {
const startDateOnly = (start || "").split("T")[0];
@@ -400,10 +425,10 @@ function EventUpdateModal({
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
endDate = new Date(endDateTime).toISOString();
endDate = convertFormDateTimeToISO(endDateTime, timezone);
} else {
// 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
}
if (tempList) {
const calendarRange = getCalendarRange(new Date(start));
const calendarRange = getCalendarRange(new Date(startDate));
await updateTempCalendar(tempList, event, dispatch, calendarRange);
}
return;
@@ -586,7 +611,7 @@ function EventUpdateModal({
).unwrap();
// STEP 3: Fetch to get new instances with correct timing
const calendarRange = getCalendarRange(new Date(start));
const calendarRange = getCalendarRange(new Date(startDate));
await refreshCalendars(
dispatch,
Object.values(calendarsList),
@@ -695,7 +720,7 @@ function EventUpdateModal({
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
}
if (tempList) {
const calendarRange = getCalendarRange(new Date(start));
const calendarRange = getCalendarRange(new Date(startDate));
await updateTempCalendar(tempList, event, dispatch, calendarRange);
}
};