Enhance stopping wash functionality with local storage management and restoration logic

This commit is contained in:
Jeppe Bundgaard
2026-06-30 10:16:09 +02:00
parent 05e1dae033
commit 7bfb341551
4 changed files with 263 additions and 3 deletions
@@ -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);
});
+34 -3
View File
@@ -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<string, unknown>;
};
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
const mergeMessages = (...sources: Array<Record<string, unknown> | undefined>) => {
const merged: Record<string, unknown> = {};
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<string, unknown>;
locales?: Partial<Record<(typeof ACTIVE_LOCALES)[number], Record<string, unknown>>>;
};
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;
@@ -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(");
+100
View File
@@ -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 () => {