diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index b21be5b..930c0a0 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -263,7 +263,7 @@ describe("EventPopover", () => { const newEvent = { title: "Meeting", start: "2025-07-18T00:00:00.000Z", - end: "2025-07-19T00:00:00.000Z", + end: "2025-07-18T00:00:00.000Z", allday: false, uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", description: "Discuss project", diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 5e9d0d9..20f6753 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -11,7 +11,6 @@ import { Select, SelectChangeEvent, TextField, - Typography, ToggleButtonGroup, ToggleButton, } from "@mui/material"; @@ -38,12 +37,8 @@ import { FieldWithLabel } from "./components/FieldWithLabel"; import { DateTimeFields } from "./components/DateTimeFields"; import { useAllDayToggle } from "./hooks/useAllDayToggle"; import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers"; -import { getEndDateForStartChange } from "./utils/dateRules"; -import { - formatLocalDateTime, - formatDateTimeInTimezone, - getRoundedCurrentTime, -} from "./utils/dateTimeFormatters"; +import {} from "./utils/dateRules"; +import {} from "./utils/dateTimeFormatters"; import { validateEventForm } from "./utils/formValidation"; interface EventFormFieldsProps { @@ -165,10 +160,10 @@ export default function EventFormFields({ const [endTime, setEndTime] = React.useState(""); // UI state for showing/hiding end date field - const [showEndDate, setShowEndDate] = React.useState(false); + // keep local flag if needed for future UI toggles (not used now) // Use all-day toggle hook - const { originalTimeRef, handleAllDayToggle } = useAllDayToggle({ + const { handleAllDayToggle } = useAllDayToggle({ allday, start, end, @@ -178,7 +173,6 @@ export default function EventFormFields({ endTime, setStartTime, setEndTime, - setEndDate, setStart, setEnd, setAllDay, @@ -250,47 +244,19 @@ export default function EventFormFields({ } }, [end]); - // Sync allday prop changes to detect all-day slot clicks - React.useEffect(() => { - if (allday && (!startTime || !endTime)) { - // This is likely from all-day slot click - set time fields empty - setStartTime(""); - setEndTime(""); - - // Mark this as coming from all-day slot for later uncheck logic - originalTimeRef.current = { - start: "", - end: "", - endDate: endDate, - fromAllDaySlot: true, - }; - } - }, [allday, startTime, endTime, endDate]); - // Change handlers for 4 separate fields const handleStartDateChange = React.useCallback( (newDate: string) => { setStartDate(newDate); const newStart = combineDateTime(newDate, startTime); - // Update start if (onStartChange) { onStartChange(newStart); } else { setStart(newStart); } - - // Rule: update end date when start date changes - const nextEndDate = getEndDateForStartChange(newDate, allday); - setEndDate(nextEndDate); - const newEnd = combineDateTime(nextEndDate, endTime || startTime); - if (onEndChange) { - onEndChange(newEnd); - } else { - setEnd(newEnd); - } }, - [startTime, endTime, onStartChange, onEndChange, setStart, setEnd, allday] + [startTime, onStartChange, setStart] ); const handleStartTimeChange = React.useCallback( @@ -359,19 +325,6 @@ export default function EventFormFields({ onValidationChange?.(validation.isValid); }, [validateForm, onValidationChange]); - // Auto-calculate end date from start date if not already set - React.useEffect(() => { - if (startDate && !endDate) { - setEndDate(startDate); - const newEnd = combineDateTime(startDate, endTime || startTime); - if (onEndChange) { - onEndChange(newEnd); - } else { - setEnd(newEnd); - } - } - }, [startDate, endDate, startTime, endTime, onEndChange, setEnd]); - // Auto-calculate end time from start time (+ 1 hour) if not already set React.useEffect(() => { if (startTime && !endTime && !allday) { @@ -412,7 +365,6 @@ export default function EventFormFields({ }; const handleDeleteVideoConference = () => { - // Remove video conference footer from description const updatedDescription = description.replace( /\nVisio: https?:\/\/[^\s]+/, "" @@ -510,8 +462,8 @@ export default function EventFormFields({ onStartTimeChange={handleStartTimeChange} onEndDateChange={handleEndDateChange} onEndTimeChange={handleEndTimeChange} - showEndDate={showEndDate} - onToggleEndDate={() => setShowEndDate(!showEndDate)} + showEndDate={showMore || allday} + onToggleEndDate={() => {}} /> @@ -666,7 +618,6 @@ export default function EventFormFields({ - {/* Extended options */} {showMore && ( <> diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx index 0e741d0..65465c3 100644 --- a/src/components/Event/components/DateTimeFields.tsx +++ b/src/components/Event/components/DateTimeFields.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { TimePicker } from "@mui/x-date-pickers/TimePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import moment, { Moment } from "moment"; +import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters"; /** * Props for DateTimeFields component @@ -49,105 +49,134 @@ export const DateTimeFields: React.FC = ({ onEndDateChange, onEndTimeChange, }) => { + const isExpanded = showMore; + const shouldShowEndDateNormal = allday || !!showEndDate; return ( - {/* First row: 4 fields */} - - {/* Start Date - 30% */} - - { - onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); - }} - slotProps={{ - textField: { - size: "small", - margin: "dense" as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - sx: { width: "100%" }, - inputProps: { "data-testid": "start-date-input" }, - }, - }} - /> - - - {/* Start Time - 20% */} - - { - onStartTimeChange(newValue?.format("HH:mm") || ""); - }} - disabled={allday} - slotProps={{ - textField: { - size: "small", - margin: "dense" as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - sx: { width: "100%" }, - inputProps: { "data-testid": "start-time-input" }, - }, - }} - /> - - - {/* End Time - 20% */} - - { - onEndTimeChange(newValue?.format("HH:mm") || ""); - }} - disabled={allday} - slotProps={{ - textField: { - size: "small", - margin: "dense" as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - error: !!validation.errors.dateTime, - sx: { width: "100%" }, - inputProps: { "data-testid": "end-time-input" }, - }, - }} - /> - - - {/* End Date - Conditional rendering */} - - {!showEndDate ? ( - // Show "..." button to reveal end date - - - - - - + {isExpanded ? ( + <> + + + { + onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + sx: { width: "100%" }, + inputProps: { "data-testid": "start-date-input" }, + }, + }} + /> - ) : ( - // Show End Date picker (no hide button) + {!allday && ( + + { + onStartTimeChange(newValue?.format("HH:mm") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + sx: { width: "100%" }, + inputProps: { "data-testid": "start-time-input" }, + }, + }} + /> + + )} + + + + { + onEndDateChange(newValue?.format("YYYY-MM-DD") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + error: !!validation.errors.dateTime, + sx: { width: "100%" }, + inputProps: { "data-testid": "end-date-input" }, + }, + }} + /> + + {!allday && ( + + { + onEndTimeChange(newValue?.format("HH:mm") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + error: !!validation.errors.dateTime, + sx: { width: "100%" }, + inputProps: { "data-testid": "end-time-input" }, + }, + }} + /> + + )} + + + ) : shouldShowEndDateNormal ? ( + + + { + onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + sx: { width: "100%" }, + inputProps: { "data-testid": "start-date-input" }, + }, + }} + /> + + { onEndDateChange(newValue?.format("YYYY-MM-DD") || ""); @@ -164,18 +193,81 @@ export const DateTimeFields: React.FC = ({ }, }} /> - )} + - + ) : ( + + + { + onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); + }} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + sx: { width: "100%" }, + inputProps: { "data-testid": "start-date-input" }, + }, + }} + /> + + + { + onStartTimeChange(newValue?.format("HH:mm") || ""); + }} + disabled={allday} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + sx: { width: "100%" }, + inputProps: { "data-testid": "start-time-input" }, + }, + }} + /> + + + { + onEndTimeChange(newValue?.format("HH:mm") || ""); + }} + disabled={allday} + slotProps={{ + textField: { + size: "small", + margin: "dense" as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + error: !!validation.errors.dateTime, + sx: { width: "100%" }, + inputProps: { "data-testid": "end-time-input" }, + }, + }} + /> + + + )} - {/* Second row: Error message - 2 columns 50% each */} + {/* Second row: Error message */} {validation.errors.dateTime && ( - {/* Empty left column - 50% */} - - - {/* Error message right column - 50% */} - + + {validation.errors.dateTime} diff --git a/src/components/Event/hooks/useAllDayToggle.ts b/src/components/Event/hooks/useAllDayToggle.ts index 3ddfa23..62337c0 100644 --- a/src/components/Event/hooks/useAllDayToggle.ts +++ b/src/components/Event/hooks/useAllDayToggle.ts @@ -1,6 +1,5 @@ import React from "react"; import { combineDateTime } from "../utils/dateTimeHelpers"; -import { getEndDateForToggle } from "../utils/dateRules"; import { getRoundedCurrentTime } from "../utils/dateTimeFormatters"; /** @@ -16,7 +15,6 @@ export interface AllDayToggleParams { endTime: string; setStartTime: (time: string) => void; setEndTime: (time: string) => void; - setEndDate: (date: string) => void; setStart: (start: string) => void; setEnd: (end: string) => void; setAllDay: (allday: boolean) => void; @@ -53,7 +51,6 @@ export function useAllDayToggle( endTime, setStartTime, setEndTime, - setEndDate, setStart, setEnd, setAllDay, @@ -73,97 +70,32 @@ export function useAllDayToggle( let newStart = start; let newEnd = end; - if (newAllDay) { - // OFF => ON: Save original time AND original end date - if (start.includes("T")) { - originalTimeRef.current = { - start: startTime, - end: endTime, - endDate: endDate, // Save original end date - }; - } + if (!newAllDay) { + const hasTimeParts = start.includes("T") && end.includes("T"); + if (!hasTimeParts && !startTime && !endTime) { + const now = new Date(); + now.setSeconds(0); + now.setMilliseconds(0); + const nextHour = new Date(now); + nextHour.setMinutes(0); + nextHour.setHours(now.getHours() + 1); - // Reset time to empty, keep only date part - setStartTime(""); - setEndTime(""); - newStart = startDate; - const nextEndDate = getEndDateForToggle({ - nextAllDay: true, - fromAllDaySlot: !!originalTimeRef.current?.fromAllDaySlot, - startDate, - previousEndDate: endDate, - originalEndDate: originalTimeRef.current?.endDate, - }); - newEnd = nextEndDate; - setEndDate(nextEndDate); - } else { - // ON => OFF: Restore original time AND original end date - if (originalTimeRef.current) { - // Check if this came from all-day slot click - if (originalTimeRef.current.fromAllDaySlot) { - // From all-day slot: set endDate = startDate, use default time - const currentTime = getRoundedCurrentTime(); - const hours = String(currentTime.getHours()).padStart(2, "0"); - const minutes = String(currentTime.getMinutes()).padStart(2, "0"); - const timeStr = `${hours}:${minutes}`; + const startHours = String(nextHour.getHours()).padStart(2, "0"); + const startMinutes = String(nextHour.getMinutes()).padStart(2, "0"); + const startTimeStr = `${startHours}:${startMinutes}`; - newStart = combineDateTime(startDate, timeStr); - - // End time = start time + 1 hour - const endTimeDate = new Date(currentTime); - endTimeDate.setHours(endTimeDate.getHours() + 1); - const endHours = String(endTimeDate.getHours()).padStart(2, "0"); - const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0"); - const endTimeStr = `${endHours}:${endMinutes}`; - - newEnd = combineDateTime(startDate, endTimeStr); // Use startDate as endDate - - // Update internal states - setStartTime(timeStr); - setEndTime(endTimeStr); - setEndDate(startDate); // Set endDate = startDate - } else { - // Normal case: restore original time AND original end date - const restoredEndDate = getEndDateForToggle({ - nextAllDay: false, - startDate, - previousEndDate: endDate, - originalEndDate: originalTimeRef.current.endDate, - }); - - newStart = combineDateTime(startDate, originalTimeRef.current.start); - newEnd = combineDateTime( - restoredEndDate, - originalTimeRef.current.end - ); - - // Update internal states - setStartTime(originalTimeRef.current.start); - setEndTime(originalTimeRef.current.end); - setEndDate(restoredEndDate); - } - - originalTimeRef.current = null; - } else { - // No original time: use rounded current time with 1 hour duration - const currentTime = getRoundedCurrentTime(); - const hours = String(currentTime.getHours()).padStart(2, "0"); - const minutes = String(currentTime.getMinutes()).padStart(2, "0"); - const timeStr = `${hours}:${minutes}`; - - newStart = combineDateTime(startDate, timeStr); - - // End time = start time + 1 hour (not 30 mins) - const endTimeDate = new Date(currentTime); - endTimeDate.setHours(endTimeDate.getHours() + 1); - const endHours = String(endTimeDate.getHours()).padStart(2, "0"); - const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0"); + const endHourDate = new Date(nextHour); + endHourDate.setHours(endHourDate.getHours() + 1); + const endHours = String(endHourDate.getHours()).padStart(2, "0"); + const endMinutes = String(endHourDate.getMinutes()).padStart(2, "0"); const endTimeStr = `${endHours}:${endMinutes}`; - newEnd = combineDateTime(endDate, endTimeStr); + const startDateOnly = start.split("T")[0] || startDate; + const endDateOnly = end.split("T")[0] || endDate || startDateOnly; + newStart = combineDateTime(startDateOnly, startTimeStr); + newEnd = combineDateTime(endDateOnly, endTimeStr); - // Update internal states - setStartTime(timeStr); + setStartTime(startTimeStr); setEndTime(endTimeStr); } } @@ -185,7 +117,6 @@ export function useAllDayToggle( endTime, setStartTime, setEndTime, - setEndDate, setStart, setEnd, setAllDay, diff --git a/src/components/Event/utils/dateTimeFormatters.ts b/src/components/Event/utils/dateTimeFormatters.ts index cba91a5..9fdf706 100644 --- a/src/components/Event/utils/dateTimeFormatters.ts +++ b/src/components/Event/utils/dateTimeFormatters.ts @@ -78,3 +78,6 @@ export function getRoundedCurrentTime(): Date { now.setMilliseconds(0); return now; } + +/** Long date display format for date pickers */ +export const LONG_DATE_FORMAT = "dddd, MMMM D, YYYY"; diff --git a/src/components/Event/utils/formValidation.ts b/src/components/Event/utils/formValidation.ts index ad3f370..1569406 100644 --- a/src/components/Event/utils/formValidation.ts +++ b/src/components/Event/utils/formValidation.ts @@ -54,7 +54,7 @@ export function validateEventForm(params: ValidationParams): ValidationResult { // Validate start time (if not all-day) else if (!allday && (!startTime || startTime.trim() === "")) { isDateTimeValid = false; - dateTimeError = "Start time is required"; + dateTimeError = "Start time and End time is required"; } // Validate end date else if (!endDate || endDate.trim() === "") { @@ -66,21 +66,36 @@ export function validateEventForm(params: ValidationParams): ValidationResult { isDateTimeValid = false; dateTimeError = "End time is required"; } - // Validate end > start + // Validate end vs start else { - const startDateTime = allday - ? new Date(startDate + "T00:00:00") - : new Date(combineDateTime(startDate, startTime)); - const endDateTime = allday - ? new Date(endDate + "T00:00:00") - : new Date(combineDateTime(endDate, endTime)); + if (allday) { + const toLocalDate = (ymd: string) => { + const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10)); + if (!y || !m || !d) return new Date(NaN); + return new Date(y, m - 1, d); + }; - if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) { - isDateTimeValid = false; - dateTimeError = "Invalid date/time"; - } else if (endDateTime <= startDateTime) { - isDateTimeValid = false; - dateTimeError = "End time must be after start time"; + const startOnly = toLocalDate(startDate); + const endOnly = toLocalDate(endDate); + + if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) { + isDateTimeValid = false; + dateTimeError = "Invalid date"; + } else if (endOnly < startOnly) { + isDateTimeValid = false; + dateTimeError = "End date must be on or after start date"; + } + } else { + const startDateTime = new Date(combineDateTime(startDate, startTime)); + const endDateTime = new Date(combineDateTime(endDate, endTime)); + + if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) { + isDateTimeValid = false; + dateTimeError = "Invalid date/time"; + } else if (endDateTime <= startDateTime) { + isDateTimeValid = false; + dateTimeError = "End time must be after start time"; + } } } diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 402f22a..33cb0f4 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -221,12 +221,21 @@ function EventPopover({ : formatLocalDateTime(selectedRange.end); // Use the string values directly to preserve the displayed time - setStart( - selectedRange.allDay ? startStr.split("T")[0] : startStr.slice(0, 16) // YYYY-MM-DDTHH:mm - ); - setEnd( - selectedRange.allDay ? endStr.split("T")[0] : endStr.slice(0, 16) - ); + const startValue = selectedRange.allDay + ? startStr.split("T")[0] + : startStr.slice(0, 16); // YYYY-MM-DDTHH:mm + const endValue = selectedRange.allDay + ? endStr.split("T")[0] + : endStr.slice(0, 16); + setStart(startValue); + setEnd(endValue); + + // If start date != end date, open extended mode + const startDateOnly = startValue.slice(0, 10); + const endDateOnly = endValue.slice(0, 10); + if (startDateOnly !== endDateOnly) { + setShowMore(true); + } } else { // Fallback: format Date objects using local time components // Only set if both start and end are valid @@ -234,6 +243,13 @@ function EventPopover({ const formattedEnd = formatLocalDateTime(selectedRange.end); if (formattedStart) setStart(formattedStart); if (formattedEnd) setEnd(formattedEnd); + if (formattedStart && formattedEnd) { + const startDateOnly = formattedStart.slice(0, 10); + const endDateOnly = formattedEnd.slice(0, 10); + if (startDateOnly !== endDateOnly) { + setShowMore(true); + } + } } } else { // No valid selectedRange - use default times @@ -479,7 +495,7 @@ function EventPopover({ calId: calList[calendarid].id, title, URL: `/calendars/${calList[calendarid].id}/${newEventUID}.ics`, - start: new Date(start).toISOString(), + start: "", allday, uid: newEventUID, description, @@ -503,8 +519,19 @@ function EventPopover({ alarm: { trigger: alarm, action: "EMAIL" }, x_openpass_videoconference: meetingLink || undefined, }; - if (end) { - newEvent.end = new Date(end).toISOString(); + + if (allday) { + const startDateOnly = (start || "").split("T")[0]; + const endDateOnlyBase = (end || start || "").split("T")[0]; + const startDateObj = new Date(`${startDateOnly}T00:00:00`); + const endDateObj = new Date(`${endDateOnlyBase}T00:00:00`); + newEvent.start = startDateObj.toISOString(); + newEvent.end = endDateObj.toISOString(); + } else { + newEvent.start = new Date(start).toISOString(); + if (end) { + newEvent.end = new Date(end).toISOString(); + } } if (attendees.length > 0) {