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:
@@ -7,6 +7,34 @@ import moment from "moment-timezone";
|
||||
import { refreshSingularCalendar } from "../../Event/utils/eventUtils";
|
||||
import { ThunkDispatch } from "@reduxjs/toolkit";
|
||||
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 = (
|
||||
currentTime: Date,
|
||||
@@ -72,20 +100,39 @@ export const eventToFullCalendarFormat = (
|
||||
return filteredEvents
|
||||
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
|
||||
.map((e) => {
|
||||
if (e.calId.split("/")[0] === userId) {
|
||||
return {
|
||||
...e,
|
||||
title: formatEventChipTitle(e, t),
|
||||
colors: e.color,
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
const eventTimezone = e.timezone || "Etc/UTC";
|
||||
const isAllDay = e.allday ?? false;
|
||||
|
||||
const convertedEvent: any = {
|
||||
...e,
|
||||
title: formatEventChipTitle(e, t),
|
||||
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
|
||||
*/
|
||||
|
||||
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
|
||||
* @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
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user