Files
frigate/web/src/components/overlay/ReviewActivityCalendar.tsx
T

244 lines
6.9 KiB
TypeScript
Raw Normal View History

2025-02-09 17:02:36 -06:00
import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Calendar } from "../ui/calendar";
2025-05-15 11:10:14 -05:00
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";
2024-06-14 12:14:32 -05:00
import { LAST_24_HOURS_KEY } from "@/types/filter";
import { useUserPersistence } from "@/hooks/use-user-persistence";
2025-02-09 17:02:36 -06:00
import { cn } from "@/lib/utils";
2025-05-15 17:13:32 -05:00
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;
2026-06-01 12:08:46 -05:00
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}`;
}
2026-07-25 08:19:58 -05:00
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;
2025-02-09 17:02:36 -06:00
recordingsSummary?: RecordingsSummary;
selectedDay?: Date;
onSelect: (day?: Date) => void;
};
export default function ReviewActivityCalendar({
reviewSummary,
2025-02-09 17:02:36 -06:00
recordingsSummary,
selectedDay,
onSelect,
}: ReviewActivityCalendarProps) {
2025-05-15 17:13:32 -05:00
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
const disabledDates = useMemo(() => {
2026-07-25 08:19:58 -05:00
// 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]);
2024-05-28 12:15:31 -06:00
const modifiers = useMemo(() => {
const recordingsSet = new Set<string>();
const alertsSet = new Set<string>();
const detectionsSet = new Set<string>();
2025-02-09 17:02:36 -06:00
if (recordingsSummary) {
for (const date of Object.keys(recordingsSummary)) {
if (date !== LAST_24_HOURS_KEY) {
recordingsSet.add(date);
2025-02-09 17:02:36 -06:00
}
}
2024-05-28 12:15:31 -06:00
}
2025-02-09 17:02:36 -06:00
if (reviewSummary) {
for (const [date, data] of Object.entries(reviewSummary)) {
if (date === LAST_24_HOURS_KEY) continue;
2024-06-14 12:14:32 -05:00
2025-02-09 17:02:36 -06:00
if (data.total_alert > data.reviewed_alert) {
alertsSet.add(date);
2025-02-09 17:02:36 -06:00
} else if (data.total_detection > data.reviewed_detection) {
detectionsSet.add(date);
2025-02-09 17:02:36 -06:00
}
}
2025-02-09 17:02:36 -06:00
}
2024-06-14 12:14:32 -05:00
return {
2026-06-01 12:08:46 -05:00
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
};
}, [reviewSummary, recordingsSummary]);
2024-05-28 12:15:31 -06:00
return (
<Calendar
mode="single"
disabled={disabledDates}
showOutsideDays={false}
selected={selectedDay}
onSelect={onSelect}
2024-05-28 12:15:31 -06:00
modifiers={modifiers}
components={{
2025-05-15 11:10:14 -05:00
DayButton: ReviewActivityDay,
}}
2024-07-08 08:14:10 -05:00
defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
2025-05-15 17:13:32 -05:00
timeZone={timezone}
/>
);
}
2025-05-15 11:10:14 -05:00
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(() => {
2025-05-15 11:10:14 -05:00
if (modifiers["alerts"]) {
return "alert";
2025-05-15 11:10:14 -05:00
} else if (modifiers["detections"]) {
return "detection";
} else {
return "none";
}
2025-05-15 11:10:14 -05:00
}, [modifiers]);
return (
2025-05-15 11:10:14 -05:00
<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>
2025-02-09 17:02:36 -06:00
</div>
2025-05-15 11:10:14 -05:00
</button>
);
}
type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
2026-06-01 12:08:46 -05:00
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
2026-06-01 12:08:46 -05:00
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
2026-06-01 12:08:46 -05:00
// 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]);
2026-07-25 08:19:58 -05:00
// 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],
);
2026-07-25 08:19:58 -05:00
const disabledDates = useMemo(() => {
2026-07-25 08:19:58 -05:00
// 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]);
2026-07-25 08:19:58 -05:00
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}
2024-07-08 08:14:10 -05:00
defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
2026-06-01 12:08:46 -05:00
modifiers={recordingsModifier}
components={
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
}
/>
);
}