-
-
-
-
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"
- />
-
-
-
-
+
+
+
setIsOpen((current) => !current)}
+ >
+
+ Search options
+
+ Plate search, matching, and filters
+
+
+
-
- Uses the same matching profiles as Recognition Feed and Plate Database.
-
+
+
+ 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}}
+
+ Clear filters
+
-
+ )}
-
+
startDownload("csv")}>
Download CSV
startDownload("json")}>
Download JSON
-
- Clear filters
-
+
+ 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\]/);