From e13cca9f95d10f028c58e451293a8618ab7d6ceb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 22 Jun 2024 16:04:18 -0600 Subject: [PATCH] Add date range picker --- .../filter/CalendarFilterButton.tsx | 77 ++- .../components/filter/SearchFilterGroup.tsx | 26 +- web/src/components/ui/calendar-range.tsx | 444 ++++++++++++++++++ web/src/hooks/use-date-utils.ts | 15 + 4 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 web/src/components/ui/calendar-range.tsx diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index 112a9d2a5..0b28bb15b 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -1,4 +1,7 @@ -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { + useFormattedRange, + useFormattedTimestamp, +} from "@/hooks/use-date-utils"; import { ReviewSummary } from "@/types/review"; import { Button } from "../ui/button"; import { FaCalendarAlt } from "react-icons/fa"; @@ -7,6 +10,8 @@ import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { isMobile } from "react-device-detect"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { DateRangePicker } from "../ui/calendar-range"; +import { DateRange } from "react-day-picker"; type CalendarFilterButtonProps = { reviewSummary?: ReviewSummary; @@ -77,3 +82,73 @@ export default function CalendarFilterButton({ ); } + +type CalendarRangeFilterButtonProps = { + range?: DateRange; + defaultText: string; + updateSelectedRange: (range?: DateRange) => void; +}; +export function CalendarRangeFilterButton({ + range, + defaultText, + updateSelectedRange, +}: CalendarRangeFilterButtonProps) { + const selectedDate = useFormattedRange( + range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, + range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, + "%b %-d", + ); + + const trigger = ( + + ); + const content = ( + <> + updateSelectedRange(range.range)} + /> + +
+ +
+ + ); + + if (isMobile) { + return ( + + {trigger} + {content} + + ); + } + + return ( + + {trigger} + {content} + + ); +} diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index bc4f8abcd..e27a65a47 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -15,9 +15,10 @@ import MobileReviewSettingsDrawer, { } from "../overlay/MobileReviewSettingsDrawer"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; -import CalendarFilterButton from "./CalendarFilterButton"; +import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import { SearchFilter } from "@/types/search"; +import { DateRange } from "react-day-picker"; const SEARCH_FILTERS = ["cameras", "date", "general"] as const; type SearchFilters = (typeof SEARCH_FILTERS)[number]; @@ -132,12 +133,14 @@ export default function SearchFilterGroup({ // handle updating filters - const onUpdateSelectedDay = useCallback( - (day?: Date) => { + const onUpdateSelectedRange = useCallback( + (range?: DateRange) => { onUpdateFilter({ ...filter, - after: day == undefined ? undefined : day.getTime() / 1000, - before: day == undefined ? undefined : getEndOfDayTimestamp(day), + after: + range?.from == undefined ? undefined : range.from.getTime() / 1000, + before: + range?.to == undefined ? undefined : getEndOfDayTimestamp(range.to), }); }, [filter, onUpdateFilter], @@ -156,14 +159,17 @@ export default function SearchFilterGroup({ /> )} {isDesktop && filters.includes("date") && ( - )} {isDesktop && filters.includes("general") && ( diff --git a/web/src/components/ui/calendar-range.tsx b/web/src/components/ui/calendar-range.tsx new file mode 100644 index 000000000..f940e772e --- /dev/null +++ b/web/src/components/ui/calendar-range.tsx @@ -0,0 +1,444 @@ +/* eslint-disable max-lines */ +"use client"; + +import { type FC, useState, useEffect, useRef } from "react"; +import { Button } from "./button"; +import { Calendar } from "./calendar"; +import { Label } from "./label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; +import { Switch } from "./switch"; +import { cn } from "@/lib/utils"; +import { LuCheck } from "react-icons/lu"; + +export interface DateRangePickerProps { + /** Click handler for applying the updates from DateRangePicker. */ + onUpdate?: (values: { range: DateRange; rangeCompare?: DateRange }) => void; + /** Initial value for start date */ + initialDateFrom?: Date | string; + /** Initial value for end date */ + initialDateTo?: Date | string; + /** Initial value for start date for compare */ + initialCompareFrom?: Date | string; + /** Initial value for end date for compare */ + initialCompareTo?: Date | string; + /** Alignment of popover */ + align?: "start" | "center" | "end"; + /** Option for locale */ + locale?: string; + /** Option for showing compare feature */ + showCompare?: boolean; +} + +const getDateAdjustedForTimezone = (dateInput: Date | string): Date => { + if (typeof dateInput === "string") { + // Split the date string to get year, month, and day parts + const parts = dateInput.split("-").map((part) => parseInt(part, 10)); + // Create a new Date object using the local timezone + // Note: Month is 0-indexed, so subtract 1 from the month part + const date = new Date(parts[0], parts[1] - 1, parts[2]); + return date; + } else { + // If dateInput is already a Date object, return it directly + return dateInput; + } +}; + +interface DateRange { + from: Date; + to: Date | undefined; +} + +interface Preset { + name: string; + label: string; +} + +// Define presets +const PRESETS: Preset[] = [ + { name: "today", label: "Today" }, + { name: "yesterday", label: "Yesterday" }, + { name: "last7", label: "Last 7 days" }, + { name: "last14", label: "Last 14 days" }, + { name: "last30", label: "Last 30 days" }, + { name: "thisWeek", label: "This Week" }, + { name: "lastWeek", label: "Last Week" }, + { name: "thisMonth", label: "This Month" }, + { name: "lastMonth", label: "Last Month" }, +]; + +/** The DateRangePicker component allows a user to select a range of dates */ +export const DateRangePicker: FC = ({ + initialDateFrom = new Date(new Date().setHours(0, 0, 0, 0)), + initialDateTo, + initialCompareFrom, + initialCompareTo, + onUpdate, + showCompare = true, +}): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + + const [range, setRange] = useState({ + from: getDateAdjustedForTimezone(initialDateFrom), + to: initialDateTo + ? getDateAdjustedForTimezone(initialDateTo) + : getDateAdjustedForTimezone(initialDateFrom), + }); + const [rangeCompare, setRangeCompare] = useState( + initialCompareFrom + ? { + from: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)), + to: initialCompareTo + ? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0)) + : new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)), + } + : undefined, + ); + + // Refs to store the values of range and rangeCompare when the date picker is opened + const openedRangeRef = useRef(); + const openedRangeCompareRef = useRef(); + + const [selectedPreset, setSelectedPreset] = useState( + undefined, + ); + + const [isSmallScreen, setIsSmallScreen] = useState( + typeof window !== "undefined" ? window.innerWidth < 960 : false, + ); + + useEffect(() => { + const handleResize = (): void => { + setIsSmallScreen(window.innerWidth < 960); + }; + + window.addEventListener("resize", handleResize); + + // Clean up event listener on unmount + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + const getPresetRange = (presetName: string): DateRange => { + const preset = PRESETS.find(({ name }) => name === presetName); + if (!preset) throw new Error(`Unknown date range preset: ${presetName}`); + const from = new Date(); + const to = new Date(); + const first = from.getDate() - from.getDay(); + + switch (preset.name) { + case "today": + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "yesterday": + from.setDate(from.getDate() - 1); + from.setHours(0, 0, 0, 0); + to.setDate(to.getDate() - 1); + to.setHours(23, 59, 59, 999); + break; + case "last7": + from.setDate(from.getDate() - 6); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "last14": + from.setDate(from.getDate() - 13); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "last30": + from.setDate(from.getDate() - 29); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "thisWeek": + from.setDate(first); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "lastWeek": + from.setDate(from.getDate() - 7 - from.getDay()); + to.setDate(to.getDate() - to.getDay() - 1); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "thisMonth": + from.setDate(1); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case "lastMonth": + from.setMonth(from.getMonth() - 1); + from.setDate(1); + from.setHours(0, 0, 0, 0); + to.setDate(0); + to.setHours(23, 59, 59, 999); + break; + } + + return { from, to }; + }; + + const setPreset = (preset: string): void => { + const range = getPresetRange(preset); + setRange(range); + if (rangeCompare) { + const rangeCompare = { + from: new Date( + range.from.getFullYear() - 1, + range.from.getMonth(), + range.from.getDate(), + ), + to: range.to + ? new Date( + range.to.getFullYear() - 1, + range.to.getMonth(), + range.to.getDate(), + ) + : undefined, + }; + setRangeCompare(rangeCompare); + } + }; + + const checkPreset = (): void => { + for (const preset of PRESETS) { + const presetRange = getPresetRange(preset.name); + + const normalizedRangeFrom = new Date(range.from); + normalizedRangeFrom.setHours(0, 0, 0, 0); + const normalizedPresetFrom = new Date( + presetRange.from.setHours(0, 0, 0, 0), + ); + + const normalizedRangeTo = new Date(range.to ?? 0); + normalizedRangeTo.setHours(0, 0, 0, 0); + const normalizedPresetTo = new Date( + presetRange.to?.setHours(0, 0, 0, 0) ?? 0, + ); + + if ( + normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() && + normalizedRangeTo.getTime() === normalizedPresetTo.getTime() + ) { + setSelectedPreset(preset.name); + return; + } + } + + setSelectedPreset(undefined); + }; + + const resetValues = (): void => { + setRange({ + from: + typeof initialDateFrom === "string" + ? getDateAdjustedForTimezone(initialDateFrom) + : initialDateFrom, + to: initialDateTo + ? typeof initialDateTo === "string" + ? getDateAdjustedForTimezone(initialDateTo) + : initialDateTo + : typeof initialDateFrom === "string" + ? getDateAdjustedForTimezone(initialDateFrom) + : initialDateFrom, + }); + setRangeCompare( + initialCompareFrom + ? { + from: + typeof initialCompareFrom === "string" + ? getDateAdjustedForTimezone(initialCompareFrom) + : initialCompareFrom, + to: initialCompareTo + ? typeof initialCompareTo === "string" + ? getDateAdjustedForTimezone(initialCompareTo) + : initialCompareTo + : typeof initialCompareFrom === "string" + ? getDateAdjustedForTimezone(initialCompareFrom) + : initialCompareFrom, + } + : undefined, + ); + }; + + useEffect(() => { + checkPreset(); + }, [range]); + + const PresetButton = ({ + preset, + label, + isSelected, + }: { + preset: string; + label: string; + isSelected: boolean; + }): JSX.Element => ( + + ); + + // Helper function to check if two date ranges are equal + const areRangesEqual = (a?: DateRange, b?: DateRange): boolean => { + if (!a || !b) return a === b; // If either is undefined, return true if both are undefined + return ( + a.from.getTime() === b.from.getTime() && + (!a.to || !b.to || a.to.getTime() === b.to.getTime()) + ); + }; + + useEffect(() => { + if (isOpen) { + openedRangeRef.current = range; + openedRangeCompareRef.current = rangeCompare; + } + }, [isOpen]); + + return ( +
+
+
+
+
+ {showCompare && ( +
+ { + if (checked) { + if (!range.to) { + setRange({ + from: range.from, + to: range.from, + }); + } + setRangeCompare({ + from: new Date( + range.from.getFullYear(), + range.from.getMonth(), + range.from.getDate() - 365, + ), + to: range.to + ? new Date( + range.to.getFullYear() - 1, + range.to.getMonth(), + range.to.getDate(), + ) + : new Date( + range.from.getFullYear() - 1, + range.from.getMonth(), + range.from.getDate(), + ), + }); + } else { + setRangeCompare(undefined); + } + }} + id="compare-mode" + /> + +
+ )} +
+ {isSmallScreen && ( + + )} +
+ { + if (value?.from != null) { + setRange({ from: value.from, to: value?.to }); + } + }} + selected={range} + numberOfMonths={isSmallScreen ? 1 : 2} + defaultMonth={ + new Date( + new Date().setMonth( + new Date().getMonth() - (isSmallScreen ? 0 : 1), + ), + ) + } + /> +
+
+
+ {!isSmallScreen && ( +
+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ )} +
+
+ + +
+
+ ); +}; diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts index 14890c5c0..c234cadd4 100644 --- a/web/src/hooks/use-date-utils.ts +++ b/web/src/hooks/use-date-utils.ts @@ -12,6 +12,21 @@ export function useFormattedTimestamp(timestamp: number, format: string) { return formattedTimestamp; } +export function useFormattedRange(start: number, end: number, format: string) { + const formattedStart = useMemo(() => { + return formatUnixTimestampToDateTime(start, { + strftime_fmt: format, + }); + }, [format, start]); + const formattedEnd = useMemo(() => { + return formatUnixTimestampToDateTime(end, { + strftime_fmt: format, + }); + }, [format, end]); + + return `${formattedStart} - ${formattedEnd}`; +} + export function useTimezone(config: FrigateConfig | undefined) { return useMemo(() => { if (!config) {