From e8b92251757ee7bee3f5c57e3d76979be02dd338 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:22:01 -0500 Subject: [PATCH] Recordings API and calendar UI performance improvements (#22352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * optimize recordings/summary endpoint db query replace strftime with integer arithmetic. increases speed by about 6x, especially noticeable for installs with long retention days * optimize calendar rendering with Set lookups and remove unnecessary remount key The old code built Date[] arrays with a TZDate object for every day in recording history (365+ timezone-aware date constructions). react-day-picker then did O(visible × history) date comparisons to match each of the displayed days against these arrays. Now we build Set from the raw keys (zero date construction), and pass matcher functions that do O(1) Set.has() lookups. react-day-picker only calls these for visible days * clean up --- frigate/api/record.py | 34 ++-------- .../overlay/ReviewActivityCalendar.tsx | 67 +++++++------------ 2 files changed, 32 insertions(+), 69 deletions(-) diff --git a/frigate/api/record.py b/frigate/api/record.py index 6eeb9fbe6..4ab4b0af1 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -1,5 +1,6 @@ """Recording APIs.""" +import datetime as dt import logging from datetime import datetime, timedelta from functools import reduce @@ -97,45 +98,22 @@ def all_recordings_summary( days: dict[str, bool] = {} for period_start, period_end, period_offset in dst_periods: - hours_offset = int(period_offset / 60 / 60) - minutes_offset = int(period_offset / 60 - hours_offset * 60) - period_hour_modifier = f"{hours_offset} hour" - period_minute_modifier = f"{minutes_offset} minute" + day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int") period_query = ( - Recordings.select( - fn.strftime( - "%Y-%m-%d", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ).alias("day") - ) + Recordings.select(day_expr.alias("day_idx")) .where( (Recordings.camera << camera_list) & (Recordings.end_time >= period_start) & (Recordings.start_time <= period_end) ) - .group_by( - fn.strftime( - "%Y-%m-%d", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ) - ) - .order_by(Recordings.start_time.desc()) + .distinct() .namedtuples() ) for g in period_query: - days[g.day] = True + day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat() + days[day_str] = True return JSONResponse(content=dict(sorted(days.items()))) diff --git a/web/src/components/overlay/ReviewActivityCalendar.tsx b/web/src/components/overlay/ReviewActivityCalendar.tsx index 86bc424ba..fe9256c23 100644 --- a/web/src/components/overlay/ReviewActivityCalendar.tsx +++ b/web/src/components/overlay/ReviewActivityCalendar.tsx @@ -3,7 +3,7 @@ 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, TZDate } from "react-day-picker"; +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"; @@ -38,60 +38,46 @@ export default function ReviewActivityCalendar({ }, []); const modifiers = useMemo(() => { - const recordings: Date[] = []; - const alerts: Date[] = []; - const detections: Date[] = []; + const recordingsSet = new Set(); + const alertsSet = new Set(); + const detectionsSet = new Set(); - // Handle recordings if (recordingsSummary) { - Object.keys(recordingsSummary).forEach((date) => { - if (date === LAST_24_HOURS_KEY) { - return; + for (const date of Object.keys(recordingsSummary)) { + if (date !== LAST_24_HOURS_KEY) { + recordingsSet.add(date); } - - const parts = date.split("-"); - const cal = new TZDate(date + "T00:00:00", timezone); - - cal.setFullYear( - parseInt(parts[0]), - parseInt(parts[1]) - 1, - parseInt(parts[2]), - ); - - recordings.push(cal); - }); + } } - // Handle reviews if present if (reviewSummary) { - Object.entries(reviewSummary).forEach(([date, data]) => { - if (date === LAST_24_HOURS_KEY) { - return; - } - - const parts = date.split("-"); - const cal = new TZDate(date + "T00:00:00", timezone); - - cal.setFullYear( - parseInt(parts[0]), - parseInt(parts[1]) - 1, - parseInt(parts[2]), - ); + for (const [date, data] of Object.entries(reviewSummary)) { + if (date === LAST_24_HOURS_KEY) continue; if (data.total_alert > data.reviewed_alert) { - alerts.push(cal); + alertsSet.add(date); } else if (data.total_detection > data.reviewed_detection) { - detections.push(cal); + detectionsSet.add(date); } - }); + } } - return { alerts, detections, recordings }; - }, [reviewSummary, recordingsSummary, timezone]); + const formatDay = (day: Date) => { + 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}`; + }; + + return { + recordings: (day: Date) => recordingsSet.has(formatDay(day)), + alerts: (day: Date) => alertsSet.has(formatDay(day)), + detections: (day: Date) => detectionsSet.has(formatDay(day)), + }; + }, [reviewSummary, recordingsSummary]); return (