Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -133,3 +133,8 @@
|
||||
border-radius 50%
|
||||
margin-left auto
|
||||
margin-right auto
|
||||
|
||||
.weekSelector
|
||||
display flex
|
||||
flex-direction column
|
||||
align-items center
|
||||
@@ -11,7 +11,10 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import EventPopover from "../../features/Events/EventModal";
|
||||
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
||||
import CalendarSelection from "./CalendarSelection";
|
||||
import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
|
||||
import {
|
||||
getCalendarDetailAsync,
|
||||
setTimeZone,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import ImportAlert from "../../features/Events/ImportAlert";
|
||||
import {
|
||||
computeStartOfTheWeek,
|
||||
@@ -36,6 +39,8 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
|
||||
import { EditModeDialog } from "../Event/EditModeDialog";
|
||||
import { EventErrorHandler } from "../Error/EventErrorHandler";
|
||||
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
|
||||
import momentTimezonePlugin from "@fullcalendar/moment-timezone";
|
||||
import { TimezoneSelector } from "./TimezoneSelector";
|
||||
|
||||
interface CalendarAppProps {
|
||||
calendarRef: React.RefObject<CalendarApi | null>;
|
||||
@@ -70,6 +75,10 @@ export default function CalendarApp({
|
||||
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) =>
|
||||
Object.values(calendars[calId].events)
|
||||
);
|
||||
|
||||
const [currentView, setCurrentView] = useState("timeGridWeek");
|
||||
const timezone = useAppSelector((state) => state.calendars.timeZone);
|
||||
|
||||
const fetchedRangesRef = useRef<Record<string, string>>({});
|
||||
|
||||
// Auto-select personal calendars when first loaded
|
||||
@@ -335,15 +344,24 @@ export default function CalendarApp({
|
||||
calendarRef.current = ref.getApi();
|
||||
}
|
||||
}}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
plugins={[
|
||||
dayGridPlugin,
|
||||
timeGridPlugin,
|
||||
interactionPlugin,
|
||||
momentTimezonePlugin,
|
||||
]}
|
||||
initialView="timeGridWeek"
|
||||
firstDay={1}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
timeZone="local"
|
||||
timeZone={timezone}
|
||||
height={"100%"}
|
||||
select={eventHandlers.handleDateSelect}
|
||||
nowIndicator
|
||||
slotLabelClassNames={(arg) => [
|
||||
updateSlotLabelVisibility(new Date(), arg, timezone),
|
||||
]}
|
||||
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
|
||||
headerToolbar={false}
|
||||
views={{
|
||||
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
|
||||
@@ -356,6 +374,23 @@ export default function CalendarApp({
|
||||
)}
|
||||
weekNumbers
|
||||
weekNumberFormat={{ week: "long" }}
|
||||
weekNumberContent={(arg) => {
|
||||
const showSelector =
|
||||
currentView === "timeGridWeek" || currentView === "timeGridDay";
|
||||
return (
|
||||
<div className="weekSelector">
|
||||
<div>{arg.text}</div>
|
||||
{showSelector && (
|
||||
<TimezoneSelector
|
||||
value={timezone}
|
||||
onChange={(newTimezone: string) =>
|
||||
dispatch(setTimeZone(newTimezone))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
slotDuration={"00:30:00"}
|
||||
slotLabelInterval={"01:00:00"}
|
||||
scrollTime="12:00:00"
|
||||
@@ -367,6 +402,7 @@ export default function CalendarApp({
|
||||
hour12: false,
|
||||
}}
|
||||
datesSet={(arg) => {
|
||||
setCurrentView(arg.view.type);
|
||||
// Get the current date from calendar API to ensure consistency
|
||||
const calendarCurrentDate =
|
||||
calendarRef.current?.getDate() || new Date(arg.start);
|
||||
|
||||
@@ -157,7 +157,8 @@ th.fc-col-header-cell.fc-day
|
||||
position relative
|
||||
border 0
|
||||
|
||||
.fc .fc-timegrid-now-indicator-arrow::before
|
||||
.fc .fc-timegrid-now-indicator-arrow::before,
|
||||
.now-time-label
|
||||
content attr(data-time)
|
||||
color #f67e35
|
||||
white-space nowrap
|
||||
@@ -169,6 +170,9 @@ th.fc-col-header-cell.fc-day
|
||||
font-size 14px
|
||||
letter-spacing 0.25px
|
||||
|
||||
.timegrid-slot-label-hidden
|
||||
opacity 0.2
|
||||
|
||||
.fc-day-today .fc-timegrid-now-indicator-container
|
||||
overflow unset
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
ListItem,
|
||||
Popover,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { MouseEvent, useMemo, useState } from "react";
|
||||
import { TIMEZONES } from "../../utils/timezone-data";
|
||||
|
||||
interface TimezoneSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function TimezoneSelector({ value, onChange }: TimezoneSelectProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const timezoneList = useMemo(() => {
|
||||
const zones = Object.keys(TIMEZONES.zones).sort();
|
||||
const browserTz = resolveTimezone(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
|
||||
return { zones, browserTz, getTimezoneOffset };
|
||||
}, []);
|
||||
const options = useMemo(() => {
|
||||
return timezoneList.zones.map((tz) => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, " "),
|
||||
offset: timezoneList.getTimezoneOffset(tz),
|
||||
}));
|
||||
}, [timezoneList]);
|
||||
|
||||
const selectedOption =
|
||||
options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
const handleOpen = (event: MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
minWidth: "auto",
|
||||
padding: "2px 4px",
|
||||
margin: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{selectedOption ? selectedOption.offset : "Select Timezone"}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { width: 280, maxHeight: 400, overflow: "auto", p: 1 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TimeZoneSearch
|
||||
selectedOption={selectedOption}
|
||||
onChange={onChange}
|
||||
handleClose={handleClose}
|
||||
options={options}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function TimeZoneSearch({
|
||||
selectedOption,
|
||||
onChange,
|
||||
handleClose,
|
||||
options,
|
||||
}: {
|
||||
selectedOption: { value: string; label: string; offset: string };
|
||||
onChange: (value: string) => void;
|
||||
handleClose: () => void;
|
||||
options: { value: string; label: string; offset: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Autocomplete
|
||||
autoFocus
|
||||
value={selectedOption}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value);
|
||||
handleClose(); // close after selection
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
getOptionLabel={(option) => `${option.offset} ${option.label}`}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
style: {
|
||||
fontSize: "10px",
|
||||
padding: "2px 4px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
disableClearable
|
||||
renderValue={(value) => <div>{value.offset}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimezone(tzName: string): string {
|
||||
if (TIMEZONES.zones[tzName]) {
|
||||
return tzName;
|
||||
}
|
||||
if (TIMEZONES.aliases[tzName]) {
|
||||
return TIMEZONES.aliases[tzName].aliasTo;
|
||||
}
|
||||
return tzName;
|
||||
}
|
||||
|
||||
export function 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")}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { CalendarApi } from "@fullcalendar/core";
|
||||
import { CalendarApi, NowIndicatorContentArg } from "@fullcalendar/core";
|
||||
import { updateSlotLabelVisibility } from "../utils/calendarUtils";
|
||||
import { createMouseHandlers } from "./mouseHandlers";
|
||||
import { userAttendee } from "../../../features/User/userDataTypes";
|
||||
@@ -30,6 +30,25 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
|
||||
errorHandler,
|
||||
} = props;
|
||||
|
||||
const handleNowIndicatorContent = (arg: NowIndicatorContentArg) => {
|
||||
if (arg.isAxis) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ style: { display: "flex", alignItems: "center" } },
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "now-time-label" },
|
||||
new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: arg.view.dateEnv.timeZone,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDayHeaderDidMount = (arg: any) => {
|
||||
if (arg.view.type === "timeGridWeek") {
|
||||
const headerEl = arg.el;
|
||||
@@ -61,54 +80,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
|
||||
};
|
||||
|
||||
const handleViewDidMount = (arg: any) => {
|
||||
const updateNowIndicator = () => {
|
||||
const nowIndicatorArrow = document.querySelector(
|
||||
".fc-timegrid-now-indicator-arrow"
|
||||
) as HTMLElement;
|
||||
if (nowIndicatorArrow) {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
nowIndicatorArrow.setAttribute("data-time", timeString);
|
||||
updateSlotLabelVisibility(now);
|
||||
}
|
||||
};
|
||||
|
||||
updateNowIndicator();
|
||||
const timeInterval = setInterval(updateNowIndicator, 60000);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (
|
||||
element.classList?.contains("fc-timegrid-now-indicator-arrow") ||
|
||||
element.querySelector?.(".fc-timegrid-now-indicator-arrow") ||
|
||||
element.classList?.contains("fc-timegrid-slot-label") ||
|
||||
element.querySelector?.(".fc-timegrid-slot-label")
|
||||
) {
|
||||
setTimeout(() => {
|
||||
updateNowIndicator();
|
||||
updateSlotLabelVisibility(new Date());
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
(arg.el as any).__timeInterval = timeInterval;
|
||||
(arg.el as any).__timeObserver = observer;
|
||||
|
||||
if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") {
|
||||
const calendarEl = document.querySelector(".fc") as HTMLElement;
|
||||
if (calendarEl) {
|
||||
@@ -254,6 +225,7 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
|
||||
};
|
||||
|
||||
return {
|
||||
handleNowIndicatorContent,
|
||||
handleDayHeaderDidMount,
|
||||
handleDayHeaderWillUnmount,
|
||||
handleViewDidMount,
|
||||
|
||||
@@ -27,5 +27,9 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
|
||||
handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [
|
||||
props.calendars,
|
||||
]),
|
||||
handleNowIndicatorContent: useCallback(
|
||||
viewHandlers.handleNowIndicatorContent,
|
||||
[]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,45 +2,43 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes";
|
||||
import { Calendars } from "../../../features/Calendars/CalendarTypes";
|
||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
||||
import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice";
|
||||
import { SlotLabelContentArg } from "@fullcalendar/core";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
export const updateSlotLabelVisibility = (currentTime: Date) => {
|
||||
const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label");
|
||||
export const updateSlotLabelVisibility = (
|
||||
currentTime: Date,
|
||||
slotLabel: SlotLabelContentArg,
|
||||
timezone: string
|
||||
) => {
|
||||
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
|
||||
|
||||
if (!isCurrentWeekOrDay) {
|
||||
slotLabels.forEach((label) => {
|
||||
const labelElement = label as HTMLElement;
|
||||
labelElement.style.opacity = "1";
|
||||
});
|
||||
return;
|
||||
return "fc-timegrid-slot-label";
|
||||
}
|
||||
|
||||
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
const current = moment.tz(currentTime, timezone);
|
||||
const currentMinutes = current.hours() * 60 + current.minutes();
|
||||
const timeText = slotLabel?.text?.trim();
|
||||
|
||||
slotLabels.forEach((label) => {
|
||||
const labelElement = label as HTMLElement;
|
||||
const timeText = labelElement.textContent?.trim();
|
||||
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
|
||||
const [hours, minutes] = timeText.split(":").map(Number);
|
||||
const labelMinutes = hours * 60 + minutes;
|
||||
|
||||
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
|
||||
const [hours, minutes] = timeText.split(":").map(Number);
|
||||
const labelMinutes = hours * 60 + minutes;
|
||||
let timeDiff = Math.abs(currentMinutes - labelMinutes);
|
||||
|
||||
let timeDiff = Math.abs(currentMinutes - labelMinutes);
|
||||
|
||||
if (timeDiff > 12 * 60) {
|
||||
timeDiff = 24 * 60 - timeDiff;
|
||||
}
|
||||
|
||||
if (timeDiff <= 15) {
|
||||
labelElement.style.opacity = "0.2";
|
||||
} else {
|
||||
labelElement.style.opacity = "1";
|
||||
}
|
||||
if (timeDiff > 12 * 60) {
|
||||
timeDiff = 24 * 60 - timeDiff;
|
||||
}
|
||||
});
|
||||
|
||||
if (timeDiff <= 15) {
|
||||
return "timegrid-slot-label-hidden";
|
||||
}
|
||||
}
|
||||
|
||||
return "fc-timegrid-slot-label";
|
||||
};
|
||||
|
||||
const checkIfCurrentWeekOrDay = (): boolean => {
|
||||
export const checkIfCurrentWeekOrDay = (): boolean => {
|
||||
const todayColumn = document.querySelector(".fc-day-today");
|
||||
|
||||
if (!todayColumn) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { dlEvent } from "../../features/Events/EventApi";
|
||||
import EventDisplayModal from "../../features/Events/EventDisplay";
|
||||
import { getTimezoneOffset } from "../Calendar/TimezoneSelector";
|
||||
import { ResponsiveDialog } from "../Dialog";
|
||||
import { EditModeDialog } from "./EditModeDialog";
|
||||
import EventDuplication from "./EventDuplicate";
|
||||
@@ -51,6 +52,9 @@ export default function EventPreviewModal({
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const calendars = useAppSelector((state) => state.calendars);
|
||||
const timezone =
|
||||
useAppSelector((state) => state.calendars.timeZone) ??
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const calendarList = Object.values(
|
||||
useAppSelector((state) => state.calendars.list)
|
||||
);
|
||||
@@ -234,10 +238,10 @@ export default function EventPreviewModal({
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{formatDate(event.start, event.allday)}
|
||||
{formatDate(event.start, timezone, event.allday)}
|
||||
{event.end &&
|
||||
formatEnd(event.start, event.end, event.allday) &&
|
||||
` – ${formatEnd(event.start, event.end, event.allday)}`}
|
||||
formatEnd(event.start, event.end, timezone, event.allday) &&
|
||||
` – ${formatEnd(event.start, event.end, timezone, event.allday)} ${!event.allday && getTimezoneOffset(timezone)}`}
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
@@ -511,13 +515,14 @@ export default function EventPreviewModal({
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string, allday?: boolean) {
|
||||
function formatDate(date: Date | string, timeZone?: string, allday?: boolean) {
|
||||
if (allday) {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
});
|
||||
} else {
|
||||
return new Date(date).toLocaleString(undefined, {
|
||||
@@ -527,11 +532,17 @@ function formatDate(date: Date | string, allday?: boolean) {
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
|
||||
function formatEnd(
|
||||
start: Date | string,
|
||||
end: Date | string,
|
||||
timeZone?: string,
|
||||
allday?: boolean
|
||||
) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
@@ -547,12 +558,14 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
});
|
||||
} else {
|
||||
if (sameDay) {
|
||||
return endDate.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
return endDate.toLocaleString(undefined, {
|
||||
@@ -561,6 +574,7 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user