diff --git a/app/download/page.jsx b/app/download/page.jsx index c02cdffc..94cda4c6 100644 --- a/app/download/page.jsx +++ b/app/download/page.jsx @@ -1,17 +1,23 @@ import DashboardLayout from "@/components/layout/MainLayout"; import TitleNavbar from "@/components/layout/TitleNav"; import PlateExportForm from "@/components/PlateExportForm"; -import { getCameraNames, getPlateViewSettings, getTags } from "@/app/actions"; +import { + getCameraNames, + getPlateViewSettings, + getTags, + getTimeFormat, +} from "@/app/actions"; import { requirePagePermission } from "@/lib/page-permission.mjs"; export const dynamic = "force-dynamic"; export default async function DownloadPage() { await requirePagePermission("export.create"); - const [tagsResult, camerasResult, settings] = await Promise.all([ + const [tagsResult, camerasResult, settings, timeFormat] = await Promise.all([ getTags(), getCameraNames(), getPlateViewSettings(), + getTimeFormat(), ]); return ( @@ -21,6 +27,7 @@ export default async function DownloadPage() { tags={tagsResult.success ? tagsResult.data : []} cameras={camerasResult.success ? camerasResult.data : []} matchingSettings={settings.plateMatching} + timeFormat={timeFormat} /> diff --git a/components/FilterHourRange.jsx b/components/FilterHourRange.jsx new file mode 100644 index 00000000..13401146 --- /dev/null +++ b/components/FilterHourRange.jsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +function hourLabel(hour, timeFormat) { + if (timeFormat === 24) return `${String(hour).padStart(2, "0")}:00`; + const suffix = hour >= 12 ? "PM" : "AM"; + return `${hour % 12 || 12}${suffix}`; +} + +export default function FilterHourRange({ + value = null, + onChange, + timeFormat = 12, + triggerClassName = "", +}) { + const [isOpen, setIsOpen] = useState(false); + const [draft, setDraft] = useState({ from: null, to: null }); + const valueFrom = Number.isInteger(value?.from) ? value.from : null; + const valueTo = Number.isInteger(value?.to) ? value.to : null; + const hours = useMemo( + () => + Array.from({ length: 24 }, (_, hour) => ({ + value: hour, + label: hourLabel(hour, timeFormat), + })), + [timeFormat] + ); + + useEffect(() => { + setDraft({ from: valueFrom, to: valueTo }); + }, [valueFrom, valueTo]); + + const hasRange = valueFrom !== null && valueTo !== null; + const label = hasRange + ? `${hourLabel(valueFrom, timeFormat)} - ${hourLabel(valueTo, timeFormat)}` + : "Hour Range"; + + const clear = () => { + setDraft({ from: null, to: null }); + onChange(null); + setIsOpen(false); + }; + + const apply = () => { + if (!Number.isInteger(draft.from) || !Number.isInteger(draft.to)) return; + onChange({ from: draft.from, to: draft.to }); + setIsOpen(false); + }; + + return ( + + + + + +
+

Filter by Hour

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ ); +} diff --git a/components/LiveFeedDateRangeFilter.jsx b/components/LiveFeedDateRangeFilter.jsx index 50420b59..ebbfb014 100644 --- a/components/LiveFeedDateRangeFilter.jsx +++ b/components/LiveFeedDateRangeFilter.jsx @@ -22,6 +22,7 @@ export default function LiveFeedDateRangeFilter({ onInteractionChange = () => {}, embedded = false, triggerClassName = "", + mobileVisible = false, }) { const fromMs = value?.from?.getTime?.() ?? null; const toMs = value?.to?.getTime?.() ?? null; @@ -67,7 +68,7 @@ export default function LiveFeedDateRangeFilter({ - - {isOpen && ( -
-
- -
- - onChange({ search: event.target.value })} - placeholder="Search the plate database" - className="pl-9" +
+
+
+
-
- - onChange({ matchMode })} - settings={matchingSettings} - className="w-full" - /> -

- Controls how closely plate characters must match this search. -

-
-
- -
- - onChange({ tags })} - className="w-full" - /> -
+ -
- - onChange({ cameraNames })} - className="w-full" - /> +
+ + + per page +
-
- - onChange({ dateRange: { ...filters.dateRange, from: event.target.value } })} - /> -
-
- - onChange({ dateRange: { ...filters.dateRange, to: event.target.value } })} - /> -
+ {isOpen && ( +
+
+
+ + + onChange({ search: event.target.value })} + placeholder="Search plates, names, or notes..." + className="h-9 pl-9 dark:bg-[#161618]" + /> +
+
+ + onChange({ matchMode })} + settings={matchingSettings} + className="h-9 w-full" + /> +
+
+ + onChange({ tags })} + className="h-9 w-full dark:bg-[#161618]" + /> +
+
+ + onChange({ cameraNames })} + className="h-9 w-full dark:bg-[#161618]" + /> +
+ + onChange({ hourRange })} + timeFormat={timeFormat} + /> +
+
+ )} +
-
- - -
-
- - -
-
- )} +
+ {hasActiveFilters && ( + <> + Active filters: + {filters.search.trim() && Search: {filters.search.trim()}} + {filters.tags.length > 0 && Tags: {filters.tags.join(", ")}} + {filters.cameraNames.length > 0 && Cameras: {filters.cameraNames.join(", ")}} + {(filters.dateRange.from || filters.dateRange.to) && ( + Date: {filters.dateRange.from || "Any"} - {filters.dateRange.to || "Any"} + )} + {filters.hourRange && ( + Hours: {filters.hourRange.from} - {filters.hourRange.to} + )} + + + )} -
- {can("export.create") && ( - )} -
- - -
); diff --git a/components/PlateExportForm.jsx b/components/PlateExportForm.jsx index 5f9c383d..2e835a79 100644 --- a/components/PlateExportForm.jsx +++ b/components/PlateExportForm.jsx @@ -2,38 +2,44 @@ import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { Download, FileJson, FileSpreadsheet, Search } from "lucide-react"; +import { format } from "date-fns"; +import { + ChevronDown, + Download, + FileJson, + FileSpreadsheet, + Search, + X, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import PlateMatchModeSelect from "@/components/PlateMatchModeSelect"; import MultiSelectFilter from "@/components/MultiSelectFilter"; +import LiveFeedDateRangeFilter from "@/components/LiveFeedDateRangeFilter"; +import FilterHourRange from "@/components/FilterHourRange"; import { readPlateMatchPreference, writePlateMatchPreference, } from "@/lib/plate-match-preference.mjs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -const HOURS = Array.from({ length: 24 }, (_, hour) => hour); -function hourLabel(hour) { - const suffix = hour >= 12 ? "PM" : "AM"; - return `${hour % 12 || 12}:00 ${suffix}`; +function localDate(value) { + if (!value) return null; + const [year, month, day] = value.split("-").map(Number); + const date = new Date(year, month - 1, day); + return Number.isNaN(date.getTime()) ? null : date; } export default function PlateExportForm({ tags = [], cameras = [], matchingSettings, + timeFormat = 12, }) { const searchParams = useSearchParams(); + const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(() => searchParams.get("search") || ""); const [matchMode, setMatchMode] = useState( () => @@ -82,15 +88,38 @@ export default function PlateExportForm({ selectedTags, ]); - const startDownload = (format) => { + const dateRange = { from: localDate(dateFrom), to: localDate(dateTo) }; + const hourRange = + hourFrom !== "all" && hourTo !== "all" + ? { from: Number(hourFrom), to: Number(hourTo) } + : null; + const hasActiveFilters = Boolean( + search.trim() || + selectedTags.length || + selectedCameras.length || + dateFrom || + dateTo || + hourRange + ); + + const startDownload = (downloadFormat) => { const params = new URLSearchParams(query); - params.set("format", format); + params.set("format", downloadFormat); window.location.assign(`/api/exports/plates?${params.toString()}`); }; const handleMatchModeChange = (mode) => { - const persistedMode = writePlateMatchPreference("downloads", mode); - setMatchMode(persistedMode); + setMatchMode(writePlateMatchPreference("downloads", mode)); + }; + + const handleDateRangeChange = (range) => { + setDateFrom(range ? format(range.from, "yyyy-MM-dd") : ""); + setDateTo(range ? format(range.to, "yyyy-MM-dd") : ""); + }; + + const handleHourRangeChange = (range) => { + setHourFrom(range ? String(range.from) : "all"); + setHourTo(range ? String(range.to) : "all"); }; const clearFilters = () => { @@ -104,125 +133,145 @@ export default function PlateExportForm({ }; return ( -
+
Export Plate Database - Download up to 50,000 matching plate records. Filters use the same - plate, tag, camera, date, and time rules as Plate Database. + Download matching database text and timestamps as CSV or JSON. - -
-
- -
- - setSearch(event.target.value)} - placeholder="Search matching records" - className="pl-9" - /> -
-
- - +
+
+ +
+ Up to + 50,000 + rows
-
- - ({ - value: item.name, - label: item.name, - color: item.color, - })), - ]} - exclusiveValues={["untagged"]} - onChange={setSelectedTags} - className="w-full" - /> -
- -
- - ({ value: item, label: item }))} - onChange={setSelectedCameras} - className="w-full" - /> -
- -
- - setDateFrom(event.target.value)} /> -
-
- - setDateTo(event.target.value)} /> -
+ {isOpen && ( +
+
+
+ + + setSearch(event.target.value)} + placeholder="Search plates, names, or notes..." + className="h-9 pl-9 dark:bg-[#161618]" + /> +
+
+ + +
+
+ + ({ + value: item.name, + label: item.name, + color: item.color, + })), + ]} + exclusiveValues={["untagged"]} + onChange={setSelectedTags} + className="h-9 w-full dark:bg-[#161618]" + /> +
+
+ + ({ value: item, label: item }))} + onChange={setSelectedCameras} + className="h-9 w-full dark:bg-[#161618]" + /> +
+ + +
+
+ )} +
-
- - -
-
- - + {hasActiveFilters && ( +
+ Active filters: + {search.trim() && Search: {search.trim()}} + {selectedTags.length > 0 && Tags: {selectedTags.join(", ")}} + {selectedCameras.length > 0 && Cameras: {selectedCameras.join(", ")}} + {(dateFrom || dateTo) && Date: {dateFrom || "Any"} - {dateTo || "Any"}} + {hourRange && Hours: {hourRange.from} - {hourRange.to}} +
-
+ )} -
+
- +

