Enhance stopping wash functionality with local storage management and restoration logic
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user