mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-26 13:48:59 +03:00
* fix calendars greying out the current day after midnight The cutoff for disabling future days was computed with setHours(getHours() + 24, -1, 0, 0), which is not "24 hours from now" but tomorrow at the current hour minus one minute. Between 00:00 and 00:59 that lands back on today, and react-day-picker matches range matchers by calendar day, so today itself was disabled, leaving the export dialog's start time stuck on the previous day. TimezoneAwareCalendar also added the configured timezone's raw UTC offset instead of its difference from the browser's, widening the broken window to several hours in negative-offset zones and letting future days through in positive-offset ones. Derive the current date in the display timezone once, then build each cutoff in the space its calendar uses: ReviewActivityCalendar passes timeZone to react-day-picker so its day cells are TZDate and need a real instant, while TimezoneAwareCalendar is handed pre-shifted dates and needs a local one. Also corrects the today prop, which was off by the browser's offset, and the truthiness check that treated a configured timezone of UTC as unset. * pin react-zoom-pan-pinch to 3.6.1 3.7.0 attaches a ResizeObserver to the transform wrapper and content unconditionally and clamps the pan position into the current bounds on every resize. The history player hides itself with display:none while scrubbing and while a new hour of recordings loads, so the observer measures it as 0x0, collapses the bounds to zero, and snaps a zoomed in view back to the top left corner. Zoom scale survives, only the position is lost. That observer was only created for centerOnInit in 3.4.4 through 3.6.1 and 4.0.0 reverted it again, so 3.7.0 is the only affected release. The caret is what picked it up during the React 19 upgrade, so pin the version exactly. Reported in #23807
244 lines
6.9 KiB
TypeScript
244 lines
6.9 KiB
TypeScript
import { RecordingsSummary, ReviewSummary } from "@/types/review";
|
|
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 { LAST_24_HOURS_KEY } from "@/types/filter";
|
|
import { useUserPersistence } from "@/hooks/use-user-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;
|
|
|
|
function formatCalendarDay(day: Date): string {
|
|
const y = day.getFullYear();
|
|
const m = String(day.getMonth() + 1).padStart(2, "0");
|
|
const d = String(day.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function getTodayInTimezone(timezone?: string): {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
offset: number;
|
|
} {
|
|
const now = new Date();
|
|
const offset = Math.round(getUTCOffset(now, timezone));
|
|
|
|
// shifting by the offset makes the UTC getters read the timezone's wall clock
|
|
const wallClock = new Date(now.getTime() + offset * 60000);
|
|
|
|
return {
|
|
year: wallClock.getUTCFullYear(),
|
|
month: wallClock.getUTCMonth(),
|
|
day: wallClock.getUTCDate(),
|
|
offset,
|
|
};
|
|
}
|
|
|
|
type ReviewActivityCalendarProps = {
|
|
reviewSummary?: ReviewSummary;
|
|
recordingsSummary?: RecordingsSummary;
|
|
selectedDay?: Date;
|
|
onSelect: (day?: Date) => void;
|
|
};
|
|
export default function ReviewActivityCalendar({
|
|
reviewSummary,
|
|
recordingsSummary,
|
|
selectedDay,
|
|
onSelect,
|
|
}: ReviewActivityCalendarProps) {
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const timezone = useTimezone(config);
|
|
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
|
|
|
const disabledDates = useMemo(() => {
|
|
// day cells are TZDate in `timezone`, so the cutoff must be a real instant
|
|
const { year, month, day, offset } = getTodayInTimezone(timezone);
|
|
// midday: ranges match by calendar day, so this dodges DST edges
|
|
const from = new Date(Date.UTC(year, month, day + 1, 12) - offset * 60000);
|
|
const to = new Date(from);
|
|
to.setFullYear(from.getFullYear() + 10);
|
|
return { from, to };
|
|
}, [timezone]);
|
|
|
|
const modifiers = useMemo(() => {
|
|
const recordingsSet = new Set<string>();
|
|
const alertsSet = new Set<string>();
|
|
const detectionsSet = new Set<string>();
|
|
|
|
if (recordingsSummary) {
|
|
for (const date of Object.keys(recordingsSummary)) {
|
|
if (date !== LAST_24_HOURS_KEY) {
|
|
recordingsSet.add(date);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (reviewSummary) {
|
|
for (const [date, data] of Object.entries(reviewSummary)) {
|
|
if (date === LAST_24_HOURS_KEY) continue;
|
|
|
|
if (data.total_alert > data.reviewed_alert) {
|
|
alertsSet.add(date);
|
|
} else if (data.total_detection > data.reviewed_detection) {
|
|
detectionsSet.add(date);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
|
|
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
|
|
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
|
|
};
|
|
}, [reviewSummary, recordingsSummary]);
|
|
|
|
return (
|
|
<Calendar
|
|
mode="single"
|
|
disabled={disabledDates}
|
|
showOutsideDays={false}
|
|
selected={selectedDay}
|
|
onSelect={onSelect}
|
|
modifiers={modifiers}
|
|
components={{
|
|
DayButton: ReviewActivityDay,
|
|
}}
|
|
defaultMonth={selectedDay ?? new Date()}
|
|
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
|
|
timeZone={timezone}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ReviewActivityDay({
|
|
day,
|
|
modifiers,
|
|
...buttonProps
|
|
}: DayButtonProps & ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
const ref = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (modifiers.focused) ref.current?.focus();
|
|
}, [modifiers.focused]);
|
|
|
|
const dayActivity = useMemo(() => {
|
|
if (modifiers["alerts"]) {
|
|
return "alert";
|
|
} else if (modifiers["detections"]) {
|
|
return "detection";
|
|
} else {
|
|
return "none";
|
|
}
|
|
}, [modifiers]);
|
|
|
|
return (
|
|
<button ref={ref} {...buttonProps}>
|
|
<div className={cn("flex flex-col items-center justify-center gap-0.5")}>
|
|
<span
|
|
className={cn(
|
|
modifiers["recordings"] ? "text-primary" : "text-primary/40",
|
|
)}
|
|
>
|
|
{day.date.getDate()}
|
|
</span>
|
|
<div
|
|
className={cn(
|
|
"w-4",
|
|
modifiers["recordings"]
|
|
? "border-b border-primary/60 text-primary"
|
|
: "text-primary/40",
|
|
modifiers.selected && "border-white text-white",
|
|
)}
|
|
/>
|
|
|
|
<div className="mt-0.5 flex h-2 flex-row gap-0.5">
|
|
{dayActivity != "none" && (
|
|
<FaCircle
|
|
size={6}
|
|
className={cn(
|
|
dayActivity == "alert"
|
|
? "fill-severity_alert"
|
|
: "fill-severity_detection",
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
type TimezoneAwareCalendarProps = {
|
|
timezone?: string;
|
|
selectedDay?: Date;
|
|
onSelect: (day?: Date) => void;
|
|
recordingsSummary?: RecordingsSummary;
|
|
};
|
|
export function TimezoneAwareCalendar({
|
|
timezone,
|
|
selectedDay,
|
|
onSelect,
|
|
recordingsSummary,
|
|
}: TimezoneAwareCalendarProps) {
|
|
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
|
|
|
// When a recordings summary is supplied, underline days that have footage
|
|
const recordingsModifier = useMemo(() => {
|
|
if (!recordingsSummary) {
|
|
return undefined;
|
|
}
|
|
const recordingsSet = new Set<string>();
|
|
for (const date of Object.keys(recordingsSummary)) {
|
|
if (date !== LAST_24_HOURS_KEY) {
|
|
recordingsSet.add(date);
|
|
}
|
|
}
|
|
return {
|
|
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
|
|
};
|
|
}, [recordingsSummary]);
|
|
|
|
// callers pre-shift dates so the local clock reads `timezone`, so boundaries
|
|
// are built in local time rather than as instants
|
|
const { year, month, day } = useMemo(
|
|
() => getTodayInTimezone(timezone),
|
|
[timezone],
|
|
);
|
|
|
|
const disabledDates = useMemo(() => {
|
|
// midday: ranges match by calendar day, so this dodges DST edges
|
|
const from = new Date(year, month, day + 1, 12);
|
|
const to = new Date(from);
|
|
to.setFullYear(from.getFullYear() + 10);
|
|
return { from, to };
|
|
}, [year, month, day]);
|
|
|
|
const today = useMemo(
|
|
() => new Date(year, month, day, 12),
|
|
[year, month, day],
|
|
);
|
|
|
|
return (
|
|
<Calendar
|
|
mode="single"
|
|
disabled={disabledDates}
|
|
showOutsideDays={false}
|
|
today={today}
|
|
selected={selectedDay}
|
|
onSelect={onSelect}
|
|
defaultMonth={selectedDay ?? new Date()}
|
|
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
|
|
modifiers={recordingsModifier}
|
|
components={
|
|
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|