diff --git a/components/ImageViewer.jsx b/components/ImageViewer.jsx
index de2d8f76..f78e0130 100644
--- a/components/ImageViewer.jsx
+++ b/components/ImageViewer.jsx
@@ -38,6 +38,7 @@ const ImageViewer = ({
defaultZoom = null,
zoomLabel = "Zoom to Plate",
onFullscreenChange = null,
+ onImageLoad = null,
}) => {
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
@@ -170,17 +171,22 @@ const ImageViewer = ({
useEffect(() => {
setImageSize(null);
- let active = true;
- const img = new Image();
- img.onload = () => {
- if (active) setImageSize({ url: image.url, width: img.width, height: img.height });
- };
- img.src = image.url;
- return () => {
- active = false;
- };
}, [image.url]);
+ const handleImageLoad = useCallback((event) => {
+ const element = event.currentTarget;
+ const width = Number(element.naturalWidth || element.width);
+ const height = Number(element.naturalHeight || element.height);
+ if (width > 0 && height > 0) {
+ setImageSize({ url: image.url, width, height });
+ }
+ onImageLoad?.({
+ url: image.url,
+ width: width > 0 ? width : null,
+ height: height > 0 ? height : null,
+ });
+ }, [image.url, onImageLoad]);
+
useEffect(() => {
const needsFocusMeasurements = defaultZoom === null && Boolean(
image?.focus_coordinates || image?.crop_coordinates
@@ -423,6 +429,7 @@ const ImageViewer = ({
className="object-contain"
draggable={false}
unoptimized
+ onLoad={handleImageLoad}
/>
diff --git a/components/PlateTable.jsx b/components/PlateTable.jsx
index a39a23a1..e2ca01f8 100644
--- a/components/PlateTable.jsx
+++ b/components/PlateTable.jsx
@@ -117,6 +117,10 @@ import {
saveLiveFeedPopupView,
} from "@/lib/live-feed-popup-preference.mjs";
import { buildBlueIrisUiUrl } from "@/lib/blue-iris-ui-url.mjs";
+import {
+ elapsedMilliseconds,
+ recordLiveFeedPerformance,
+} from "@/lib/live-feed-performance.mjs";
import {
Sheet,
SheetContent,
@@ -302,9 +306,10 @@ export default function PlateTable({
sort = { field: "", direction: "" },
onSort = () => {},
matchingSettings,
+ isLive = true,
+ onLiveChange = () => {},
+ onViewerOpenChange = () => {},
}) {
- console.log("PlateTable rendering with data:", data.length);
-
const { can } = useAccess();
const canRead = can("plate.read");
const canReview = can("plate.review");
@@ -392,7 +397,6 @@ export default function PlateTable({
const [isDirectionReviewOpen, setIsDirectionReviewOpen] = useState(false);
const [directionReviewError, setDirectionReviewError] = useState("");
const [searchInput, setSearchInput] = useState(filters.search || "");
- const [isLive, setIsLive] = useState(true);
const [prefetchedImages, setPrefetchedImages] = useState(new Set());
const [biHost, setBiHost] = useState(null);
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false);
@@ -402,6 +406,7 @@ export default function PlateTable({
const confirmNextTokenSequenceRef = useRef(0);
const activeConfirmNextOperationRef = useRef(null);
const selectedImageIdRef = useRef(null);
+ const viewerNavigationTimingRef = useRef(null);
const router = useRouter();
@@ -409,6 +414,10 @@ export default function PlateTable({
selectedImageIdRef.current = selectedImage?.id ?? null;
}, [selectedImage?.id]);
+ useEffect(() => {
+ onViewerOpenChange(selectedImage !== null);
+ }, [onViewerOpenChange, selectedImage]);
+
const cancelConfirmNextOperation = useCallback(() => {
activeConfirmNextOperationRef.current = null;
setConfirmNextOperation(null);
@@ -423,6 +432,7 @@ export default function PlateTable({
useEffect(() => () => {
activeConfirmNextOperationRef.current = null;
selectedImageIdRef.current = null;
+ viewerNavigationTimingRef.current = null;
}, []);
useEffect(() => {
@@ -552,20 +562,6 @@ export default function PlateTable({
fetchBiHost();
}, []);
- useEffect(() => {
- let interval;
- if (
- isLive &&
- pendingUnconfirmedNavigation === null &&
- confirmNextOperation === null
- ) {
- interval = setInterval(() => {
- router.refresh();
- }, 4500);
- }
- return () => clearInterval(interval);
- }, [confirmNextOperation, isLive, pendingUnconfirmedNavigation, router]);
-
// Helper functions
const getImageUrl = (base64Data) => {
if (!base64Data) return "/placeholder-image.jpg";
@@ -603,6 +599,14 @@ export default function PlateTable({
crop_coordinates = plate.crop_coordinates;
}
+ if (viewerNavigationTimingRef.current) {
+ viewerNavigationTimingRef.current = {
+ ...viewerNavigationTimingRef.current,
+ targetReadId: Number(plate.id),
+ targetCamera: plate.camera_name || "",
+ };
+ }
+
setSelectedIndex(plateIndex);
setSelectedImage({
url: imageUrl,
@@ -681,6 +685,7 @@ export default function PlateTable({
]
);
const onViewerPageChange = pagination.onViewerPageChange;
+ const onViewerDataRefresh = pagination.onViewerDataRefresh;
const handleViewerNavigation = useCallback((direction) => {
if (
@@ -691,6 +696,23 @@ export default function PlateTable({
) return;
const destination = getViewerNavigation(direction);
+ if (destination.kind === "none") return;
+
+ viewerNavigationTimingRef.current = {
+ metric: "viewer_navigation",
+ operation: direction,
+ boundary: destination.kind === "item" ? "same_page" : "cross_page",
+ startedAt: performance.now(),
+ sourceReadId: Number(selectedImage.id),
+ sourceCamera: selectedImage.cameraName || "",
+ targetPage: destination.kind === "page" ? destination.page : pagination.page,
+ targetReadId: destination.kind === "item"
+ ? Number(data[destination.index]?.id)
+ : null,
+ targetCamera: destination.kind === "item"
+ ? data[destination.index]?.camera_name || ""
+ : "",
+ };
if (destination.kind === "item") {
handleImageClick(
{ preventDefault: () => {} },
@@ -713,6 +735,7 @@ export default function PlateTable({
onViewerPageChange,
pendingUnconfirmedNavigation,
pendingViewerNavigation,
+ pagination.page,
selectedImage,
]);
@@ -801,6 +824,25 @@ export default function PlateTable({
? "vehicle"
: "plate";
+ const handleViewerImageLoad = useCallback(({ url, width, height }) => {
+ const timing = viewerNavigationTimingRef.current;
+ if (!timing || !selectedImage?.id) return;
+ if (timing.targetReadId && timing.targetReadId !== Number(selectedImage.id)) return;
+
+ recordLiveFeedPerformance({
+ ...timing,
+ durationMs: elapsedMilliseconds(timing.startedAt, performance.now()),
+ outcome: "image_loaded",
+ targetReadId: Number(selectedImage.id),
+ targetCamera: selectedImage.cameraName || timing.targetCamera || "",
+ imageView: displayedImageView,
+ imageUrlKind: String(url || "").startsWith("data:") ? "inline" : "stored_file",
+ imageWidth: width,
+ imageHeight: height,
+ });
+ viewerNavigationTimingRef.current = null;
+ }, [displayedImageView, selectedImage]);
+
useEffect(() => {
if (selectedImage && data && data.length > 0) {
const currentPlate = data.find((plate) => plate.id === selectedImage.id);
@@ -939,6 +981,8 @@ export default function PlateTable({
const readId = selectedImage.id;
const nextValidated = !selectedImage.validated;
+ const reviewStartedAt = performance.now();
+ let reviewSucceeded = false;
const previousReviewState = {
validated: selectedImage.validated,
plateNumber: selectedImage.plateNumber,
@@ -984,12 +1028,21 @@ export default function PlateTable({
result.data?.reviewRevision ?? previous.reviewRevision,
};
});
+ reviewSucceeded = true;
return true;
} catch (error) {
rollbackReviewState();
console.error("Failed to update plate review:", error);
return false;
} finally {
+ recordLiveFeedPerformance({
+ metric: "review_action",
+ operation: nextValidated ? "confirm" : "reopen",
+ durationMs: elapsedMilliseconds(reviewStartedAt, performance.now()),
+ success: reviewSucceeded,
+ readId: Number(readId),
+ camera: selectedImage.cameraName || "",
+ });
setPendingReviewReadId((current) => (current === readId ? null : current));
setPendingReviewTargetValidated(null);
}
@@ -1013,9 +1066,21 @@ export default function PlateTable({
activeConfirmNextOperationRef.current = operation;
setConfirmNextOperation(operation);
const nextRead = nextUnconfirmedIndex >= 0 ? data[nextUnconfirmedIndex] : null;
- const waitsForFilteredRemoval =
+ const waitsForFilteredBoundaryRefresh =
+ !nextRead &&
selectedReviewStatuses.length > 0 &&
!selectedReviewStatuses.includes("confirmed");
+ viewerNavigationTimingRef.current = {
+ metric: "viewer_navigation",
+ operation: "confirm_and_next",
+ boundary: nextRead ? "same_page" : "cross_page",
+ startedAt: performance.now(),
+ sourceReadId: Number(selectedImage.id),
+ sourceCamera: selectedImage.cameraName || "",
+ targetPage: nextRead ? pagination.page : pagination.page + 1,
+ targetReadId: nextRead ? Number(nextRead.id) : null,
+ targetCamera: nextRead?.camera_name || "",
+ };
const confirmed = await handleSelectedImageValidation();
if (!isConfirmNextOperationCurrent({
activeToken: activeConfirmNextOperationRef.current?.token ?? null,
@@ -1024,6 +1089,15 @@ export default function PlateTable({
originReadId: origin.originReadId,
})) return;
if (!confirmed) {
+ const timing = viewerNavigationTimingRef.current;
+ if (timing?.operation === "confirm_and_next") {
+ recordLiveFeedPerformance({
+ ...timing,
+ durationMs: elapsedMilliseconds(timing.startedAt, performance.now()),
+ outcome: "review_failed",
+ });
+ viewerNavigationTimingRef.current = null;
+ }
cancelConfirmNextOperation();
return;
}
@@ -1036,12 +1110,17 @@ export default function PlateTable({
...origin,
deadlineAt: Date.now() + CONFIRM_NEXT_SCAN_TIMEOUT_MS,
};
- if (waitsForFilteredRemoval) {
+ if (
+ waitsForFilteredBoundaryRefresh &&
+ typeof onViewerDataRefresh === "function"
+ ) {
setPendingUnconfirmedNavigation({
...pending,
phase: "await-filtered-removal",
targetPage: origin.originPage,
+ originDataRevision: pagination.dataRevision,
});
+ onViewerDataRefresh();
return;
}
if (hasLaterResultPage && typeof onViewerPageChange === "function") {
@@ -1057,6 +1136,13 @@ export default function PlateTable({
useEffect(() => {
const pending = pendingUnconfirmedNavigation;
if (!pending) return;
+ if (
+ pending.phase === "await-filtered-removal" &&
+ pending.originDataRevision === pagination.dataRevision &&
+ Date.now() < pending.deadlineAt
+ ) {
+ return;
+ }
const transition = resolveUnconfirmedPageTransition({
pending,
reads: data,
@@ -1084,6 +1170,7 @@ export default function PlateTable({
onViewerPageChange,
pagination.page,
pagination.pageSize,
+ pagination.dataRevision,
pagination.total,
pendingUnconfirmedNavigation,
]);
@@ -1279,6 +1366,7 @@ export default function PlateTable({
if (deletingSelectedRead) {
cancelConfirmNextFlow();
selectedImageIdRef.current = null;
+ viewerNavigationTimingRef.current = null;
setSelectedImage(null);
setSelectedIndex(-1);
setPendingViewerNavigation(null);
@@ -1815,7 +1903,7 @@ export default function PlateTable({
diff --git a/components/PlateTableWrapper.jsx b/components/PlateTableWrapper.jsx
index dfa063d0..60a07b07 100644
--- a/components/PlateTableWrapper.jsx
+++ b/components/PlateTableWrapper.jsx
@@ -12,6 +12,10 @@ import {
writeTablePageSizePreference,
} from "@/lib/table-page-size-preference.mjs";
import { scrollMainToTop } from "@/lib/page-scroll.mjs";
+import {
+ elapsedMilliseconds,
+ recordLiveFeedPerformance,
+} from "@/lib/live-feed-performance.mjs";
import {
addKnownPlate,
correctPlateRead,
@@ -25,6 +29,9 @@ import {
validatePlateRecord,
} from "@/app/actions";
+const LIVE_REFRESH_INTERVAL_MS = 5_000;
+const LIVE_REFRESH_TIMEOUT_MS = 15_000;
+
export default function PlateTableWrapper({
data, // Initial data from server component (props from page.jsx)
total, // Initial total from server component
@@ -49,10 +56,38 @@ export default function PlateTableWrapper({
const [liveData, setLiveData] = useState(data);
const [liveTotal, setLiveTotal] = useState(total);
const [directionOverrides, setDirectionOverrides] = useState({});
+ const [reviewOverrides, setReviewOverrides] = useState({});
+ const [isViewerOpen, setIsViewerOpen] = useState(false);
+ const [serverDataRevision, setServerDataRevision] = useState(0);
// State to control if live updates are active (toggled by user)
const [isLiveModeActive, setIsLiveModeActive] = useState(true);
const eventSourceRef = useRef(null); // Ref to hold the EventSource instance
+ const refreshTimingRef = useRef(null);
+ const refreshAfterViewerCloseRef = useRef(false);
+ const viewerWasOpenRef = useRef(false);
+
+ const requestLiveRefresh = useCallback((reason) => {
+ const startedAt = performance.now();
+ const active = refreshTimingRef.current;
+ if (active && startedAt - active.startedAt < LIVE_REFRESH_TIMEOUT_MS) {
+ return false;
+ }
+ if (active) {
+ recordLiveFeedPerformance({
+ metric: "feed_refresh",
+ operation: active.reason,
+ durationMs: elapsedMilliseconds(active.startedAt, startedAt),
+ outcome: "timed_out",
+ });
+ }
+ refreshTimingRef.current = {
+ reason,
+ startedAt,
+ };
+ router.refresh();
+ return true;
+ }, [router]);
// Derived state to check if any filters are active
const hasActiveFilters = useCallback(() => {
@@ -75,16 +110,31 @@ export default function PlateTableWrapper({
useEffect(() => {
setLiveData(data);
setLiveTotal(total);
+ setServerDataRevision((current) => current + 1);
+ const timing = refreshTimingRef.current;
+ if (timing) {
+ recordLiveFeedPerformance({
+ metric: "feed_refresh",
+ operation: timing.reason,
+ durationMs: elapsedMilliseconds(timing.startedAt, performance.now()),
+ rowCount: data.length,
+ total,
+ });
+ refreshTimingRef.current = null;
+ }
}, [data, total]);
// 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.
useEffect(() => {
- if (!isLiveModeActive) return undefined;
- const timer = window.setInterval(() => router.refresh(), 10_000);
+ if (!isLiveModeActive || isViewerOpen) return undefined;
+ const timer = window.setInterval(
+ () => requestLiveRefresh("live_poll"),
+ LIVE_REFRESH_INTERVAL_MS
+ );
return () => window.clearInterval(timer);
- }, [isLiveModeActive, router]);
+ }, [isLiveModeActive, isViewerOpen, requestLiveRefresh]);
useEffect(() => {
setDirectionOverrides((current) => {
@@ -107,6 +157,27 @@ export default function PlateTableWrapper({
});
}, [data]);
+ useEffect(() => {
+ setReviewOverrides((current) => {
+ let changed = false;
+ const next = { ...current };
+ data.forEach((plate) => {
+ const override = current[plate.id];
+ if (!override) return;
+ if (
+ plate.validated === override.validated &&
+ plate.review_status === override.review_status &&
+ Number(plate.review_revision || 0) >= Number(override.review_revision || 0) &&
+ plate.plate_number === override.plate_number
+ ) {
+ delete next[plate.id];
+ changed = true;
+ }
+ });
+ return changed ? next : current;
+ });
+ }, [data]);
+
// Effect to manage SSE connection and data merging
// useEffect(() => {
// if (isLiveModeActive && !hasActiveFilters()) {
@@ -222,6 +293,19 @@ export default function PlateTableWrapper({
[params]
);
+ useEffect(() => {
+ const currentPage = parseInt(params.get("page") || "1");
+ const pageSize = parseInt(params.get("pageSize") || "25");
+ const adjacentPages = [];
+ if (currentPage > 1) adjacentPages.push(currentPage - 1);
+ if (currentPage * pageSize < total) adjacentPages.push(currentPage + 1);
+ adjacentPages.forEach((page) => {
+ router.prefetch(
+ `${pathname}?${createQueryString({ page: page.toString() })}`
+ );
+ });
+ }, [createQueryString, params, pathname, router, total]);
+
useEffect(() => {
const updates = {};
if (!params.get("matchMode")) {
@@ -284,10 +368,9 @@ export default function PlateTableWrapper({
[createQueryString, params, pathname, router, total]
);
- // Action handlers that trigger server-side changes and should revalidate data.
- // These *must* call `router.refresh()` to ensure the server's cache is invalidated
- // and the page.jsx re-fetches its data, which then updates the `data` prop
- // in PlateTableWrapper.
+ // Most mutations refresh immediately. Plate confirmation is the exception:
+ // it applies a local review override and defers the server refresh until the
+ // viewer closes so Confirm and Next never races a full feed request.
const handleAddTag = async (plateNumber, tagName) => {
const formData = new FormData();
formData.append("plateNumber", plateNumber);
@@ -343,11 +426,45 @@ export default function PlateTableWrapper({
const handleValidatePlate = async (id, value) => {
const result = await validatePlateRecord(id, value);
if (result.success) {
- router.refresh();
+ setReviewOverrides((current) => ({
+ ...current,
+ [id]: {
+ validated: value,
+ review_status:
+ result.data?.reviewStatus || (value ? "confirmed" : "unreviewed"),
+ review_revision: Number(result.data?.reviewRevision || 0),
+ plate_number: result.data?.effectivePlate,
+ },
+ }));
+ if (isViewerOpen) {
+ refreshAfterViewerCloseRef.current = true;
+ } else {
+ requestLiveRefresh("review_action");
+ }
}
return result;
};
+ const handleViewerOpenChange = useCallback((open) => {
+ const nextOpen = open === true;
+ const wasOpen = viewerWasOpenRef.current;
+ viewerWasOpenRef.current = nextOpen;
+ setIsViewerOpen(nextOpen);
+ if (
+ wasOpen &&
+ !nextOpen &&
+ (refreshAfterViewerCloseRef.current || isLiveModeActive)
+ ) {
+ refreshAfterViewerCloseRef.current = false;
+ requestLiveRefresh("viewer_close");
+ }
+ }, [isLiveModeActive, requestLiveRefresh]);
+
+ const handleViewerDataRefresh = useCallback(() => {
+ refreshAfterViewerCloseRef.current = false;
+ requestLiveRefresh("confirm_next_filtered_boundary");
+ }, [requestLiveRefresh]);
+
const handlePreviewCorrection = async (formData) => {
return await previewPlateCorrection(formData);
};
@@ -407,13 +524,23 @@ export default function PlateTableWrapper({
// Determine which data to pass to PlateTable
const baseDataToDisplay =
hasActiveFilters() || !isLiveModeActive ? data : liveData;
- const dataToDisplay = baseDataToDisplay.map((plate) =>
- directionOverrides[plate.id]
- ? { ...plate, ...directionOverrides[plate.id] }
- : plate
- );
- const totalToDisplay =
+ const dataWithOverrides = baseDataToDisplay.map((plate) => ({
+ ...plate,
+ ...(directionOverrides[plate.id] || {}),
+ ...(reviewOverrides[plate.id] || {}),
+ }));
+ const reviewStatusFilters = params.getAll("reviewStatus").filter(Boolean);
+ const dataToDisplay = reviewStatusFilters.length > 0
+ ? dataWithOverrides.filter((plate) => reviewStatusFilters.includes(
+ plate.review_status || (plate.validated ? "confirmed" : "unreviewed")
+ ))
+ : dataWithOverrides;
+ const baseTotalToDisplay =
hasActiveFilters() || !isLiveModeActive ? total : liveTotal;
+ const totalToDisplay = Math.max(
+ 0,
+ baseTotalToDisplay - (dataWithOverrides.length - dataToDisplay.length)
+ );
return (
handlePageChange("next"),
onPreviousPage: () => handlePageChange("prev"),
onViewerPageChange: (direction) =>
handlePageChange(direction, { scrollToTop: false }),
+ onViewerDataRefresh: handleViewerDataRefresh,
}}
filters={{
search: params.get("search") || "",
@@ -471,6 +600,7 @@ export default function PlateTableWrapper({
onReverseReview={handleReverseReview}
onReviewDirection={handleReviewDirection}
onValidate={handleValidatePlate}
+ onViewerOpenChange={handleViewerOpenChange}
isLive={isLiveModeActive} // Pass the live mode state
onLiveChange={setIsLiveModeActive} // Pass the setter for live mode
loading={false} // Loading state is now more complex. For simplicity, we'll keep it false here.
diff --git a/docs/COMMUNITY_PRODUCT_ROADMAP.md b/docs/COMMUNITY_PRODUCT_ROADMAP.md
index ebc28a86..c9224c8b 100644
--- a/docs/COMMUNITY_PRODUCT_ROADMAP.md
+++ b/docs/COMMUNITY_PRODUCT_ROADMAP.md
@@ -18,13 +18,19 @@ reliable background processing.
6. Audit sensitive searches, exports, corrections, rule changes, and
destructive maintenance.
-## Release baseline — August 4, 2026
+## Release baseline — August 7, 2026
- Application `0.1.15` includes named users and roles, evidence-preserving plate
review, filter-respecting exports, the searchable help center, local privacy
controls, and viewport-safe date/help navigation. Monitored Plates now lives
inside Known Plates with reason, priority, monitoring-since, and read-history
context; the former `/flagged` route redirects to that view.
+- Recognition Feed navigation performance is now a delivered baseline. Live
+ updates have one controller, polling pauses while the image popup is open,
+ review state is applied locally until close, adjacent pages are prefetched,
+ image dimensions reuse the displayed request, and a bounded browser buffer
+ measures feed refresh, review action, same-page or cross-page navigation,
+ camera, image view, and final image-load timing without server telemetry.
- Unified notifications now include migration preview, idempotent disabled
copies, restricted disabled-rule editing, no-delivery simulation, shadow
comparison, administrator approval evidence, atomic per-rule cutover, and
diff --git a/lib/help-manual.mjs b/lib/help-manual.mjs
index b5283f77..f480bb3b 100644
--- a/lib/help-manual.mjs
+++ b/lib/help-manual.mjs
@@ -14,9 +14,9 @@ export const HELP_MANUAL = Object.freeze({
shortTitle: "User Guide",
description:
"A practical guide to viewing plate activity, reviewing OCR results, managing known plates, exporting records, and safely administering your ALPR installation.",
- manualVersion: "1.50",
- updatedAt: "August 4, 2026",
- coverageBaseline: "August 4, 2026 storage-maintenance release: host requests update automatically, application and worker images use separate protection ledgers, the currently attested worker remains protected between timer runs, the manual image-retirement grace is configurable from one to 365 days with a seven-day default, Docker receipts distinguish logical footprint from conservative layer-store reclamation, and a bounded read-only host snapshot reports Docker and verified backup totals; plus the August 2 production-active backup, Live Feed review, storage, privacy, maintenance, notification, and Phase 2 baseline",
+ manualVersion: "1.51",
+ updatedAt: "August 7, 2026",
+ coverageBaseline: "August 7, 2026 Live Feed navigation-performance release: one live-refresh controller pauses while the image viewer is open, review state updates locally until the viewer closes, adjacent pages are prefetched, image sizing reuses the displayed image request, and bounded browser diagnostics measure refresh, review, navigation, camera, and image-load timing; plus the August 4 storage-maintenance and August 2 production-active backup, Live Feed review, storage, privacy, maintenance, notification, and Phase 2 baseline",
filename: "ALPR-Database-Community-User-Guide.pdf",
sections: [
{
@@ -143,6 +143,7 @@ export const HELP_MANUAL = Object.freeze({
"Select Previous Read or Next read to move through the filtered Live Feed results in either direction. Navigation continues across result pages instead of wrapping. Left Arrow and Right Arrow provide the same review navigation from the keyboard.",
"The popup remembers the last Plate capture or Vehicle view selection in this browser, including after signing out and back in. Reads without a vehicle image temporarily show the plate capture without changing that saved preference.",
"Select Confirm and Next to confirm an unreviewed read and, only after confirmation succeeds, advance to the next unconfirmed read. Already confirmed reads are skipped, including across result pages; if no later unconfirmed read can be found, the viewer stops instead of wrapping. Plate corrections preserve the cursor or selection when editing in the middle of the value.",
+ "While the image popup is open, Live Feed pauses background refreshes so review buttons and images do not compete with a full feed reload. Confirmed or reopened state updates immediately; the feed refreshes after the popup closes. Adjacent result pages are prepared in the background, and a bounded browser diagnostic buffer records refresh, review, navigation, camera, and image-load timing for troubleshooting.",
"The two popup action rows use aligned fixed-width columns, so review status changes and unavailable optional actions do not move Next read or neighboring controls.",
"Use Add tag or Remove tag to change classification when your role permits tag management.",
"Use Confirm detected plate, Correct this read, Reject, Reopen, or Reverse review only after inspecting the original image.",
diff --git a/lib/live-feed-performance.mjs b/lib/live-feed-performance.mjs
new file mode 100644
index 00000000..ea34b4e3
--- /dev/null
+++ b/lib/live-feed-performance.mjs
@@ -0,0 +1,39 @@
+export const LIVE_FEED_PERFORMANCE_BUFFER = "__ALPR_LIVE_FEED_PERFORMANCE__";
+export const LIVE_FEED_PERFORMANCE_LIMIT = 100;
+
+function roundedDuration(value) {
+ const duration = Number(value);
+ return Number.isFinite(duration) && duration >= 0
+ ? Number(duration.toFixed(1))
+ : null;
+}
+
+export function recordLiveFeedPerformance(metric = {}, {
+ target = globalThis,
+ logger = null,
+ recordedAt = new Date().toISOString(),
+} = {}) {
+ const entry = {
+ ...metric,
+ durationMs: roundedDuration(metric.durationMs),
+ recordedAt,
+ };
+ const previous = Array.isArray(target?.[LIVE_FEED_PERFORMANCE_BUFFER])
+ ? target[LIVE_FEED_PERFORMANCE_BUFFER]
+ : [];
+ if (target) {
+ target[LIVE_FEED_PERFORMANCE_BUFFER] = [
+ ...previous,
+ entry,
+ ].slice(-LIVE_FEED_PERFORMANCE_LIMIT);
+ }
+ logger?.info?.("[ALPR live-feed performance]", entry);
+ return entry;
+}
+
+export function elapsedMilliseconds(startedAt, endedAt) {
+ const start = Number(startedAt);
+ const end = Number(endedAt);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
+ return Number((end - start).toFixed(1));
+}
diff --git a/lib/release-info.mjs b/lib/release-info.mjs
index 2986a12e..868ed28c 100644
--- a/lib/release-info.mjs
+++ b/lib/release-info.mjs
@@ -7,9 +7,10 @@ const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
export const CURRENT_RELEASE_NOTES = Object.freeze({
- title: "August 4, 2026 storage-maintenance release",
- publishedAt: "2026-08-04",
+ title: "August 7, 2026 Live Feed navigation-performance release",
+ publishedAt: "2026-08-07",
items: Object.freeze([
+ "Live Feed navigation now uses one popup-aware refresh controller, defers confirmation refreshes until review closes, prefetches adjacent pages, reuses the displayed image load for sizing, and retains bounded client timing diagnostics by camera.",
"Resolved five known dependency vulnerabilities by updating brace-expansion, ip-address, and postcss to fixed releases.",
"Live host-maintenance requests with automatic status updates, configurable manual image-retirement grace, separate application and worker-image protection, conservative Docker layer-store reclaim receipts, and a bounded read-only host-storage snapshot for Docker and verified backup totals.",
"Production-active, fail-closed, no-input manual database-backup control plane, gated on the reviewed database-backup-create-v1 worker capability, with automatic pending-status refresh and a manual fallback.",
diff --git a/test/help-manual.test.mjs b/test/help-manual.test.mjs
index aab8be7f..f77bb8cd 100644
--- a/test/help-manual.test.mjs
+++ b/test/help-manual.test.mjs
@@ -14,7 +14,7 @@ async function source(path) {
}
test("the user guide is structured, searchable, and role-aware", () => {
- assert.equal(HELP_MANUAL.manualVersion, "1.50");
+ assert.equal(HELP_MANUAL.manualVersion, "1.51");
assert.ok(HELP_MANUAL.sections.length >= 14);
const ids = HELP_MANUAL.sections.map((section) => section.id);
@@ -101,7 +101,7 @@ test("production releases require help and roadmap updates", async () => {
assert.match(text, /lib\/help-manual\.mjs/);
assert.match(text, /docs\/COMMUNITY_PRODUCT_ROADMAP\.md/);
}
- assert.match(roadmap, /Release baseline — August 4, 2026/);
+ assert.match(roadmap, /Release baseline — August 7, 2026/);
assert.doesNotMatch(roadmap, /current production release is `[0-9a-f]{7,40}`/i);
});
diff --git a/test/live-feed-performance.test.mjs b/test/live-feed-performance.test.mjs
new file mode 100644
index 00000000..213251a0
--- /dev/null
+++ b/test/live-feed-performance.test.mjs
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ elapsedMilliseconds,
+ LIVE_FEED_PERFORMANCE_BUFFER,
+ LIVE_FEED_PERFORMANCE_LIMIT,
+ recordLiveFeedPerformance,
+} from "../lib/live-feed-performance.mjs";
+
+test("live-feed performance metrics retain a bounded diagnostic buffer", () => {
+ const target = {};
+ const entries = [];
+ const logger = { info: (_label, entry) => entries.push(entry) };
+
+ for (let index = 0; index < LIVE_FEED_PERFORMANCE_LIMIT + 5; index += 1) {
+ recordLiveFeedPerformance(
+ { metric: "viewer_navigation", durationMs: index + 0.04, sequence: index },
+ { target, logger, recordedAt: "2026-08-07T00:00:00.000Z" }
+ );
+ }
+
+ assert.equal(target[LIVE_FEED_PERFORMANCE_BUFFER].length, LIVE_FEED_PERFORMANCE_LIMIT);
+ assert.equal(target[LIVE_FEED_PERFORMANCE_BUFFER][0].sequence, 5);
+ assert.equal(target[LIVE_FEED_PERFORMANCE_BUFFER].at(-1).durationMs, 104);
+ assert.equal(entries.length, LIVE_FEED_PERFORMANCE_LIMIT + 5);
+});
+
+test("elapsed live-feed timing rejects invalid clocks and rounds valid durations", () => {
+ assert.equal(elapsedMilliseconds(10, 13.456), 3.5);
+ assert.equal(elapsedMilliseconds(13, 10), null);
+ assert.equal(elapsedMilliseconds("invalid", 10), null);
+});
diff --git a/test/release-info.test.mjs b/test/release-info.test.mjs
index 426fc1a3..3c90bc17 100644
--- a/test/release-info.test.mjs
+++ b/test/release-info.test.mjs
@@ -23,14 +23,14 @@ test("release information resolves a commit-pinned deployment image", () => {
});
assert.equal(release.version, "0.1.15");
- assert.equal(release.manualVersion, "1.50");
- assert.equal(release.manualUpdatedAt, "August 4, 2026");
+ assert.equal(release.manualVersion, "1.51");
+ assert.equal(release.manualUpdatedAt, "August 7, 2026");
assert.equal(release.gitSha, "8cd2fa8");
assert.equal(release.channel, "staging");
assert.equal(release.source, "commit-pinned image");
assert.equal(release.readOnly, true);
- assert.equal(release.notes.title, "August 4, 2026 storage-maintenance release");
- assert.equal(release.notes.publishedAt, "2026-08-04");
+ assert.equal(release.notes.title, "August 7, 2026 Live Feed navigation-performance release");
+ assert.equal(release.notes.publishedAt, "2026-08-07");
assert.ok(release.notes.items.length >= 4);
});
diff --git a/test/ui-table-enhancements.test.mjs b/test/ui-table-enhancements.test.mjs
index 8f4e00e6..ebe30623 100644
--- a/test/ui-table-enhancements.test.mjs
+++ b/test/ui-table-enhancements.test.mjs
@@ -30,8 +30,9 @@ test("live feed plate identities open exact matching read history", async () =>
});
test("live feed image review advances visibly and starts focused on the plate", async () => {
- const [plateTable, imageViewer] = await Promise.all([
+ const [plateTable, plateTableWrapper, imageViewer] = await Promise.all([
source("components/PlateTable.jsx"),
+ source("components/PlateTableWrapper.jsx"),
source("components/ImageViewer.jsx"),
]);
@@ -57,14 +58,31 @@ test("live feed image review advances visibly and starts focused on the plate",
const operationCheck = plateTable.indexOf("isConfirmNextOperationCurrent", confirmationAwait);
assert.ok(operationStart >= 0 && operationStart < confirmationAwait && confirmationAwait < operationCheck);
assert.match(plateTable, /selectedReadId: selectedImageIdRef\.current/);
- assert.match(plateTable, /cancelConfirmNextFlow\(\);\s*selectedImageIdRef\.current = null;\s*setIsImageFullscreen/);
+ assert.match(plateTable, /cancelConfirmNextFlow\(\);\s*selectedImageIdRef\.current = null;[\s\S]*?setIsImageFullscreen/);
assert.match(plateTable, /findNextUnconfirmedReadIndex/);
assert.match(plateTable, /const nextRead = nextUnconfirmedIndex >= 0 \? data\[nextUnconfirmedIndex\] : null/);
assert.match(plateTable, /phase: "scan"[\s\S]*?onViewerPageChange\("next"\)/);
assert.match(plateTable, /resolveUnconfirmedPageTransition/);
assert.match(plateTable, /selectedImage\.validated !== true/);
assert.match(plateTable, /CONFIRM_NEXT_SCAN_TIMEOUT_MS = 15000/);
- assert.match(plateTable, /pendingUnconfirmedNavigation === null &&\s*confirmNextOperation === null/);
+ assert.doesNotMatch(plateTable, /setInterval\(\(\) => \{\s*router\.refresh\(\)/);
+ assert.match(plateTable, /checked=\{isLive\}[\s\S]*?onCheckedChange=\{onLiveChange\}/);
+ assert.match(plateTableWrapper, /LIVE_REFRESH_INTERVAL_MS = 5_000/);
+ assert.match(plateTableWrapper, /if \(!isLiveModeActive \|\| isViewerOpen\) return undefined/);
+ assert.match(plateTableWrapper, /requestLiveRefresh\("live_poll"\)/);
+ assert.match(plateTableWrapper, /refreshAfterViewerCloseRef\.current = true/);
+ assert.match(plateTableWrapper, /setReviewOverrides/);
+ assert.match(plateTableWrapper, /reviewStatusFilters\.includes/);
+ assert.match(plateTableWrapper, /dataRevision: serverDataRevision/);
+ assert.match(plateTable, /originDataRevision: pagination\.dataRevision/);
+ assert.match(plateTable, /onViewerDataRefresh\(\)/);
+ assert.doesNotMatch(
+ plateTableWrapper.slice(
+ plateTableWrapper.indexOf("const handleValidatePlate"),
+ plateTableWrapper.indexOf("const handlePreviewCorrection")
+ ),
+ /router\.refresh\(\)/
+ );
assert.match(plateTable, /useEffect\(\(\) => \(\) => \{[\s\S]*?activeConfirmNextOperationRef\.current = null;[\s\S]*?selectedImageIdRef\.current = null;[\s\S]*?\}, \[\]\)/);
assert.match(plateTable, /disabled=\{pendingViewerNavigation !== null \|\| confirmNextBusy\}/);
assert.match(plateTable, /Confirm and Next<\/span>/);
@@ -160,6 +178,11 @@ test("live feed image review advances visibly and starts focused on the plate",
assert.match(imageViewer, /const midpoint = \(1 \+ getSliderMax\(\)\) \/ 2/);
assert.match(imageViewer, /Math\.round\(midpoint \* 10\) \/ 10/);
assert.match(imageViewer, /new ResizeObserver\(updateContainerSize\)/);
+ assert.doesNotMatch(imageViewer, /const img = new Image\(\)/);
+ assert.match(imageViewer, /onLoad=\{handleImageLoad\}/);
+ assert.match(plateTable, /onImageLoad=\{handleViewerImageLoad\}/);
+ assert.match(plateTable, /metric: "viewer_navigation"/);
+ assert.match(plateTable, /metric: "review_action"/);
assert.match(imageViewer, /const fitScale = Math\.min\(/);
assert.match(imageViewer, /const focusX = offsetX \+/);
assert.match(imageViewer, /containerSize\.width \/ 2 - focusX \* zoom \+ pan\.x/);
@@ -321,7 +344,8 @@ test("live feed direction is visible, correctable, and filterable by semantic ca
assert.match(table, /ariaLabel="Filter by direction"/);
assert.match(table, / router\.refresh\(\), 10_000\)/);
+ assert.match(wrapper, /requestLiveRefresh\("live_poll"\)/);
+ assert.match(wrapper, /if \(!isLiveModeActive \|\| isViewerOpen\) return undefined/);
assert.match(table, /label="Direction"[\s\S]*?field="direction"/);
assert.match(table, /aria-label="Review vehicle direction"/);
assert.match(table, /className="h-4 w-4 shrink-0 p-0 text-muted-foreground hover:text-foreground"/);
diff --git a/test/vehicle-intelligence.test.mjs b/test/vehicle-intelligence.test.mjs
index 88a793c0..f7b0f46d 100644
--- a/test/vehicle-intelligence.test.mjs
+++ b/test/vehicle-intelligence.test.mjs
@@ -142,7 +142,9 @@ test("live-feed refreshes do not reset a user's vehicle zoom", async () => {
assert.match(viewer, /const initializedViewRef = useRef\(null\)/);
assert.match(viewer, /initializedViewRef\.current === viewResetKey/);
assert.match(viewer, /initializedViewRef\.current = viewResetKey;[\s\S]*?setZoom\(clampZoom\(initialZoom\)\)/);
- assert.match(viewer, /setImageSize\(\{ url: image\.url, width: img\.width, height: img\.height \}\)/);
+ assert.match(viewer, /const width = Number\(element\.naturalWidth \|\| element\.width\)/);
+ assert.match(viewer, /setImageSize\(\{ url: image\.url, width, height \}\)/);
+ assert.doesNotMatch(viewer, /const img = new Image\(\)/);
});
test("local vehicle type inference preserves confidence and model provenance", async () => {