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:
Josh Hawkins 2025-05-15 17:13:32 -05:00 committed by GitHub
parent f48356cbee
commit 2f9b373c1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 74 additions and 25 deletions

View File

@ -1,6 +1,7 @@
import { import {
useFormattedRange, useFormattedRange,
useFormattedTimestamp, useFormattedTimestamp,
useTimezone,
} from "@/hooks/use-date-utils"; } from "@/hooks/use-date-utils";
import { RecordingsSummary, ReviewSummary } from "@/types/review"; import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
@ -15,6 +16,8 @@ import { DateRange } from "react-day-picker";
import { useState } from "react"; import { useState } from "react";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
type CalendarFilterButtonProps = { type CalendarFilterButtonProps = {
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
@ -29,10 +32,12 @@ export default function CalendarFilterButton({
updateSelectedDay, updateSelectedDay,
}: CalendarFilterButtonProps) { }: CalendarFilterButtonProps) {
const { t } = useTranslation(["components/filter", "views/events"]); const { t } = useTranslation(["components/filter", "views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp( const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1, day == undefined ? 0 : day?.getTime() / 1000 + 1,
t("time.formattedTimestampMonthDay", { ns: "common" }), t("time.formattedTimestampMonthDay", { ns: "common" }),
config?.ui.timezone,
); );
const trigger = ( const trigger = (
@ -98,12 +103,15 @@ export function CalendarRangeFilterButton({
updateSelectedRange, updateSelectedRange,
}: CalendarRangeFilterButtonProps) { }: CalendarRangeFilterButtonProps) {
const { t } = useTranslation(["components/filter"]); const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selectedDate = useFormattedRange( const selectedDate = useFormattedRange(
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
t("time.formattedTimestampMonthDay", { ns: "common" }), t("time.formattedTimestampMonthDay", { ns: "common" }),
config?.ui.timezone,
); );
const trigger = ( const trigger = (
@ -128,6 +136,7 @@ export function CalendarRangeFilterButton({
<DateRangePicker <DateRangePicker
initialDateFrom={range?.from} initialDateFrom={range?.from}
initialDateTo={range?.to} initialDateTo={range?.to}
timezone={timezone}
showCompare={false} showCompare={false}
onUpdate={(range) => { onUpdate={(range) => {
updateSelectedRange(range.range); updateSelectedRange(range.range);

View File

@ -406,12 +406,14 @@ function CustomTimeSelector({
config?.ui.time_format == "24hour" config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" }) ? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }), : t("time.formattedTimestamp.12hour", { ns: "common" }),
config?.ui.timezone,
); );
const formattedEnd = useFormattedTimestamp( const formattedEnd = useFormattedTimestamp(
endTime, endTime,
config?.ui.time_format == "24hour" config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" }) ? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }), : t("time.formattedTimestamp.12hour", { ns: "common" }),
config?.ui.timezone,
); );
const startClock = useMemo(() => { const startClock = useMemo(() => {

View File

@ -3,10 +3,13 @@ import { Calendar } from "../ui/calendar";
import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react"; import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react";
import { FaCircle } from "react-icons/fa"; import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil"; 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 { LAST_24_HOURS_KEY } from "@/types/filter";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils"; 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; type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
@ -22,6 +25,8 @@ export default function ReviewActivityCalendar({
selectedDay, selectedDay,
onSelect, onSelect,
}: ReviewActivityCalendarProps) { }: ReviewActivityCalendarProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const [weekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn] = usePersistence("weekStartsOn", 0);
const disabledDates = useMemo(() => { const disabledDates = useMemo(() => {
@ -45,7 +50,7 @@ export default function ReviewActivityCalendar({
} }
const parts = date.split("-"); const parts = date.split("-");
const cal = new Date(date); const cal = new TZDate(date, timezone);
cal.setFullYear( cal.setFullYear(
parseInt(parts[0]), parseInt(parts[0]),
@ -65,7 +70,7 @@ export default function ReviewActivityCalendar({
} }
const parts = date.split("-"); const parts = date.split("-");
const cal = new Date(date); const cal = new TZDate(date, timezone);
cal.setFullYear( cal.setFullYear(
parseInt(parts[0]), parseInt(parts[0]),
@ -82,7 +87,7 @@ export default function ReviewActivityCalendar({
} }
return { alerts, detections, recordings }; return { alerts, detections, recordings };
}, [reviewSummary, recordingsSummary]); }, [reviewSummary, recordingsSummary, timezone]);
return ( return (
<Calendar <Calendar
@ -98,6 +103,7 @@ export default function ReviewActivityCalendar({
}} }}
defaultMonth={selectedDay ?? new Date()} defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType} weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
timeZone={timezone}
/> />
); );
} }

View File

@ -12,6 +12,7 @@ import {
import { Switch } from "./switch"; import { Switch } from "./switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LuCheck } from "react-icons/lu"; import { LuCheck } from "react-icons/lu";
import { TZDate } from "react-day-picker";
import { t } from "i18next"; import { t } from "i18next";
export interface DateRangePickerProps { export interface DateRangePickerProps {
@ -32,19 +33,24 @@ export interface DateRangePickerProps {
locale?: string; locale?: string;
/** Option for showing compare feature */ /** Option for showing compare feature */
showCompare?: boolean; showCompare?: boolean;
/** timezone */
timezone?: string;
} }
const getDateAdjustedForTimezone = (dateInput: Date | string): Date => { const getDateAdjustedForTimezone = (
dateInput: Date | string,
timezone?: string,
): Date => {
if (typeof dateInput === "string") { if (typeof dateInput === "string") {
// Split the date string to get year, month, and day parts // Split the date string to get year, month, and day parts
const parts = dateInput.split("-").map((part) => parseInt(part, 10)); const parts = dateInput.split("-").map((part) => parseInt(part, 10));
// Create a new Date object using the local timezone // Create a new Date object using the local timezone
// Note: Month is 0-indexed, so subtract 1 from the month part // 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; return date;
} else { } else {
// If dateInput is already a Date object, return it directly // 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 */ /** The DateRangePicker component allows a user to select a range of dates */
export function DateRangePicker({ 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, initialDateTo,
initialCompareFrom, initialCompareFrom,
initialCompareTo, initialCompareTo,
@ -84,18 +95,27 @@ export function DateRangePicker({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [range, setRange] = useState<DateRange>({ const [range, setRange] = useState<DateRange>({
from: getDateAdjustedForTimezone(initialDateFrom), from: getDateAdjustedForTimezone(initialDateFrom, timezone),
to: initialDateTo to: initialDateTo
? getDateAdjustedForTimezone(initialDateTo) ? getDateAdjustedForTimezone(initialDateTo, timezone)
: getDateAdjustedForTimezone(initialDateFrom), : getDateAdjustedForTimezone(initialDateFrom, timezone),
}); });
const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>( const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>(
initialCompareFrom 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 to: initialCompareTo
? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0)) ? new TZDate(
: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)), new Date(initialCompareTo).setHours(0, 0, 0, 0),
timezone,
)
: new TZDate(
new Date(initialCompareFrom).setHours(0, 0, 0, 0),
timezone,
),
} }
: undefined, : undefined,
); );
@ -128,8 +148,8 @@ export function DateRangePicker({
const getPresetRange = (presetName: string): DateRange => { const getPresetRange = (presetName: string): DateRange => {
const preset = PRESETS.find(({ name }) => name === presetName); const preset = PRESETS.find(({ name }) => name === presetName);
if (!preset) throw new Error(`Unknown date range preset: ${presetName}`); if (!preset) throw new Error(`Unknown date range preset: ${presetName}`);
const from = new Date(); const from = new TZDate(new Date(), timezone);
const to = new Date(); const to = new TZDate(new Date(), timezone);
const first = from.getDate() - from.getDay(); const first = from.getDate() - from.getDay();
switch (preset.name) { switch (preset.name) {
@ -191,16 +211,18 @@ export function DateRangePicker({
setRange(range); setRange(range);
if (rangeCompare) { if (rangeCompare) {
const rangeCompare = { const rangeCompare = {
from: new Date( from: new TZDate(
range.from.getFullYear() - 1, range.from.getFullYear() - 1,
range.from.getMonth(), range.from.getMonth(),
range.from.getDate(), range.from.getDate(),
timezone,
), ),
to: range.to to: range.to
? new Date( ? new TZDate(
range.to.getFullYear() - 1, range.to.getFullYear() - 1,
range.to.getMonth(), range.to.getMonth(),
range.to.getDate(), range.to.getDate(),
timezone,
) )
: undefined, : undefined,
}; };
@ -212,16 +234,18 @@ export function DateRangePicker({
for (const preset of PRESETS) { for (const preset of PRESETS) {
const presetRange = getPresetRange(preset.name); const presetRange = getPresetRange(preset.name);
const normalizedRangeFrom = new Date(range.from); const normalizedRangeFrom = new TZDate(range.from, timezone);
normalizedRangeFrom.setHours(0, 0, 0, 0); normalizedRangeFrom.setHours(0, 0, 0, 0);
const normalizedPresetFrom = new Date( const normalizedPresetFrom = new TZDate(
presetRange.from.setHours(0, 0, 0, 0), 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); normalizedRangeTo.setHours(0, 0, 0, 0);
const normalizedPresetTo = new Date( const normalizedPresetTo = new TZDate(
presetRange.to?.setHours(0, 0, 0, 0) ?? 0, presetRange.to?.setHours(0, 0, 0, 0) ?? 0,
timezone,
); );
if ( if (
@ -401,6 +425,7 @@ export function DateRangePicker({
), ),
) )
} }
timeZone={timezone}
/> />
</div> </div>
</div> </div>

View File

@ -21,22 +21,29 @@ export function useFormattedTimestamp(
return formattedTimestamp; 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 locale = useDateLocale();
const formattedStart = useMemo(() => { const formattedStart = useMemo(() => {
return formatUnixTimestampToDateTime(start, { return formatUnixTimestampToDateTime(start, {
timezone,
date_format: format, date_format: format,
locale, locale,
}); });
}, [format, start, locale]); }, [format, start, timezone, locale]);
const formattedEnd = useMemo(() => { const formattedEnd = useMemo(() => {
return formatUnixTimestampToDateTime(end, { return formatUnixTimestampToDateTime(end, {
timezone,
date_format: format, date_format: format,
locale, locale,
}); });
}, [format, end, locale]); }, [format, end, timezone, locale]);
return `${formattedStart} - ${formattedEnd}`; return `${formattedStart} - ${formattedEnd}`;
} }