diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 5b7e9f0b39..83061ee899 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "../fixtures/frigate-test"; +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; import { expectBodyInteractive, waitForBodyInteractive, @@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => { await expect(dialog.getByText(/None/)).toBeVisible(); }); - test("starting an export posts the expected payload and navigates to the case", async ({ + test("starting an export posts the expected payload and stays on the review page", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Desktop multi-select flow"); @@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => { "mex-review-002", ]); - await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, { - timeout: 5_000, - }); + // Creating a case must not pull the user off the review they were + // working through — the case is offered as a link on the toast instead. + const viewCase = frigateApp.page.getByRole("link", { name: /view/i }); + await expect(viewCase).toBeVisible({ timeout: 5_000 }); + await expect(viewCase).toHaveAttribute( + "href", + /export\?caseId=new-case-xyz$/, + ); + await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/); }); test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({ @@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => { expect(payload.new_case_description).toBeUndefined(); expect(payload.items).toHaveLength(2); - // Navigate should hit /export. useSearchEffect consumes the caseId - // query param and strips it once the case is found in the cases list, - // so we assert on the path, not the query string. - await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, { - timeout: 5_000, - }); + // Attaching to a case leaves the user on the review page; the case is + // reachable from the toast action. + const viewCase = frigateApp.page.getByRole("link", { name: /view/i }); + await expect(viewCase).toBeVisible({ timeout: 5_000 }); + await expect(viewCase).toHaveAttribute( + "href", + /export\?caseId=existing-case-abc$/, + ); + await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/); + }); +}); + +test.describe("Multi-Camera Export from History @high", () => { + // The recording view seeds the multi-camera range around the playback + // position, so the deep link has to land close to the live edge for the + // seeded end to run past the end of the timeline. + const playbackTime = Math.floor(Date.now() / 1000) - 300; + + async function openRecordingView(frigateApp: FrigateApp) { + // The recording view pulls these while the timeline renders; the preview + // server 500s on them, which the error collector would flag. + await frigateApp.page.route("**/api/*/recordings**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/recordings/unavailable**", (route) => + route.fulfill({ json: [] }), + ); + + await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`); + } + + // Desktop opens the export form in a dialog from the Actions menu; mobile + // opens the same form inside the settings drawer. + async function openMultiCameraTab(frigateApp: FrigateApp) { + await openRecordingView(frigateApp); + + if (frigateApp.isMobile) { + await frigateApp.page + .getByRole("button", { name: /filters/i }) + .first() + .click({ timeout: 15_000 }); + await frigateApp.page.getByRole("button", { name: /^export$/i }).click(); + } else { + await frigateApp.page + .getByRole("button", { name: /actions/i }) + .click({ timeout: 15_000 }); + await frigateApp.page.getByRole("menuitem", { name: /export/i }).click(); + } + + const form = frigateApp.page.getByRole("dialog"); + await expect(form).toBeVisible({ timeout: 5_000 }); + await form.getByRole("tab", { name: /multi-camera/i }).click(); + + return form; + } + + test("timeline selection renders both export handles on the timeline", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults(); + const form = await openMultiCameraTab(frigateApp); + + await form + .getByRole("button", { name: "Select from Timeline" }) + .click({ timeout: 5_000 }); + await expect(form).toBeHidden({ timeout: 5_000 }); + + // A range seeded past the end of the timeline has no segment to anchor + // to, which leaves the handle unpositioned at the top of the timeline + // with an empty label until it is dragged. + for (const handle of [".export-start", ".export-end"]) { + const locator = frigateApp.page.locator(handle); + await expect(locator).toHaveText(/\d{1,2}:\d{2}/, { timeout: 5_000 }); + await expect(locator).not.toHaveAttribute("style", /top:\s*0px/); + } + }); + + test("the time range picker opens without a configured timezone", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults(); + const form = await openMultiCameraTab(frigateApp); + + // ui.timezone is null until the user sets one, which used to take the + // whole page down when the calendar worked out its disabled days + await form + .getByRole("button", { name: /^start time$/i }) + .click({ timeout: 5_000 }); + + await expect( + frigateApp.page.getByRole("button", { name: /previous month/i }), + ).toBeVisible({ timeout: 5_000 }); + }); + + test("canceling timeline selection reopens the form with the case intact", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults(); + const form = await openMultiCameraTab(frigateApp); + + await form + .getByPlaceholder(/new case name/i) + .fill("Incident 7", { timeout: 5_000 }); + await form + .getByPlaceholder(/case description/i) + .fill("Front gate follow-up"); + + await form.getByRole("button", { name: "Select from Timeline" }).click(); + await expect(form).toBeHidden({ timeout: 5_000 }); + + await frigateApp.page.getByRole("button", { name: /cancel/i }).click(); + + await expect(form).toBeVisible({ timeout: 5_000 }); + await expect( + form.getByRole("tab", { name: /multi-camera/i }), + ).toHaveAttribute("aria-selected", "true"); + await expect(form.getByPlaceholder(/new case name/i)).toHaveValue( + "Incident 7", + ); + await expect(form.getByPlaceholder(/case description/i)).toHaveValue( + "Front gate follow-up", + ); }); }); diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index dcb4a23db4..904f2f4391 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -100,10 +100,8 @@ "exportButton_other": "Export {{count}} reviews", "exportingButton": "Exporting...", "toast": { - "started_one": "Started 1 export. Opening the case now.", - "started_other": "Started {{count}} exports. Opening the case now.", - "startedNoCase_one": "Started 1 export.", - "startedNoCase_other": "Started {{count}} exports.", + "started_one": "Started 1 export.", + "started_other": "Started {{count}} exports.", "partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}", "failed": "Failed to start {{total}} exports. Failed: {{failedItems}}" } @@ -116,8 +114,8 @@ "batchSuccess_other": "Started {{count}} exports. Opening the case now.", "batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", "batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}", - "batchQueuedSuccess_one": "Queued 1 export. Opening the case now.", - "batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.", + "batchQueuedSuccess_one": "Queued 1 export.", + "batchQueuedSuccess_other": "Queued {{count}} exports.", "batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", "batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}", "error": { diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 5b7c4414fa..c5c1a1c5b0 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -258,6 +258,7 @@ export default function ReviewFilterGroup({ // not applicable as exports are not used camera="" latestTime={0} + earliestTime={0} currentTime={0} mode="none" setMode={() => {}} diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 1add0b09ba..a067537a37 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -39,6 +39,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; import { Command, CommandGroup, @@ -62,7 +63,6 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Textarea } from "../ui/textarea"; -import { useNavigate } from "react-router-dom"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { isReplayCamera } from "@/utils/cameraUtil"; import { isValidIconName } from "@/utils/iconUtil"; @@ -79,9 +79,14 @@ const EXPORT_OPTIONS = [ type ExportOption = (typeof EXPORT_OPTIONS)[number]; export type ExportTab = "export" | "multi"; +// length of a range seeded around the current playback time +const MULTI_CAMERA_RANGE_SECONDS = 3600; +const TIMELINE_SELECTION_SECONDS = 60; + type ExportDialogProps = { camera: string; latestTime: number; + earliestTime: number; currentTime: number; range?: TimeRange; mode: ExportMode; @@ -94,6 +99,7 @@ type ExportDialogProps = { export default function ExportDialog({ camera, latestTime, + earliestTime, currentTime, range, mode, @@ -107,9 +113,13 @@ export default function ExportDialog({ const [selectedCaseId, setSelectedCaseId] = useState(); const [singleNewCaseName, setSingleNewCaseName] = useState(""); const [singleNewCaseDescription, setSingleNewCaseDescription] = useState(""); + const [batchCaseSelection, setBatchCaseSelection] = useState("new"); + const [newCaseName, setNewCaseName] = useState(""); + const [newCaseDescription, setNewCaseDescription] = useState(""); const [activeTab, setActiveTab] = useState("export"); const [isStartingExport, setIsStartingExport] = useState(false); const previousModeRef = useRef(mode); + const preTimelineRangeRef = useRef(undefined); useEffect(() => { const previousMode = previousModeRef.current; @@ -188,6 +198,9 @@ export default function ExportDialog({ setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); + setBatchCaseSelection("new"); + setNewCaseName(""); + setNewCaseDescription(""); setRange(undefined); setMode("none"); return true; @@ -223,14 +236,32 @@ export default function ExportDialog({ ]); const handleCancel = useCallback(() => { + if (mode == "timeline_multi") { + setRange(preTimelineRangeRef.current); + setMode("select"); + return; + } + setName(""); setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); + setBatchCaseSelection("new"); + setNewCaseName(""); + setNewCaseDescription(""); setMode("none"); setRange(undefined); setActiveTab("export"); - }, [setMode, setRange]); + }, [mode, setMode, setRange]); + + const onSelectFromTimeline = useCallback( + (initialRange: TimeRange) => { + preTimelineRangeRef.current = range; + setRange(initialRange); + setMode("timeline_multi"); + }, + [range, setMode, setRange], + ); const Overlay = isDesktop ? Dialog : Drawer; const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; @@ -304,12 +335,16 @@ export default function ExportDialog({ > @@ -330,12 +369,16 @@ export default function ExportDialog({ type ExportContentProps = { latestTime: number; + earliestTime: number; currentTime: number; range?: TimeRange; name: string; selectedCaseId?: string; singleNewCaseName: string; singleNewCaseDescription: string; + batchCaseSelection: string; + newCaseName: string; + newCaseDescription: string; activeTab: ExportTab; isStartingExport: boolean; onStartExport: () => Promise; @@ -344,19 +387,27 @@ type ExportContentProps = { setSelectedCaseId: (caseId: string | undefined) => void; setSingleNewCaseName: (name: string) => void; setSingleNewCaseDescription: (description: string) => void; + setBatchCaseSelection: (caseId: string) => void; + setNewCaseName: (name: string) => void; + setNewCaseDescription: (description: string) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; + onSelectFromTimeline: (range: TimeRange) => void; onCancel: () => void; }; export function ExportContent({ latestTime, + earliestTime, currentTime, range, name, selectedCaseId, singleNewCaseName, singleNewCaseDescription, + batchCaseSelection, + newCaseName, + newCaseDescription, activeTab, isStartingExport, onStartExport, @@ -365,12 +416,15 @@ export function ExportContent({ setSelectedCaseId, setSingleNewCaseName, setSingleNewCaseDescription, + setBatchCaseSelection, + setNewCaseName, + setNewCaseDescription, setRange, setMode, + onSelectFromTimeline, onCancel, }: ExportContentProps) { const { t } = useTranslation(["components/dialog"]); - const navigate = useNavigate(); const isAdmin = useIsAdmin(); const [selectedOption, setSelectedOption] = useState("1"); const { data: cases } = useSWR(isAdmin ? "cases" : null); @@ -379,13 +433,8 @@ export function ExportContent({ range, ); const [selectedCameraIds, setSelectedCameraIds] = useState([]); - const [batchCaseSelection, setBatchCaseSelection] = useState( - selectedCaseId || "none", - ); const [hasManualCameraSelection, setHasManualCameraSelection] = useState(false); - const [newCaseName, setNewCaseName] = useState(""); - const [newCaseDescription, setNewCaseDescription] = useState(""); const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); const [cameraSearch, setCameraSearch] = useState(""); const [cameraMenuOpen, setCameraMenuOpen] = useState(false); @@ -416,38 +465,47 @@ export function ExportContent({ return () => window.clearTimeout(timeoutId); }, [activeTab, range]); - useEffect(() => { - if (activeTab !== "multi") { - return; - } - - if (selectedCaseId) { - setBatchCaseSelection(selectedCaseId); - return; - } - - if ((cases?.length ?? 0) === 0) { - setBatchCaseSelection("new"); - return; - } - - setBatchCaseSelection("new"); - }, [activeTab, cases?.length, selectedCaseId]); - useEffect(() => { setHasManualCameraSelection(false); }, [multiRangeKey]); + const buildRangeAroundCurrentTime = useCallback( + (durationSeconds: number): TimeRange => ({ + after: Math.max(earliestTime, currentTime - durationSeconds / 2), + before: Math.min(latestTime, currentTime + durationSeconds / 2), + }), + [currentTime, earliestTime, latestTime], + ); + + const clampRangeToTimeline = useCallback( + (candidate?: TimeRange): TimeRange => { + const fallback = buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS); + + if (!candidate) { + return fallback; + } + + const after = Math.min( + latestTime, + Math.max(earliestTime, candidate.after), + ); + const before = Math.min( + latestTime, + Math.max(earliestTime, candidate.before), + ); + + return before > after ? { after, before } : fallback; + }, + [buildRangeAroundCurrentTime, earliestTime, latestTime], + ); + useEffect(() => { if (activeTab !== "multi" || range) { return; } - setRange({ - before: currentTime + 1800, - after: currentTime - 1800, - }); - }, [activeTab, currentTime, range, setRange]); + setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS)); + }, [activeTab, buildRangeAroundCurrentTime, range, setRange]); const { data: events, isLoading: isEventsLoading } = useSWR( activeTab === "multi" && debouncedRange @@ -715,6 +773,16 @@ export function ExportContent({ return result.error ? `${cameraName}: ${result.error}` : cameraName; }) .join(", "); + const exportCaseId = response.data.export_case_id; + const viewCaseAction = exportCaseId ? ( + + + + ) : undefined; if (failedResults.length > 0 && successfulResults.length > 0) { toast.success( @@ -728,6 +796,7 @@ export function ExportContent({ { position: "top-center", description: failedSummary, + action: viewCaseAction, }, ); } else if (failedResults.length > 0) { @@ -748,7 +817,7 @@ export function ExportContent({ t("export.toast.batchQueuedSuccess", { count: successfulResults.length, }), - { position: "top-center" }, + { position: "top-center", action: viewCaseAction }, ); } @@ -761,9 +830,6 @@ export function ExportContent({ setRange(undefined); setMode("none"); setActiveTab("export"); - if (response.data.export_case_id) { - navigate(`/export?caseId=${response.data.export_case_id}`); - } } } catch (error) { const apiError = error as { @@ -794,12 +860,14 @@ export function ExportContent({ range, selectedCameraIds, setActiveTab, + setBatchCaseSelection, setMode, setName, + setNewCaseDescription, + setNewCaseName, setRange, setSelectedCaseId, t, - navigate, ]); return ( @@ -820,10 +888,8 @@ export function ExportContent({ onValueChange={(value) => { const tab = value as ExportTab; if (tab === "multi") { - setRange({ - before: currentTime + 1800, - after: currentTime - 1800, - }); + setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS)); + setBatchCaseSelection(selectedCaseId ?? "new"); } else { onSelectTime(selectedOption); } @@ -975,23 +1041,18 @@ export function ExportContent({ className="size-9 shrink-0 p-0" aria-label={t("export.multiCamera.selectFromTimeline")} onClick={() => { - if (!range) { - setRange({ - before: currentTime + 30, - after: currentTime - 30, - }); - } - setActiveTab("multi"); - setMode("timeline_multi"); + onSelectFromTimeline(clampRangeToTimeline(range)); }} > - - {t("export.multiCamera.selectFromTimeline")} - + + + {t("export.multiCamera.selectFromTimeline")} + + @@ -1256,7 +1317,9 @@ export function ExportContent({ disabled={isStartingExport} onClick={async () => { if (selectedOption == "timeline") { - setRange({ before: currentTime + 30, after: currentTime - 30 }); + setRange( + buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS), + ); setMode("timeline"); } else { const didQueue = await onStartExport(); diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 2dbefabefd..47a5236618 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { baseUrl } from "@/api/baseUrl"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Button } from "../ui/button"; @@ -65,6 +65,7 @@ type MobileReviewSettingsDrawerProps = { filter?: ReviewFilter; currentSeverity?: ReviewSeverity; latestTime: number; + earliestTime: number; currentTime: number; range?: TimeRange; mode: ExportMode; @@ -90,6 +91,7 @@ export default function MobileReviewSettingsDrawer({ filter, currentSeverity, latestTime, + earliestTime, currentTime, range, mode, @@ -142,7 +144,22 @@ export default function MobileReviewSettingsDrawer({ ); const [singleNewCaseName, setSingleNewCaseName] = useState(""); const [singleNewCaseDescription, setSingleNewCaseDescription] = useState(""); + const [batchCaseSelection, setBatchCaseSelection] = useState("new"); + const [newCaseName, setNewCaseName] = useState(""); + const [newCaseDescription, setNewCaseDescription] = useState(""); const [isStartingExport, setIsStartingExport] = useState(false); + const preTimelineRangeRef = useRef(undefined); + + const onSelectFromTimeline = useCallback( + (initialRange: TimeRange) => { + preTimelineRangeRef.current = range; + setRange(initialRange); + setMode("timeline_multi"); + setDrawerMode("none"); + }, + [range, setMode, setRange], + ); + const onStartExport = useCallback(async () => { if (isStartingExport) { return false; @@ -214,6 +231,9 @@ export default function MobileReviewSettingsDrawer({ setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); + setBatchCaseSelection("new"); + setNewCaseName(""); + setNewCaseDescription(""); setRange(undefined); setMode("none"); return true; @@ -433,12 +453,16 @@ export default function MobileReviewSettingsDrawer({ content = ( { setMode(mode); @@ -455,12 +482,16 @@ export default function MobileReviewSettingsDrawer({ setDrawerMode("none"); } }} + onSelectFromTimeline={onSelectFromTimeline} onCancel={() => { setMode("none"); setRange(undefined); setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); + setBatchCaseSelection("new"); + setNewCaseName(""); + setNewCaseDescription(""); setExportTab("export"); setDrawerMode("select"); }} @@ -639,6 +670,14 @@ export default function MobileReviewSettingsDrawer({ void onStartExport(); }} onCancel={() => { + if (mode == "timeline_multi") { + setRange(preTimelineRangeRef.current); + setExportTab("multi"); + setMode("select"); + setDrawerMode("export"); + return; + } + setExportTab("export"); setRange(undefined); setMode("none"); diff --git a/web/src/components/overlay/MultiExportDialog.tsx b/web/src/components/overlay/MultiExportDialog.tsx index b4c92111c8..4b89e90c69 100644 --- a/web/src/components/overlay/MultiExportDialog.tsx +++ b/web/src/components/overlay/MultiExportDialog.tsx @@ -3,7 +3,6 @@ import { isDesktop } from "react-device-detect"; import axios from "axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import useSWR from "swr"; import { @@ -43,6 +42,7 @@ import { ExportCase, } from "@/types/export"; import { FrigateConfig } from "@/types/frigateConfig"; +import { baseUrl } from "@/api/baseUrl"; import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { useDateLocale } from "@/hooks/use-date-locale"; @@ -65,7 +65,6 @@ export default function MultiExportDialog({ }: MultiExportDialogProps) { const { t } = useTranslation(["components/dialog", "common"]); const locale = useDateLocale(); - const navigate = useNavigate(); const isAdmin = useIsAdmin(); const { data: config } = useSWR("config"); @@ -203,19 +202,24 @@ export default function MultiExportDialog({ const results = response.data.results ?? []; const successful = results.filter((r) => r.success); const failed = results.filter((r) => !r.success); + const exportCaseId = response.data.export_case_id; + const viewCaseAction = exportCaseId ? ( + + + + ) : undefined; if (successful.length > 0 && failed.length === 0) { toast.success( - t( - isAdmin - ? "export.multi.toast.started" - : "export.multi.toast.startedNoCase", - { - ns: "components/dialog", - count: successful.length, - }, - ), - { position: "top-center" }, + t("export.multi.toast.started", { + ns: "components/dialog", + count: successful.length, + }), + { position: "top-center", action: viewCaseAction }, ); } else if (successful.length > 0 && failed.length > 0) { // Resolve each failure to its review via item_index so same-camera @@ -229,7 +233,7 @@ export default function MultiExportDialog({ total: results.length, failedItems: failedLabels, }), - { position: "top-center" }, + { position: "top-center", action: viewCaseAction }, ); } else { const failedLabels = failed.map(formatFailureLabel).join(", "); @@ -247,9 +251,6 @@ export default function MultiExportDialog({ onStarted(); setOpen(false); resetState(); - if (response.data.export_case_id) { - navigate(`/export?caseId=${response.data.export_case_id}`); - } } } catch (error) { const apiError = error as { @@ -275,7 +276,6 @@ export default function MultiExportDialog({ formatFailureLabel, isAdmin, isNewCase, - navigate, newCaseDescription, newCaseName, onStarted, diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index cc541214c2..53f31fcf4e 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -318,12 +318,12 @@ export const formatSecondsToDuration = ( * @param timezone string representation of the timezone the user is requesting * @returns number of minutes offset from UTC */ -export const getUTCOffset = ( - date: Date, - timezone: string = getResolvedTimeZone(), -): number => { +export const getUTCOffset = (date: Date, timezone?: string | null): number => { + // ui.timezone comes back as null until the user sets one + const resolvedTimezone = timezone || getResolvedTimeZone(); + // If timezone is in UTC±HH:MM format, parse it to get offset - const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/); + const utcOffsetMatch = resolvedTimezone.match(/^UTC([+-])(\d{2}):(\d{2})$/); if (utcOffsetMatch) { const hours = parseInt(utcOffsetMatch[2], 10); const minutes = parseInt(utcOffsetMatch[3], 10); @@ -334,7 +334,7 @@ export const getUTCOffset = ( const utcDate = new Date(date.getTime()); // locale of en-CA is required for proper locale format let iso = utcDate - .toLocaleString("en-CA", { timeZone: timezone, hour12: false }) + .toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false }) .replace(", ", "T"); iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`; let target = new Date(`${iso}Z`); diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index df0d525e9e..5b3b9283e0 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -1356,6 +1356,7 @@ export default function MotionSearchView({ camera={selectedCamera} currentTime={currentTime} latestTime={timeRange.before} + earliestTime={timeRange.after} mode={exportMode} range={exportRange} showPreview={showExportPreview} @@ -1476,6 +1477,7 @@ export default function MotionSearchView({ camera={selectedCamera} currentTime={currentTime} latestTime={timeRange.before} + earliestTime={timeRange.after} mode={exportMode} range={exportRange} showPreview={showExportPreview} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index f51bc6a3c0..1595e315a9 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -677,6 +677,7 @@ export function RecordingView({ camera={mainCamera} currentTime={currentTime} latestTime={timeRange.before} + earliestTime={timeRange.after} mode={exportMode} range={exportRange} showPreview={showExportPreview} @@ -810,6 +811,7 @@ export function RecordingView({ filter={filter} currentTime={currentTime} latestTime={timeRange.before} + earliestTime={timeRange.after} recordingsSummary={recordingsSummary} mode={exportMode} range={exportRange}