mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-12 22:25:24 +03:00
Add date range picker
This commit is contained in:
parent
52d4fb1cfc
commit
e13cca9f95
@ -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({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant={range == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaCalendarAlt
|
||||
className={`${range == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${range == undefined ? "text-primary" : "text-selected-foreground"}`}
|
||||
>
|
||||
{range == undefined ? defaultText : selectedDate}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<DateRangePicker
|
||||
initialDateFrom={range?.from}
|
||||
initialDateTo={range?.to}
|
||||
showCompare={false}
|
||||
onUpdate={(range) => updateSelectedRange(range.range)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateSelectedRange(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>{content}</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent className="w-[840px]">{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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") && (
|
||||
<CalendarFilterButton
|
||||
day={
|
||||
filter?.after == undefined
|
||||
<CalendarRangeFilterButton
|
||||
range={
|
||||
filter?.after == undefined || filter?.before == undefined
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
: {
|
||||
from: new Date(filter.after * 1000),
|
||||
to: new Date(filter.before * 1000),
|
||||
}
|
||||
}
|
||||
defaultText="All Dates"
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
updateSelectedRange={onUpdateSelectedRange}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && filters.includes("general") && (
|
||||
|
||||
444
web/src/components/ui/calendar-range.tsx
Normal file
444
web/src/components/ui/calendar-range.tsx
Normal file
@ -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<DateRangePickerProps> = ({
|
||||
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<DateRange>({
|
||||
from: getDateAdjustedForTimezone(initialDateFrom),
|
||||
to: initialDateTo
|
||||
? getDateAdjustedForTimezone(initialDateTo)
|
||||
: getDateAdjustedForTimezone(initialDateFrom),
|
||||
});
|
||||
const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>(
|
||||
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<DateRange | undefined>();
|
||||
const openedRangeCompareRef = useRef<DateRange | undefined>();
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
|
||||
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 => (
|
||||
<Button
|
||||
className={cn(isSelected && "pointer-events-none text-white")}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPreset(preset);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<span className={cn("pr-2 opacity-0", isSelected && "opacity-70")}>
|
||||
<LuCheck width={18} height={18} />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div className="w-full">
|
||||
<div className="flex py-2">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col items-center justify-end gap-2 px-3 pb-4 lg:flex-row lg:items-start lg:pb-0">
|
||||
{showCompare && (
|
||||
<div className="flex items-center space-x-2 py-1 pr-4">
|
||||
<Switch
|
||||
defaultChecked={Boolean(rangeCompare)}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
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"
|
||||
/>
|
||||
<Label htmlFor="compare-mode">Compare</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSmallScreen && (
|
||||
<Select
|
||||
defaultValue={selectedPreset}
|
||||
onValueChange={(value) => {
|
||||
setPreset(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mx-auto mb-2 w-[180px]">
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.name} value={preset.name}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
onSelect={(value: { from?: Date; to?: Date } | undefined) => {
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<div className="flex flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
||||
<div className="flex w-full flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<PresetButton
|
||||
key={preset.name}
|
||||
preset={preset.name}
|
||||
label={preset.label}
|
||||
isSelected={selectedPreset === preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 py-2 pr-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
resetValues();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (
|
||||
!areRangesEqual(range, openedRangeRef.current) ||
|
||||
!areRangesEqual(rangeCompare, openedRangeCompareRef.current)
|
||||
) {
|
||||
onUpdate?.({ range, rangeCompare });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user