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

245 lines
6.7 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}`;
}
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(() => {
const tomorrow = new Date();
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
const future = new Date();
future.setFullYear(tomorrow.getFullYear() + 10);
return { from: tomorrow, to: future };
}, []);
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]);
const timezoneOffset = useMemo(
() =>
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
[timezone],
);
const disabledDates = useMemo(() => {
const tomorrow = new Date();
if (timezoneOffset) {
tomorrow.setHours(
tomorrow.getHours() + 24,
tomorrow.getMinutes() + timezoneOffset,
0,
0,
);
} else {
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
}
const future = new Date();
future.setFullYear(tomorrow.getFullYear() + 10);
return { from: tomorrow, to: future };
}, [timezoneOffset]);
const today = useMemo(() => {
if (!timezoneOffset) {
return undefined;
}
const date = new Date();
const utc = Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
);
const todayUtc = new Date(utc);
todayUtc.setMinutes(todayUtc.getMinutes() + timezoneOffset, 0, 0);
return todayUtc;
}, [timezoneOffset]);
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
}
/>
);
}