diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index df50d913..4b7e4a86 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -68,6 +68,8 @@ const isSyncingActiveWash = ref(false); const MIN_FINISHING_SCREEN_MS = 250; const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1"; const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000; +const STOPPING_WASH_KEY = "mywash_stopping_v1"; +const STOPPING_WASH_TTL_MS = 2 * 60 * 1000; const ACTIVE_WASH_REFRESH_MS = 5 * 1000; const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000; const SELF_SERVE_FETCH_DEBOUNCE_MS = 300; @@ -127,6 +129,98 @@ const getAuthenticatedCustomerNumber = () => const resolveEffectiveCustomerNumber = (candidate: any = null) => getAuthenticatedCustomerNumber() ?? normalizePositiveInteger(candidate); +const normalizeMarkerReg = (value: any) => + String(value || "") + .trim() + .toUpperCase(); + +const clearStoppingWashMarker = () => { + try { + localStorage.removeItem(STOPPING_WASH_KEY); + } catch {} +}; + +const readStoppingWashMarker = () => { + try { + const raw = localStorage.getItem(STOPPING_WASH_KEY); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw); + const startedAt = Number(parsed?.startedAt || 0); + if (!startedAt || Date.now() - startedAt > STOPPING_WASH_TTL_MS) { + clearStoppingWashMarker(); + return null; + } + + return parsed; + } catch { + clearStoppingWashMarker(); + return null; + } +}; + +const markStoppingWash = (laneId: number | string | null) => { + const normalizedLaneId = normalizePositiveInteger(laneId); + if (!normalizedLaneId) { + return; + } + + const payload = { + laneId: normalizedLaneId, + reg: normalizeMarkerReg(licensePlateInput.value), + customerNumber: resolveEffectiveCustomerNumber(customerNumberInput.value), + startedAt: Date.now(), + }; + + try { + localStorage.setItem(STOPPING_WASH_KEY, JSON.stringify(payload)); + } catch {} +}; + +const doesStoppingWashMarkerMatch = ( + marker: any, + candidate: { laneId?: any; reg?: any; customerNumber?: any } | null +) => { + if (!marker || !candidate) { + return false; + } + + const markerLaneId = normalizePositiveInteger(marker?.laneId); + const candidateLaneId = normalizePositiveInteger(candidate?.laneId); + if (!markerLaneId || !candidateLaneId || markerLaneId !== candidateLaneId) { + return false; + } + + const markerReg = normalizeMarkerReg(marker?.reg); + const candidateReg = normalizeMarkerReg(candidate?.reg); + if (markerReg && candidateReg && markerReg !== candidateReg) { + return false; + } + + const markerCustomerNumber = normalizePositiveInteger(marker?.customerNumber); + const candidateCustomerNumber = normalizePositiveInteger(candidate?.customerNumber); + return !markerCustomerNumber || !candidateCustomerNumber || markerCustomerNumber === candidateCustomerNumber; +}; + +const restoreStoppingWashIfMatched = ( + candidate: { laneId?: any; reg?: any; customerNumber?: any } | null +) => { + const marker = readStoppingWashMarker(); + if (!marker) { + return false; + } + + if (!doesStoppingWashMarkerMatch(marker, candidate)) { + return false; + } + + isCompletingWash.value = true; + currentStep.value = steps.WASH_IN_PROGRESS; + return true; +}; + const applyEffectiveCustomerNumberInput = (candidate: any = null) => { const effectiveCustomerNumber = resolveEffectiveCustomerNumber(candidate); customerNumberInput.value = effectiveCustomerNumber ? String(effectiveCustomerNumber) : ""; @@ -545,6 +639,7 @@ const resetGuidedWashStep = () => { }; const finalizeGuidedWashCompletion = (laneId: number | string | null) => { + clearStoppingWashMarker(); markRecentlyCompletedWash(laneId, licensePlateInput.value, getNumericCustomerNumber()); shouldRestoreServerActiveWash.value = false; hasCompletedGuidedWash.value = true; @@ -578,6 +673,7 @@ const completeGuidedWash = async () => { const completingLaneId = washLaneId.value; washActionError.value = null; + markStoppingWash(completingLaneId); isCompletingWash.value = true; await nextTick(); @@ -594,6 +690,8 @@ const completeGuidedWash = async () => { } catch (error) { console.error("Error opening property exit gate during wash completion:", error); } + } else { + clearStoppingWashMarker(); } return; } @@ -1345,6 +1443,7 @@ const applyServerActiveWash = async (activeWash: any) => { const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg ?? licensePlateInput.value); const sessionStatus = normalizeServerSessionStatus(session?.status ?? details?.status); const shouldRestoreTaskStep = SERVER_TASK_RESTORE_STATUSES.has(sessionStatus); + const activeWashCustomerNumber = session?.customer_number ?? details?.customer?.customer_number; if (!laneId || !reg) { return false; @@ -1376,6 +1475,11 @@ const applyServerActiveWash = async (activeWash: any) => { vehicleStepError.value = null; hasCompletedGuidedWash.value = false; currentStep.value = shouldRestoreTaskStep ? steps.TASKS : steps.WASH_IN_PROGRESS; + restoreStoppingWashIfMatched({ + laneId, + reg, + customerNumber: activeWashCustomerNumber, + }); startElapsedTimer(); @@ -1466,6 +1570,7 @@ const scheduleServerActiveWashRestore = (delayMs = 0) => { const clearLocalActiveWashFromServer = (options: { preserveActionError?: boolean } = {}) => { const completedLaneId = washLaneId.value; completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : completedDurationMs.value ?? 0; + clearStoppingWashMarker(); markRecentlyCompletedWash(completedLaneId, licensePlateInput.value, getNumericCustomerNumber()); if (!options.preserveActionError) { washActionError.value = null; @@ -1835,11 +1940,13 @@ const onCloseCompleted = () => { washActionError.value = null; answerSyncError.value = null; hasStartFormUserInput.value = false; + isCompletingWash.value = false; hasCompletedGuidedWash.value = false; hasRecentlyCompletedWash.value = false; try { localStorage.removeItem(RECENT_COMPLETED_WASH_KEY); } catch {} + clearStoppingWashMarker(); markQuestionReviewRequired(); resetGuidedWashStep(); currentStep.value = steps.VEHICLE; @@ -1865,6 +1972,13 @@ onMounted(async () => { await fetchCustomerVehicles(); await fetchVehicleTypes(); const restoredProgress = restoreProgress(); + if (restoredProgress?.washInProgress) { + restoreStoppingWashIfMatched({ + laneId: restoredProgress.washLaneId, + reg: restoredProgress.licensePlateInput, + customerNumber: restoredProgress.customerNumberInput, + }); + } shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress; scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0); }); diff --git a/tests/e2e/i18n.views.spec.ts b/tests/e2e/i18n.views.spec.ts index 1905044b..94f6967e 100644 --- a/tests/e2e/i18n.views.spec.ts +++ b/tests/e2e/i18n.views.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from "@playwright/test"; import { scanViewTranslationKeys, type ViewTranslationKeyUsage } from "./support/viewI18nKeyScanner"; const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const; -const LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "locales"); +const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "generated"); const REVIEWED_NON_LITERAL_CALLS = new Set([ "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue|t|const translated = t(key, params);", 'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(`replication.status.${status || "unknown"}`);', @@ -26,12 +26,43 @@ const REVIEWED_NON_LITERAL_CALLS = new Set([ "src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|{{ $t(confirmActionLabelKey) }}", ]); -const readLocale = (locale: (typeof ACTIVE_LOCALES)[number]) => { - const absolutePath = path.join(LOCALES_DIRECTORY, `${locale}.json`); +const readGeneratedJson = (fileName: string) => { + const absolutePath = path.join(GENERATED_LOCALES_DIRECTORY, fileName); const content = fs.readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, ""); return JSON.parse(content) as Record; }; +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const mergeMessages = (...sources: Array | undefined>) => { + const merged: Record = {}; + + for (const source of sources) { + if (!source) { + continue; + } + + for (const [key, value] of Object.entries(source)) { + if (isPlainObject(value) && isPlainObject(merged[key])) { + merged[key] = mergeMessages(merged[key], value); + } else { + merged[key] = value; + } + } + } + + return merged; +}; + +const globalMessages = readGeneratedJson("global-v2.json") as { + shared?: Record; + locales?: Partial>>; +}; + +const readLocale = (locale: (typeof ACTIVE_LOCALES)[number]) => + mergeMessages(globalMessages.shared, globalMessages.locales?.[locale], readGeneratedJson(`${locale}-v2.json`)); + const hasKeyPath = (value: unknown, keyPath: string): boolean => { let current: unknown = value; diff --git a/tests/unit/my-wash-start-production.spec.js b/tests/unit/my-wash-start-production.spec.js index 7aff2839..ef475bf7 100644 --- a/tests/unit/my-wash-start-production.spec.js +++ b/tests/unit/my-wash-start-production.spec.js @@ -65,6 +65,21 @@ describe("MyWashStart.vue production recovery contracts", () => { expect(source).toContain("finalizeGuidedWashCompletion(completingLaneId);"); }); + it("persists and restores the stopping wash view across refreshes", () => { + expect(source).toContain('const STOPPING_WASH_KEY = "mywash_stopping_v1";'); + expect(source).toContain("const STOPPING_WASH_TTL_MS = 2 * 60 * 1000;"); + expect(source).toContain("const markStoppingWash = (laneId: number | string | null) =>"); + expect(source).toContain("markStoppingWash(completingLaneId);"); + expect(source).toContain("const stopSucceeded = await onStopWash(completingLaneId);"); + expect(source.indexOf("markStoppingWash(completingLaneId);")).toBeLessThan( + source.indexOf("const stopSucceeded = await onStopWash(completingLaneId);") + ); + expect(source).toContain("const restoreStoppingWashIfMatched = ("); + expect(source).toContain("if (restoredProgress?.washInProgress) {"); + expect(source).toContain("restoreStoppingWashIfMatched({"); + expect(source).toContain("clearStoppingWashMarker();"); + }); + it("keeps active and completed washes visible even if department availability changes", () => { expect(source).toContain("const shouldShowWashFlow = computed("); expect(source).toContain("const isCompletedWashState = computed("); diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index 193ade0c..dfe3e2b3 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -86,6 +86,8 @@ const mocks = vi.hoisted(() => { }; }); +const stoppingWashStorageKey = "mywash_stopping_v1"; + vi.mock("@/components/session/token/SessionUser.vue", async () => { const { ref } = await import("vue"); mocks.sessionCustomerNumber = ref(12345679); @@ -1815,6 +1817,11 @@ describe("MyWashStart", () => { await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click"); await nextTick(); + expect(JSON.parse(localStorage.getItem(stoppingWashStorageKey))).toMatchObject({ + laneId: 7, + reg: "AB12345", + customerNumber: 12345679, + }); expect(wrapper.get('[data-testid="self-serve-finishing-wash"]').text()).toContain( "Afslutter vask, porten åbnes automatisk" ); @@ -1826,6 +1833,99 @@ describe("MyWashStart", () => { await flushPromises(); expect(mocks.openPropertyExitGate).toHaveBeenCalledWith(7, { suppressAlert: true }); + expect(localStorage.getItem(stoppingWashStorageKey)).toBeNull(); + }); + + it("restores the finishing screen after refresh when a stopping marker matches active progress", async () => { + mocks.restoredProgressPayload = { + washInProgress: true, + washLaneId: 7, + washStartTime: Date.now() - 20_000, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioWashType: "Manual", + radioLaneOption: 7, + customerNumberInput: 12345679, + isForcingNearestDepartment: false, + forceNearestDepartmentEvaluationId: 0, + answers: {}, + completedTasks: {}, + currentStep: 4, + }; + localStorage.setItem( + stoppingWashStorageKey, + JSON.stringify({ + laneId: 7, + reg: "AB12345", + customerNumber: 12345679, + startedAt: Date.now(), + }) + ); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + messages: { + en: { + self_wash: { + finishing_wash_exit_opening: "Afslutter vask, porten åbnes automatisk", + }, + user_dashboard: { + wash: { + title: "Wash", + subtitle: "Wash", + }, + }, + }, + }, + }); + + await flushPromises(); + + expect(wrapper.get('[data-testid="self-serve-finishing-wash"]').text()).toContain( + "Afslutter vask, porten åbnes automatisk" + ); + expect(wrapper.find('[data-testid="self-serve-bottom-actions"]').exists()).toBe(false); + expect(localStorage.getItem(stoppingWashStorageKey)).not.toBeNull(); + }); + + it("ignores and clears an expired stopping marker during active progress restore", async () => { + mocks.restoredProgressPayload = { + washInProgress: true, + washLaneId: 7, + washStartTime: Date.now() - 20_000, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioWashType: "Manual", + radioLaneOption: 7, + customerNumberInput: 12345679, + isForcingNearestDepartment: false, + forceNearestDepartmentEvaluationId: 0, + answers: {}, + completedTasks: {}, + currentStep: 4, + }; + localStorage.setItem( + stoppingWashStorageKey, + JSON.stringify({ + laneId: 7, + reg: "AB12345", + customerNumber: 12345679, + startedAt: Date.now() - 3 * 60 * 1000, + }) + ); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + + expect(wrapper.find('[data-testid="self-serve-finishing-wash"]').exists()).toBe(false); + expect(localStorage.getItem(stoppingWashStorageKey)).toBeNull(); }); it("keeps the guided wash in progress when stop fails", async () => {