From b359ff1b8ee91206ff9f744d9d1c581b90d7f490 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sun, 11 Jun 2023 21:48:45 +0200 Subject: [PATCH] [Feature] Add timepicker to calendar (#5183) * added timepicker as children to calendar * new timepicker component * Add timepicker * new timePicker component * timepicker as calendar child * hover:border and rounded * adjusted width * complete rework * more code comments * memorization * preselect hover, transition * numberOfDaysSelected has minimum of 1 * prefill hours when component mounts * persist hours when component mount * accommodate for the new timePicker * add reset state * scroll into view * reuse before, after * fix LastDayInRange when a time is selected * do not add hours if before is zero * use hours instead of days * useeffect to reset hour. check timerange before scroll * scroll to last element, not first --- web/src/components/Calendar.jsx | 31 ++-- web/src/components/TimePicker.jsx | 239 ++++++++++++++++++++++++++++++ web/src/routes/Events.jsx | 31 ++-- 3 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 web/src/components/TimePicker.jsx diff --git a/web/src/components/Calendar.jsx b/web/src/components/Calendar.jsx index 3525ce0f1..faaf5544b 100644 --- a/web/src/components/Calendar.jsx +++ b/web/src/components/Calendar.jsx @@ -5,7 +5,7 @@ import ArrowRightDouble from '../icons/ArrowRightDouble'; const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf(); -const Calendar = ({ onChange, calendarRef, close, dateRange }) => { +const Calendar = ({ onChange, calendarRef, close, dateRange, children }) => { const keyRef = useRef([]); const date = new Date(); @@ -102,8 +102,9 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => { ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month), + timeRange: { ...dateRange }, })); - }, [year, month, getMonthDetails]); + }, [year, month, getMonthDetails, dateRange]); useEffect(() => { // add refs for keyboard navigation @@ -121,7 +122,7 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => { (day) => { if (!state.timeRange.after || !state.timeRange.before) return; - return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after; + return day.timestamp < state.timeRange.before && day.timestamp >= new Date(state.timeRange.after).setHours(0); }, [state.timeRange] ); @@ -129,14 +130,21 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => { const isFirstDayInRange = useCallback( (day) => { if (isCurrentDay(day)) return; - return state.timeRange.after === day.timestamp; + return new Date(state.timeRange.after).setHours(0) === day.timestamp; }, [state.timeRange.after] ); const isLastDayInRange = useCallback( (day) => { - return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0); + // if the hour is not above 0, we will use 24 hour. + const beforeHour = new Date(state.timeRange.before).getHours() || 24; + + /** + * When user selects a day in the calendar, the before will be 00:00. + * When user selects a time in timepicker, the day.timestamp hour must be changed to match the selected end () hour. + */ + return state.timeRange.before === new Date(day.timestamp).setHours(beforeHour); }, [state.timeRange.before] ); @@ -256,12 +264,12 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => { onkeydown={(e) => handleKeydown(e, day, idx)} ref={(ref) => (keyRef.current[idx] = ref)} tabIndex={day.month === 0 ? day.date : null} - className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${ + className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer hover:border hover:rounded-md border-gray-600 ${ day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : '' } - ${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''} - ${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''} - ${isLastDayInRange(day) ? ' rounded-r-xl ' : ''} + ${isFirstDayInRange(day) ? ' rounded-l-xl hover:rounded-l-xl' : ''} + ${isSelectedRange(day) ? ' bg-blue-600 hover:rounded-none' : ''} + ${isLastDayInRange(day) ? ' rounded-r-xl hover:rounded-r-xl' : ''} ${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`} key={idx} > @@ -287,8 +295,8 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => { }; return ( -
-
+
+
{
{renderCalendar()}
+ {children}
); }; diff --git a/web/src/components/TimePicker.jsx b/web/src/components/TimePicker.jsx new file mode 100644 index 000000000..edb39bd52 --- /dev/null +++ b/web/src/components/TimePicker.jsx @@ -0,0 +1,239 @@ +import { h } from 'preact'; +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { ArrowDropdown } from '../icons/ArrowDropdown'; +import { ArrowDropup } from '../icons/ArrowDropup'; + +const TimePicker = ({ dateRange, onChange }) => { + const [error, setError] = useState(null); + const [timeRange, setTimeRange] = useState(new Set()); + const [hoverIdx, setHoverIdx] = useState(null); + const [reset, setReset] = useState(false); + + /** + * Initializes two variables before and after with date objects, + * If they are not null, it creates a new Date object with the value of the property and if not, + * it creates a new Date object with the current hours to 0 and 24 respectively. + */ + const before = useMemo(() => { + return dateRange.before ? new Date(dateRange.before) : new Date(new Date().setHours(24, 0, 0, 0)); + }, [dateRange]); + + const after = useMemo(() => { + return dateRange.after ? new Date(dateRange.after) : new Date(new Date().setHours(0, 0, 0, 0)); + }, [dateRange]); + + useEffect(() => { + /** + * This will reset hours when user selects another date in the calendar. + */ + if (before.getHours() === 0 && after.getHours() === 0 && timeRange.size > 1) return setTimeRange(new Set()); + }, [after, before, timeRange]); + + useEffect(() => { + if (reset || !after) return; + /** + * calculates the number of hours between two dates, by finding the difference in days, + * converting it to hours and adding the hours from the before date. + */ + const days = Math.max(before.getDate() - after.getDate()); + const hourOffset = days * 24; + const beforeOffset = before.getHours() ? hourOffset + before.getHours() : 0; + + /** + * Fills the timeRange by iterating over the hours between 'after' and 'before' during component mount, to keep the selected hours persistent. + */ + for (let hour = after.getHours(); hour < beforeOffset; hour++) { + setTimeRange((timeRange) => timeRange.add(hour)); + } + + /** + * find an element by the id timeIndex- concatenated with the minimum value from timeRange array, + * and if that element is present, it will scroll into view if needed + */ + if (timeRange.size > 1) { + const element = document.getElementById(`timeIndex-${Math.max(...timeRange)}`); + if (element) { + element.scrollIntoViewIfNeeded(true); + } + } + }, [after, before, timeRange, reset]); + + /** + * numberOfDaysSelected is a set that holds the number of days selected in the dateRange. + * The loop iterates through the days starting from the after date's day to the before date's day. + * If the before date's hour is 0, it skips it. + */ + const numberOfDaysSelected = useMemo(() => { + return new Set([...Array(Math.max(1, before.getDate() - after.getDate() + 1))].map((_, i) => after.getDate() + i)); + }, [before, after]); + + if (before.getHours() === 0) numberOfDaysSelected.delete(before.getDate()); + + // Create repeating array with the number of hours for each day selected ...23,24,0,1,2... + const hoursInDays = useMemo(() => { + return Array.from({ length: numberOfDaysSelected.size * 24 }, (_, i) => i % 24); + }, [numberOfDaysSelected]); + + // function for handling the selected time from the provided list + const handleTime = useCallback( + (hour) => { + if (isNaN(hour)) return; + + const _timeRange = new Set([...timeRange]); + _timeRange.add(hour); + + // reset error messages + setError(null); + + /** + * Check if the variable "hour" exists in the "timeRange" set. + * If it does, reset the timepicker + */ + if (timeRange.has(hour)) { + setTimeRange(new Set()); + setReset(true); + const resetBefore = before.setDate(after.getDate() + numberOfDaysSelected.size - 1); + return onChange({ + after: after.setHours(0, 0, 0, 0) / 1000, + before: new Date(resetBefore).setHours(24, 0, 0, 0) / 1000, + }); + } + + //update after + if (_timeRange.size === 1) { + // check if the first selected value is within first day + const firstSelectedHour = Math.ceil(Math.max(..._timeRange)); + if (firstSelectedHour > 23) { + return setError('Select a time on the initial day!'); + } + + // calculate days offset + const dayOffsetAfter = new Date(after).setHours(Math.min(..._timeRange)); + + let dayOffsetBefore = before; + if (numberOfDaysSelected.size === 1) { + dayOffsetBefore = new Date(after).setHours(Math.min(..._timeRange) + 1); + } + + onChange({ + after: dayOffsetAfter / 1000, + before: dayOffsetBefore / 1000, + }); + } + + //update before + if (_timeRange.size > 1) { + let selectedDay = Math.ceil(Math.max(..._timeRange) / 24); + + // if user selects time 00:00 for the next day, add one day + if (hour === 24 && selectedDay === numberOfDaysSelected.size - 1) { + selectedDay += 1; + } + + // Check if end time is on the last day + if (selectedDay !== numberOfDaysSelected.size) { + return setError('Ending must occur on final day!'); + } + + // Check if end time is later than start time + const startHour = Math.min(..._timeRange); + if (hour <= startHour) { + return setError('Ending hour must be greater than start time!'); + } + + // Add all hours between start and end times to the set + for (let x = startHour; x <= hour; x++) { + _timeRange.add(x); + } + + // calculate days offset + const dayOffsetBefore = new Date(dateRange.after); + onChange({ + after: dateRange.after / 1000, + // we add one hour to get full 60min of last selected hour + before: dayOffsetBefore.setHours(Math.max(..._timeRange) + 1) / 1000, + }); + } + + for (let i = 0; i < _timeRange.size; i++) { + setTimeRange((timeRange) => timeRange.add(Array.from(_timeRange)[i])); + } + }, + [after, before, timeRange, dateRange.after, numberOfDaysSelected.size, onChange] + ); + const isSelected = useCallback( + (idx) => { + return !!timeRange.has(idx); + }, + [timeRange] + ); + + const isHovered = useCallback( + (idx) => { + return timeRange.size === 1 && idx > Math.max(...timeRange) && idx <= hoverIdx; + }, + [timeRange, hoverIdx] + ); + + // background colors for each day + const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none'; + function randomGrayTone(shade) { + const grayTones = [ + 'bg-[#212529]/50', + 'bg-[#343a40]/50', + 'bg-[#495057]/50', + 'bg-[#666463]/50', + 'bg-[#817D7C]/50', + 'bg-[#73706F]/50', + 'bg-[#585655]/50', + 'bg-[#4F4D4D]/50', + 'bg-[#454343]/50', + 'bg-[#363434]/50', + ]; + return grayTones[shade % grayTones.length]; + } + + return ( + <> + {error ? {error} : null} +
+
+ +
+
+
+ {hoursInDays.map((_, idx) => ( +
1 && Math.max(...timeRange) === idx ? 'rounded-b-lg' : ''}`} + onMouseEnter={() => setHoverIdx(idx)} + onMouseLeave={() => setHoverIdx(null)} + > +
handleTime(idx)} + > + {hoursInDays[idx]}:00 +
+
+ ))} +
+
+ +
+
+
+ + ); +}; + +export default TimePicker; diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index 2502c4cb1..21b282a53 100644 --- a/web/src/routes/Events.jsx +++ b/web/src/routes/Events.jsx @@ -27,6 +27,7 @@ import Dialog from '../components/Dialog'; import MultiSelect from '../components/MultiSelect'; import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil'; import TimeAgo from '../components/TimeAgo'; +import Timepicker from '../components/TimePicker'; import TimelineSummary from '../components/TimelineSummary'; const API_LIMIT = 25; @@ -437,18 +438,26 @@ export default function Events({ path, ...props }) { /> )} + {state.showCalendar && ( - setState({ ...state, showCalendar: false })} - relativeTo={datePicker} - > - setState({ ...state, showCalendar: false })} - /> - + + setState({ ...state, showCalendar: false })} + relativeTo={datePicker} + > + setState({ ...state, showCalendar: false })} + > + + + + )} {state.showPlusSubmit && (