+ Exports contain database text and timestamps only. +

-

- Exports contain database text and timestamps only. Image ZIP export - remains deferred until user roles and export auditing are available. -

diff --git a/components/PlateTable.jsx b/components/PlateTable.jsx index 56ebe4f7..cff5ccc9 100644 --- a/components/PlateTable.jsx +++ b/components/PlateTable.jsx @@ -184,6 +184,7 @@ const POPUP_ACTION_GRID_CLASS = "grid w-full grid-cols-7 gap-2"; const POPUP_ACTION_SLOT_CLASS = "min-h-8 min-w-0"; const TABLE_ACTION_BUTTON_CLASS = "h-8 w-8 p-0"; const CONFIRM_NEXT_SCAN_TIMEOUT_MS = 15000; +const SEARCH_FILTER_DEBOUNCE_MS = 250; function PopupActionSlot({ children, className = "", reserve = false }) { if (!reserve && !children) return null; @@ -442,14 +443,14 @@ export default function PlateTable({ const [isSearchOptionsOpen, setIsSearchOptionsOpen] = useState(false); const handleFilterSheetOpenChange = useCallback((open) => { setIsFilterSheetOpen(open); - onFilterInteractionChange(open); - }, [onFilterInteractionChange]); + }, []); const correctionInputRef = useRef(null); const confirmNextTokenSequenceRef = useRef(0); const activeConfirmNextOperationRef = useRef(null); const selectedImageIdRef = useRef(null); const viewerNavigationTimingRef = useRef(null); + const searchFilterTimerRef = useRef(null); const router = useRouter(); @@ -461,6 +462,26 @@ export default function PlateTable({ onViewerOpenChange(selectedImage !== null); }, [onViewerOpenChange, selectedImage]); + // A filter surface temporarily pauses background refresh work without + // changing the user's Live updates preference. + useEffect(() => { + onFilterInteractionChange(isSearchOptionsOpen || isFilterSheetOpen); + }, [isFilterSheetOpen, isSearchOptionsOpen, onFilterInteractionChange]); + + useEffect( + () => () => { + onFilterInteractionChange(false); + if (searchFilterTimerRef.current) { + window.clearTimeout(searchFilterTimerRef.current); + } + }, + [onFilterInteractionChange] + ); + + useEffect(() => { + setSearchInput(filters.search || ""); + }, [filters.search]); + const cancelConfirmNextOperation = useCallback(() => { activeConfirmNextOperationRef.current = null; setConfirmNextOperation(null); @@ -1358,16 +1379,22 @@ export default function PlateTable({ const handleSearchChange = (e) => { const value = e.target.value.toUpperCase(); const cursorPosition = e.target.selectionStart; + const input = e.target; // Save cursor position - setTimeout(() => { - e.target.setSelectionRange(cursorPosition, cursorPosition); - }, 0); + window.requestAnimationFrame(() => { + input.setSelectionRange(cursorPosition, cursorPosition); + }); setSearchInput(value); - // Delay the actual filter update - setTimeout(() => { + // Commit only the most recent value so typing cannot queue overlapping + // route navigations. + if (searchFilterTimerRef.current) { + window.clearTimeout(searchFilterTimerRef.current); + } + searchFilterTimerRef.current = window.setTimeout(() => { + searchFilterTimerRef.current = null; onUpdateFilters({ search: value }); - }, 300); + }, SEARCH_FILTER_DEBOUNCE_MS); }; const handleMatchModeChange = (matchMode) => { @@ -1573,6 +1600,10 @@ export default function PlateTable({ }; const clearFilters = () => { + if (searchFilterTimerRef.current) { + window.clearTimeout(searchFilterTimerRef.current); + searchFilterTimerRef.current = null; + } setSearchInput(""); onUpdateFilters({ readId: null, @@ -2142,7 +2173,6 @@ export default function PlateTable({ @@ -2371,7 +2401,7 @@ export default function PlateTable({ { const startedAt = performance.now(); @@ -130,6 +133,15 @@ export default function PlateTableWrapper({ } }, [data, total]); + // Keep controls responsive while the Server Component is loading. Ignore + // an older navigation response when a newer filter selection is pending. + useEffect(() => { + const pendingQuery = pendingFilterQueryRef.current; + if (pendingQuery && pendingQuery !== paramsKey) return; + pendingFilterQueryRef.current = ""; + setOptimisticQueryString(paramsKey); + }, [paramsKey]); + // The background visual-intelligence worker can update direction after the // plate row first appears. Refresh while live updates are enabled so Pending // becomes an assigned direction (or a genuine Unknown) without user action. @@ -291,7 +303,9 @@ export default function PlateTableWrapper({ // Helper for updating URL query params const createQueryString = useCallback( (updates) => { - const current = new URLSearchParams(params); + const current = new URLSearchParams( + pendingFilterQueryRef.current || params.toString() + ); Object.entries(updates).forEach(([key, value]) => { if (Array.isArray(value)) { current.delete(key); @@ -311,8 +325,6 @@ export default function PlateTableWrapper({ const handleUpdateFilters = useCallback( (newParams) => { - // When filters are updated, automatically disable live mode. - setIsLiveModeActive(false); if (newParams.matchMode) { writePlateMatchPreference("recognition-feed", newParams.matchMode); } @@ -320,12 +332,14 @@ export default function PlateTableWrapper({ writeTablePageSizePreference("live-feed", newParams.pageSize); } const queryString = createQueryString({ ...newParams, page: "1" }); + pendingFilterQueryRef.current = queryString; + setOptimisticQueryString(queryString); writeRecognitionFeedFilterPreference( recognitionFeedFilterPreferenceFromSearchParams( new URLSearchParams(queryString) ) ); - router.push(`${pathname}?${queryString}`); + router.push(`${pathname}?${queryString}`, { scroll: false }); }, [createQueryString, pathname, router] ); @@ -521,7 +535,10 @@ export default function PlateTableWrapper({ ...(directionOverrides[plate.id] || {}), ...(reviewOverrides[plate.id] || {}), })); - const reviewStatusFilters = params.getAll("reviewStatus").filter(Boolean); + const displayedParams = new URLSearchParams(optimisticQueryString); + const reviewStatusFilters = displayedParams + .getAll("reviewStatus") + .filter(Boolean); const dataToDisplay = reviewStatusFilters.length > 0 ? dataWithOverrides.filter((plate) => reviewStatusFilters.includes( plate.review_status || (plate.validated ? "confirmed" : "unreviewed") @@ -544,9 +561,9 @@ export default function PlateTableWrapper({ timeFormat={timeFormat} biHost={biHost} pagination={{ - page: parseInt(params.get("page") || "1"), + page: parseInt(displayedParams.get("page") || "1"), pageSize: parseInt( - params.get("pageSize") || String(preferredPageSize) + displayedParams.get("pageSize") || String(preferredPageSize) ), total: totalToDisplay, dataRevision: serverDataRevision, @@ -557,36 +574,45 @@ export default function PlateTableWrapper({ onViewerDataRefresh: handleViewerDataRefresh, }} filters={{ - readId: params.get("readId") || "", - search: params.get("search") || "", - matchMode: params.get("matchMode") || preferredMatchMode, - tags: params.getAll("tag").filter((tag) => tag && tag !== "all"), + readId: displayedParams.get("readId") || "", + search: displayedParams.get("search") || "", + matchMode: displayedParams.get("matchMode") || preferredMatchMode, + tags: displayedParams + .getAll("tag") + .filter((tag) => tag && tag !== "all"), dateRange: { - from: params.get("timestampFrom") || params.get("dateFrom") - ? new Date(params.get("timestampFrom") || params.get("dateFrom")) + from: + displayedParams.get("timestampFrom") || displayedParams.get("dateFrom") + ? new Date( + displayedParams.get("timestampFrom") || + displayedParams.get("dateFrom") + ) : null, - to: params.get("timestampTo") || params.get("dateTo") - ? new Date(params.get("timestampTo") || params.get("dateTo")) + to: displayedParams.get("timestampTo") || displayedParams.get("dateTo") + ? new Date( + displayedParams.get("timestampTo") || + displayedParams.get("dateTo") + ) : null, }, hourRange: - params.get("hourFrom") && params.get("hourTo") + displayedParams.get("hourFrom") && displayedParams.get("hourTo") ? { - from: parseInt(params.get("hourFrom")), - to: parseInt(params.get("hourTo")), + from: parseInt(displayedParams.get("hourFrom")), + to: parseInt(displayedParams.get("hourTo")), } : null, - cameraNames: params.getAll("camera").filter(Boolean), - reviewStatuses: params.getAll("reviewStatus").filter(Boolean), - directionLabels: params.getAll("direction").filter(Boolean), - minimumSpeed: params.get("minimumSpeed") || "", - maximumSpeed: params.get("maximumSpeed") || "", + cameraNames: displayedParams.getAll("camera").filter(Boolean), + reviewStatuses: displayedParams.getAll("reviewStatus").filter(Boolean), + directionLabels: displayedParams.getAll("direction").filter(Boolean), + minimumSpeed: displayedParams.get("minimumSpeed") || "", + maximumSpeed: displayedParams.get("maximumSpeed") || "", dashboardTimeFrame, dashboardMetric, }} sort={{ - field: params.get("sortField") || "timestamp", - direction: params.get("sortDirection") || "desc", + field: displayedParams.get("sortField") || "timestamp", + direction: displayedParams.get("sortDirection") || "desc", }} matchingSettings={matchingSettings} onSort={handleSort} diff --git a/components/plateDbTable.jsx b/components/plateDbTable.jsx index 1c1dd3ef..849165e7 100644 --- a/components/plateDbTable.jsx +++ b/components/plateDbTable.jsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect, useDeferredValue } from "react"; +import { useState, useEffect, useDeferredValue, useRef } from "react"; import Link from "next/link"; import { Tag, @@ -166,12 +166,15 @@ export default function PlateTable({ matchingSettings }) { const [totalCount, setTotalCount] = useState(0); const [pageCount, setPageCount] = useState(0); const [timeFormat, setTimeFormat] = useState(12); + const dataRequestSequenceRef = useRef(0); useEffect(() => { setPageSize(readTablePageSizePreference("plate-database")); }, []); useEffect(() => { + const requestSequence = ++dataRequestSequenceRef.current; + let cancelled = false; const loadData = async () => { const result = await getPlates(page, pageSize, sortConfig, { search: deferredSearch, @@ -187,6 +190,7 @@ export default function PlateTable({ matchingSettings }) { ? { from: filterHourFrom, to: filterHourTo } : null, }); + if (cancelled || requestSequence !== dataRequestSequenceRef.current) return; if (result.success) { setData(result.data); setTotalCount(result.pagination.total); @@ -194,6 +198,9 @@ export default function PlateTable({ matchingSettings }) { } }; loadData(); + return () => { + cancelled = true; + }; }, [ page, pageSize, @@ -447,6 +454,7 @@ export default function PlateTable({ matchingSettings }) { onPageSizeChange={handlePageSizeChange} sortConfig={sortConfig} matchingSettings={matchingSettings} + timeFormat={timeFormat} /> 0; + const includesUnknownDirection = requestedDirections.includes( + UNKNOWN_DIRECTION_FILTER + ); const countDirectionJoin = requiresDirectionFilter - ? "LEFT JOIN vehicle_direction_observations direction ON direction.read_id = pr.id" + ? `${includesUnknownDirection ? "LEFT JOIN" : "JOIN"} vehicle_direction_observations direction ON direction.read_id = pr.id` : ""; const countRadarJoin = requiresRadarFilter ? "LEFT JOIN radar_events radar ON radar.matched_read_id = pr.id" @@ -358,9 +361,11 @@ export async function getPlateReads({ const pagedRadarJoin = requiresRadarFilter || sortsBySpeed ? "LEFT JOIN radar_events radar ON radar.matched_read_id = pr.id" : ""; - const pagedDirectionJoin = requiresDirectionFilter || sortsByDirection - ? "LEFT JOIN vehicle_direction_observations direction ON direction.read_id = pr.id" - : ""; + const pagedDirectionJoin = requiresDirectionFilter + ? `${includesUnknownDirection ? "LEFT JOIN" : "JOIN"} vehicle_direction_observations direction ON direction.read_id = pr.id` + : sortsByDirection + ? "LEFT JOIN vehicle_direction_observations direction ON direction.read_id = pr.id" + : ""; const pagedTagJoins = sortsByTags ? `LEFT JOIN plate_tags page_plate_tags ON pr.plate_number = page_plate_tags.plate_number LEFT JOIN tags t ON page_plate_tags.tag_id = t.id` diff --git a/migrations.sql b/migrations.sql index 9832f948..01f6d1a4 100644 --- a/migrations.sql +++ b/migrations.sql @@ -9710,3 +9710,23 @@ CREATE INDEX IF NOT EXISTS idx_blue_iris_camera_inventory_display_name INSERT INTO public.schema_migrations(version,description) VALUES ('2026082202_blue_iris_camera_inventory','Cache sanitized Blue Iris display-to-short camera mappings for reliable plate-capture and Vehicle View playback links.') ON CONFLICT(version) DO NOTHING; + +-- Recognition Feed filters should use bounded index lookups instead of +-- rescanning the read history whenever an operator changes a control. +CREATE INDEX IF NOT EXISTS idx_plate_reads_filter_camera + ON public.plate_reads (LOWER(camera_name), "timestamp" DESC, id DESC) + WHERE camera_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_plate_reads_filter_review_status + ON public.plate_reads (review_status, "timestamp" DESC, id DESC) + WHERE review_status IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_plate_tags_filter_tag + ON public.plate_tags (tag_id, plate_number); +CREATE INDEX IF NOT EXISTS idx_vehicle_direction_filter_label + ON public.vehicle_direction_observations (LOWER(direction_label), read_id) + WHERE direction_label IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_vehicle_direction_filter_status + ON public.vehicle_direction_observations (status, read_id); + +INSERT INTO public.schema_migrations(version,description) VALUES + ('2026082203_recognition_filter_indexes','Add query-path indexes for Recognition Feed camera, tag, review-status, and direction filters.') +ON CONFLICT(version) DO NOTHING; diff --git a/test/integration-ingress-receipts.test.mjs b/test/integration-ingress-receipts.test.mjs index 505308e9..3e933743 100644 --- a/test/integration-ingress-receipts.test.mjs +++ b/test/integration-ingress-receipts.test.mjs @@ -168,7 +168,7 @@ test("receipt explorer is protected and reachable from System Logs", async () => assert.match(viewer, /triggerAliasConflict/); assert.match(viewer, /Duplicate target \{readId\}/); assert.equal(liveFeed.includes("readId: /^\\d+$/.test"), true); - assert.match(wrapper, /readId: params\.get\("readId"\)/); + assert.match(wrapper, /readId: displayedParams\.get\("readId"\)/); assert.match(plateTable, /Exact read: \{filters\.readId\}/); assert.match(plateTable, /\/logs\?readId=\$\{plate\.id\}&expand=first/); assert.match(database, /pr\.id = \$\{addValue\(requestedReadId\)\}::bigint/); diff --git a/test/live-feed-regressions.test.mjs b/test/live-feed-regressions.test.mjs index 183ef932..4a000fb1 100644 --- a/test/live-feed-regressions.test.mjs +++ b/test/live-feed-regressions.test.mjs @@ -33,11 +33,18 @@ test("Live Feed pauses polling while a date or mobile-filter interaction is acti ]); assert.match(dateFilter, /onOpenChange=\{onInteractionChange\}/); - assert.match(table, /handleFilterSheetOpenChange = useCallback[\s\S]*?onFilterInteractionChange\(open\)/); + assert.match(table, /onFilterInteractionChange\(isSearchOptionsOpen \|\| isFilterSheetOpen\)/); assert.match(table, /onOpenChange=\{handleFilterSheetOpenChange\}/); - assert.match(table, /onInteractionChange=\{onFilterInteractionChange\}/); + assert.doesNotMatch(table, /onInteractionChange=\{onFilterInteractionChange\}/); assert.match(wrapper, /if \(isFilterInteractionActive\) return undefined/); assert.match(wrapper, /onFilterInteractionChange=\{setIsFilterInteractionActive\}/); + assert.doesNotMatch( + wrapper, + /filters are updated[\s\S]*?setIsLiveModeActive\(false\)/i + ); + assert.match(wrapper, /pendingFilterQueryRef\.current = queryString;[\s\S]*?setOptimisticQueryString\(queryString\)/); + assert.match(table, /SEARCH_FILTER_DEBOUNCE_MS = 250/); + assert.match(table, /window\.clearTimeout\(searchFilterTimerRef\.current\)/); }); test("Live Feed does not eagerly preload every full capture", async () => { @@ -75,7 +82,8 @@ test("Live Feed count and page queries avoid optional joins and prefer Blue Iris assert.match(database, /SELECT COUNT\(\*\)\s+FROM plate_reads pr/); assert.match(database, /const countDirectionJoin = requiresDirectionFilter/); - assert.match(database, /const pagedDirectionJoin = requiresDirectionFilter \|\| sortsByDirection/); + assert.match(database, /const pagedDirectionJoin = requiresDirectionFilter/); + assert.match(database, /includesUnknownDirection \? "LEFT JOIN" : "JOIN"/); assert.match(database, /FROM public\.vehicle_overview_associations association/); assert.match(database, /profile\.source_camera_short_name/); assert.match(database, /overview_candidate_playback\.source_camera/); @@ -85,6 +93,10 @@ test("Live Feed count and page queries avoid optional joins and prefer Blue Iris assert.match(table, /buildBlueIrisPlatePlaybackPath\(/); assert.match(table, /selectedImage\?\.plateBiCamera/); assert.match(migrations, /2026082202_blue_iris_camera_inventory/); + assert.match(migrations, /2026082203_recognition_filter_indexes/); + assert.match(migrations, /idx_plate_reads_filter_camera/); + assert.match(migrations, /idx_plate_tags_filter_tag/); + assert.match(migrations, /idx_vehicle_direction_filter_label/); }); test("Blue Iris camera inventory refreshes at runtime startup without waiting for ingestion", async () => { diff --git a/test/plate-export.test.mjs b/test/plate-export.test.mjs index c6693b18..59546995 100644 --- a/test/plate-export.test.mjs +++ b/test/plate-export.test.mjs @@ -82,4 +82,16 @@ test("Downloads page and API expose authenticated filter-respecting exports", as assert.match(filters, /Search options/); assert.match(filters, /aria-expanded=\{isOpen\}/); assert.match(filters, /Export these results/); + assert.match(filters, /id="plate-database-page-size"/); + assert.ok( + filters.indexOf('id="plate-database-page-size"') < + filters.indexOf('id="plate-database-search-options"'), + "database page size belongs beside the Search options header" + ); + assert.match(filters, /\{hasActiveFilters && \(/); + assert.match(form, /aria-controls="plate-export-search-options"/); + assert.match(form, /LiveFeedDateRangeFilter/); + assert.match(form, /FilterHourRange/); + assert.match(form, /\{hasActiveFilters && \(/); + assert.match(form, /Up to[\s\S]*?50,000[\s\S]*?rows/); }); diff --git a/test/table-page-size-preference.test.mjs b/test/table-page-size-preference.test.mjs index c1686dc4..29c7199f 100644 --- a/test/table-page-size-preference.test.mjs +++ b/test/table-page-size-preference.test.mjs @@ -85,12 +85,15 @@ test("Live Feed preferences use a server-readable cookie without a hydration nav assert.match(page, /readTablePageSizeCookiePreference/); assert.doesNotMatch(wrapper, /router\.replace\(/); assert.match(wrapper, /writeTablePageSizePreference\("live-feed"/); - assert.equal( - wrapper.match( - /parseInt\(\s*params\.get\("pageSize"\) \|\| String\(preferredPageSize\)\s*\)/g - )?.length, - 2, - "pagination and page navigation must both fall back to the saved server preference" + assert.match( + wrapper, + /params\.get\("pageSize"\) \|\| String\(preferredPageSize\)/, + "page navigation must fall back to the saved server preference" + ); + assert.match( + wrapper, + /displayedParams\.get\("pageSize"\) \|\| String\(preferredPageSize\)/, + "the optimistic pagination control must use the saved server preference" ); const pageSizeControl = table.indexOf('id="recognition-feed-page-size"'); diff --git a/test/table-sort-controls.test.mjs b/test/table-sort-controls.test.mjs index b54d133f..ab0c8974 100644 --- a/test/table-sort-controls.test.mjs +++ b/test/table-sort-controls.test.mjs @@ -29,7 +29,7 @@ test("Recognition Feed exposes every supported server-side sort control", async const controls = [ ["Plate Number", "plate_number"], ["%", "confidence"], - ["Occurrences", "occurrence_count"], + ["Count", "occurrence_count"], ["Tags", "tags"], ["Camera", "camera_name"], ["Timestamp", "timestamp"], diff --git a/test/ui-table-enhancements.test.mjs b/test/ui-table-enhancements.test.mjs index ef9e0771..7d0022cd 100644 --- a/test/ui-table-enhancements.test.mjs +++ b/test/ui-table-enhancements.test.mjs @@ -337,7 +337,7 @@ test("live feed review status filtering is multi-select, URL-backed, and server- for (const status of ["unreviewed", "confirmed", "corrected", "alias_resolved"]) { assert.match(table, new RegExp(`value: "${status}"`)); } - assert.match(wrapper, /params\.getAll\("reviewStatus"\)/); + assert.match(wrapper, /displayedParams[\s\S]*?\.getAll\("reviewStatus"\)/); assert.match(page, /searchParamList\(searchParams\?\.reviewStatus\)/); assert.match(actions, /reviewStatuses: Array\.isArray\(reviewStatuses\)/); assert.match(database, /FILTERABLE_REVIEW_STATUSES/); @@ -366,7 +366,7 @@ test("live feed direction is visible, correctable, and filterable by semantic ca assert.match(table, //); assert.match(table, /Front view/); assert.match(table, /Rear view/); - assert.match(wrapper, /params\.getAll\("direction"\)/); + assert.match(wrapper, /displayedParams\.getAll\("direction"\)/); assert.match(wrapper, /setDirectionOverrides/); assert.match(wrapper, /direction_label: observation\.directionLabel/); assert.match(wrapper, /directionOverrides\[plate\.id\]/);