mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02: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 {
|
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);
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user