);
};
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 && (
-
+
+
+
)}
{state.showPlusSubmit && (