mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Improve timezone handling (#18257)
* Ensure review activity calendar uses correct timezone react-day-picker 9.x adds a timeZone prop and a TZDate() handler to show the calendar based on a timezone and better handle dates passed to it in timezones * Ensure calendar range uses correct timezone * clean up * ensure range is timezone aware * ensure export dates are timezone aware
This commit is contained in:
		
							parent
							
								
									f48356cbee
								
							
						
					
					
						commit
						2f9b373c1a
					
				| @ -1,6 +1,7 @@ | ||||
| import { | ||||
|   useFormattedRange, | ||||
|   useFormattedTimestamp, | ||||
|   useTimezone, | ||||
| } from "@/hooks/use-date-utils"; | ||||
| import { RecordingsSummary, ReviewSummary } from "@/types/review"; | ||||
| import { Button } from "../ui/button"; | ||||
| @ -15,6 +16,8 @@ import { DateRange } from "react-day-picker"; | ||||
| import { useState } from "react"; | ||||
| import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| 
 | ||||
| type CalendarFilterButtonProps = { | ||||
|   reviewSummary?: ReviewSummary; | ||||
| @ -29,10 +32,12 @@ export default function CalendarFilterButton({ | ||||
|   updateSelectedDay, | ||||
| }: CalendarFilterButtonProps) { | ||||
|   const { t } = useTranslation(["components/filter", "views/events"]); | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const selectedDate = useFormattedTimestamp( | ||||
|     day == undefined ? 0 : day?.getTime() / 1000 + 1, | ||||
|     t("time.formattedTimestampMonthDay", { ns: "common" }), | ||||
|     config?.ui.timezone, | ||||
|   ); | ||||
| 
 | ||||
|   const trigger = ( | ||||
| @ -98,12 +103,15 @@ export function CalendarRangeFilterButton({ | ||||
|   updateSelectedRange, | ||||
| }: CalendarRangeFilterButtonProps) { | ||||
|   const { t } = useTranslation(["components/filter"]); | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const timezone = useTimezone(config); | ||||
|   const [open, setOpen] = useState(false); | ||||
| 
 | ||||
|   const selectedDate = useFormattedRange( | ||||
|     range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, | ||||
|     range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, | ||||
|     t("time.formattedTimestampMonthDay", { ns: "common" }), | ||||
|     config?.ui.timezone, | ||||
|   ); | ||||
| 
 | ||||
|   const trigger = ( | ||||
| @ -128,6 +136,7 @@ export function CalendarRangeFilterButton({ | ||||
|       <DateRangePicker | ||||
|         initialDateFrom={range?.from} | ||||
|         initialDateTo={range?.to} | ||||
|         timezone={timezone} | ||||
|         showCompare={false} | ||||
|         onUpdate={(range) => { | ||||
|           updateSelectedRange(range.range); | ||||
|  | ||||
| @ -406,12 +406,14 @@ function CustomTimeSelector({ | ||||
|     config?.ui.time_format == "24hour" | ||||
|       ? t("time.formattedTimestamp.24hour", { ns: "common" }) | ||||
|       : t("time.formattedTimestamp.12hour", { ns: "common" }), | ||||
|     config?.ui.timezone, | ||||
|   ); | ||||
|   const formattedEnd = useFormattedTimestamp( | ||||
|     endTime, | ||||
|     config?.ui.time_format == "24hour" | ||||
|       ? t("time.formattedTimestamp.24hour", { ns: "common" }) | ||||
|       : t("time.formattedTimestamp.12hour", { ns: "common" }), | ||||
|     config?.ui.timezone, | ||||
|   ); | ||||
| 
 | ||||
|   const startClock = useMemo(() => { | ||||
|  | ||||
| @ -3,10 +3,13 @@ import { Calendar } from "../ui/calendar"; | ||||
| import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react"; | ||||
| import { FaCircle } from "react-icons/fa"; | ||||
| import { getUTCOffset } from "@/utils/dateUtil"; | ||||
| import { type DayButtonProps } from "react-day-picker"; | ||||
| import { type DayButtonProps, TZDate } from "react-day-picker"; | ||||
| import { LAST_24_HOURS_KEY } from "@/types/filter"; | ||||
| import { usePersistence } from "@/hooks/use-persistence"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import useSWR from "swr"; | ||||
| import { useTimezone } from "@/hooks/use-date-utils"; | ||||
| 
 | ||||
| type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6; | ||||
| 
 | ||||
| @ -22,6 +25,8 @@ export default function ReviewActivityCalendar({ | ||||
|   selectedDay, | ||||
|   onSelect, | ||||
| }: ReviewActivityCalendarProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const timezone = useTimezone(config); | ||||
|   const [weekStartsOn] = usePersistence("weekStartsOn", 0); | ||||
| 
 | ||||
|   const disabledDates = useMemo(() => { | ||||
| @ -45,7 +50,7 @@ export default function ReviewActivityCalendar({ | ||||
|         } | ||||
| 
 | ||||
|         const parts = date.split("-"); | ||||
|         const cal = new Date(date); | ||||
|         const cal = new TZDate(date, timezone); | ||||
| 
 | ||||
|         cal.setFullYear( | ||||
|           parseInt(parts[0]), | ||||
| @ -65,7 +70,7 @@ export default function ReviewActivityCalendar({ | ||||
|         } | ||||
| 
 | ||||
|         const parts = date.split("-"); | ||||
|         const cal = new Date(date); | ||||
|         const cal = new TZDate(date, timezone); | ||||
| 
 | ||||
|         cal.setFullYear( | ||||
|           parseInt(parts[0]), | ||||
| @ -82,7 +87,7 @@ export default function ReviewActivityCalendar({ | ||||
|     } | ||||
| 
 | ||||
|     return { alerts, detections, recordings }; | ||||
|   }, [reviewSummary, recordingsSummary]); | ||||
|   }, [reviewSummary, recordingsSummary, timezone]); | ||||
| 
 | ||||
|   return ( | ||||
|     <Calendar | ||||
| @ -98,6 +103,7 @@ export default function ReviewActivityCalendar({ | ||||
|       }} | ||||
|       defaultMonth={selectedDay ?? new Date()} | ||||
|       weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType} | ||||
|       timeZone={timezone} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -12,6 +12,7 @@ import { | ||||
| import { Switch } from "./switch"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { LuCheck } from "react-icons/lu"; | ||||
| import { TZDate } from "react-day-picker"; | ||||
| import { t } from "i18next"; | ||||
| 
 | ||||
| export interface DateRangePickerProps { | ||||
| @ -32,19 +33,24 @@ export interface DateRangePickerProps { | ||||
|   locale?: string; | ||||
|   /** Option for showing compare feature */ | ||||
|   showCompare?: boolean; | ||||
|   /** timezone */ | ||||
|   timezone?: string; | ||||
| } | ||||
| 
 | ||||
| const getDateAdjustedForTimezone = (dateInput: Date | string): Date => { | ||||
| const getDateAdjustedForTimezone = ( | ||||
|   dateInput: Date | string, | ||||
|   timezone?: 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]); | ||||
|     const date = new TZDate(parts[0], parts[1] - 1, parts[2], timezone); | ||||
|     return date; | ||||
|   } else { | ||||
|     // If dateInput is already a Date object, return it directly
 | ||||
|     return dateInput; | ||||
|     return new TZDate(dateInput, timezone); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| @ -73,7 +79,12 @@ const PRESETS: Preset[] = [ | ||||
| 
 | ||||
| /** The DateRangePicker component allows a user to select a range of dates */ | ||||
| export function DateRangePicker({ | ||||
|   initialDateFrom = new Date(new Date().setHours(0, 0, 0, 0)), | ||||
|   timezone, | ||||
|   initialDateFrom = (() => { | ||||
|     const date = new TZDate(new Date(), timezone); | ||||
|     date.setHours(0, 0, 0, 0); | ||||
|     return date; | ||||
|   })(), | ||||
|   initialDateTo, | ||||
|   initialCompareFrom, | ||||
|   initialCompareTo, | ||||
| @ -84,18 +95,27 @@ export function DateRangePicker({ | ||||
|   const [isOpen, setIsOpen] = useState(false); | ||||
| 
 | ||||
|   const [range, setRange] = useState<DateRange>({ | ||||
|     from: getDateAdjustedForTimezone(initialDateFrom), | ||||
|     from: getDateAdjustedForTimezone(initialDateFrom, timezone), | ||||
|     to: initialDateTo | ||||
|       ? getDateAdjustedForTimezone(initialDateTo) | ||||
|       : getDateAdjustedForTimezone(initialDateFrom), | ||||
|       ? getDateAdjustedForTimezone(initialDateTo, timezone) | ||||
|       : getDateAdjustedForTimezone(initialDateFrom, timezone), | ||||
|   }); | ||||
|   const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>( | ||||
|     initialCompareFrom | ||||
|       ? { | ||||
|           from: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)), | ||||
|           from: new TZDate( | ||||
|             new Date(initialCompareFrom).setHours(0, 0, 0, 0), | ||||
|             timezone, | ||||
|           ), | ||||
|           to: initialCompareTo | ||||
|             ? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0)) | ||||
|             : new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)), | ||||
|             ? new TZDate( | ||||
|                 new Date(initialCompareTo).setHours(0, 0, 0, 0), | ||||
|                 timezone, | ||||
|               ) | ||||
|             : new TZDate( | ||||
|                 new Date(initialCompareFrom).setHours(0, 0, 0, 0), | ||||
|                 timezone, | ||||
|               ), | ||||
|         } | ||||
|       : undefined, | ||||
|   ); | ||||
| @ -128,8 +148,8 @@ export function DateRangePicker({ | ||||
|   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 from = new TZDate(new Date(), timezone); | ||||
|     const to = new TZDate(new Date(), timezone); | ||||
|     const first = from.getDate() - from.getDay(); | ||||
| 
 | ||||
|     switch (preset.name) { | ||||
| @ -191,16 +211,18 @@ export function DateRangePicker({ | ||||
|     setRange(range); | ||||
|     if (rangeCompare) { | ||||
|       const rangeCompare = { | ||||
|         from: new Date( | ||||
|         from: new TZDate( | ||||
|           range.from.getFullYear() - 1, | ||||
|           range.from.getMonth(), | ||||
|           range.from.getDate(), | ||||
|           timezone, | ||||
|         ), | ||||
|         to: range.to | ||||
|           ? new Date( | ||||
|           ? new TZDate( | ||||
|               range.to.getFullYear() - 1, | ||||
|               range.to.getMonth(), | ||||
|               range.to.getDate(), | ||||
|               timezone, | ||||
|             ) | ||||
|           : undefined, | ||||
|       }; | ||||
| @ -212,16 +234,18 @@ export function DateRangePicker({ | ||||
|     for (const preset of PRESETS) { | ||||
|       const presetRange = getPresetRange(preset.name); | ||||
| 
 | ||||
|       const normalizedRangeFrom = new Date(range.from); | ||||
|       const normalizedRangeFrom = new TZDate(range.from, timezone); | ||||
|       normalizedRangeFrom.setHours(0, 0, 0, 0); | ||||
|       const normalizedPresetFrom = new Date( | ||||
|       const normalizedPresetFrom = new TZDate( | ||||
|         presetRange.from.setHours(0, 0, 0, 0), | ||||
|         timezone, | ||||
|       ); | ||||
| 
 | ||||
|       const normalizedRangeTo = new Date(range.to ?? 0); | ||||
|       const normalizedRangeTo = new TZDate(range.to ?? new Date(0), timezone); | ||||
|       normalizedRangeTo.setHours(0, 0, 0, 0); | ||||
|       const normalizedPresetTo = new Date( | ||||
|       const normalizedPresetTo = new TZDate( | ||||
|         presetRange.to?.setHours(0, 0, 0, 0) ?? 0, | ||||
|         timezone, | ||||
|       ); | ||||
| 
 | ||||
|       if ( | ||||
| @ -401,6 +425,7 @@ export function DateRangePicker({ | ||||
|                     ), | ||||
|                   ) | ||||
|                 } | ||||
|                 timeZone={timezone} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
| @ -21,22 +21,29 @@ export function useFormattedTimestamp( | ||||
|   return formattedTimestamp; | ||||
| } | ||||
| 
 | ||||
| export function useFormattedRange(start: number, end: number, format: string) { | ||||
| export function useFormattedRange( | ||||
|   start: number, | ||||
|   end: number, | ||||
|   format: string, | ||||
|   timezone?: string, | ||||
| ) { | ||||
|   const locale = useDateLocale(); | ||||
| 
 | ||||
|   const formattedStart = useMemo(() => { | ||||
|     return formatUnixTimestampToDateTime(start, { | ||||
|       timezone, | ||||
|       date_format: format, | ||||
|       locale, | ||||
|     }); | ||||
|   }, [format, start, locale]); | ||||
|   }, [format, start, timezone, locale]); | ||||
| 
 | ||||
|   const formattedEnd = useMemo(() => { | ||||
|     return formatUnixTimestampToDateTime(end, { | ||||
|       timezone, | ||||
|       date_format: format, | ||||
|       locale, | ||||
|     }); | ||||
|   }, [format, end, locale]); | ||||
|   }, [format, end, timezone, locale]); | ||||
| 
 | ||||
|   return `${formattedStart} - ${formattedEnd}`; | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user