Merge pull request #57 from copenhagentruckwash/fix-bugs-in-start-wash-flow

Harden customer wash start flow
This commit is contained in:
Jeppe B
2026-06-01 18:01:29 +02:00
committed by GitHub
2 changed files with 202 additions and 12 deletions
@@ -54,9 +54,12 @@ const isVehicleStepNextLoading = ref(false);
const hasStartFormUserInput = ref(false);
const shouldRestoreServerActiveWash = ref(false);
const isRestoringServerActiveWash = ref(false);
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 ACTIVE_WASH_REFRESH_MS = 5 * 1000;
const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000;
const normalizePositiveInteger = (value: any) => {
const parsed = parseInt(String(value ?? ""), 10);
@@ -374,6 +377,7 @@ const completeGuidedWash = async () => {
};
const unregisterBeforeUnload = ref<null | (() => void)>(null);
const activeWashRefreshInterval = ref<ReturnType<typeof window.setInterval> | null>(null);
const registrationOptions = computed(() =>
customerVehicles.value
@@ -505,12 +509,7 @@ const questionReviewStateKey = computed(() => {
return "";
}
return [
normalizeLicensePlate(licensePlateInput.value),
normalizeLaneId(radioLaneOption.value) ?? "",
vehicleTypeSelect.value ?? "",
visibleQuestionIds,
].join("|");
return [normalizeLicensePlate(licensePlateInput.value), vehicleTypeSelect.value ?? "", visibleQuestionIds].join("|");
});
const hasConfirmedCurrentQuestionReview = computed(
@@ -893,6 +892,111 @@ const scheduleServerActiveWashRestore = (delayMs = 0) => {
}, delayMs);
};
const clearLocalActiveWashFromServer = () => {
const completedLaneId = washLaneId.value;
markRecentlyCompletedWash(completedLaneId, licensePlateInput.value, getNumericCustomerNumber());
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
currentStep.value = steps.COMPLETED;
washInProgress.value = false;
washLaneId.value = null;
washStartTime.value = null;
shouldRestoreServerActiveWash.value = false;
stopElapsedTimer();
clearProgress();
resetGuidedWashStep();
};
const syncActiveWashWithServer = async () => {
if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value) {
return;
}
const laneId = normalizeLaneId(washLaneId.value);
if (!laneId) {
clearLocalActiveWashFromServer();
return;
}
isSyncingActiveWash.value = true;
try {
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: laneId,
});
const details = unwrapApiData(response);
if (!Object.prototype.hasOwnProperty.call(details || {}, "in_progress")) {
return;
}
const customerNumber = getNumericCustomerNumber();
const serverCustomerNumber = normalizePositiveInteger(
details?.session?.customer_number ?? details?.customer?.customer_number
);
const serverStillMatchesCurrentWash =
!!details?.in_progress && (!customerNumber || !serverCustomerNumber || serverCustomerNumber === customerNumber);
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
const isWithinStartGracePeriod =
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
return;
}
clearLocalActiveWashFromServer();
return;
}
const session = details?.session || {};
const vehicle = details?.vehicle || {};
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
if (serverReg) {
licensePlateInput.value = serverReg;
}
applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number ?? customerNumber);
vehicleTypeSelect.value =
normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value;
radioWashType.value = session?.machine_relay_enabled ? "Machine" : radioWashType.value;
washStartTime.value =
parseServerDateTimeMs(session?.wash_started_at) ??
parseServerDateTimeMs(session?.machine_start_triggered_at) ??
parseServerDateTimeMs(session?.machine_relay_enabled_at) ??
washStartTime.value;
const summaryParams: Record<string, any> = session?.id
? { session_id: session.id }
: { lane_id: laneId, reg: normalizeLicensePlate(licensePlateInput.value) };
const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
if (selectedVehicleTypeId) {
summaryParams.vehicle_type = selectedVehicleTypeId;
}
await fetchWashSummary(summaryParams, false);
saveProgress("serverActiveWashRefresh");
} catch (error) {
console.warn("Failed to refresh active self-serve wash:", error);
} finally {
isSyncingActiveWash.value = false;
}
};
const stopActiveWashRefresh = () => {
if (activeWashRefreshInterval.value) {
window.clearInterval(activeWashRefreshInterval.value);
activeWashRefreshInterval.value = null;
}
};
const startActiveWashRefresh = () => {
stopActiveWashRefresh();
if (!washInProgress.value || !washLaneId.value) {
return;
}
syncActiveWashWithServer();
activeWashRefreshInterval.value = window.setInterval(() => {
syncActiveWashWithServer();
}, ACTIVE_WASH_REFRESH_MS);
};
const retrySelfServeData = async () => {
if (isSelfServeRetrying.value) {
return;
@@ -979,6 +1083,29 @@ const onVehicleStepNext = async () => {
}
};
const onPreviousStep = (fallbackPrevious: { action?: () => void } | null = null) => {
if (washInProgress.value || currentStep.value === steps.COMPLETED) {
return;
}
if (currentStep.value === steps.QUESTIONS) {
currentStep.value = steps.VEHICLE;
return;
}
if (currentStep.value === steps.SELECT_LANE) {
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.VEHICLE;
return;
}
if (currentStep.value === steps.TASKS) {
currentStep.value = steps.SELECT_LANE;
return;
}
fallbackPrevious?.action?.();
};
const onToggleTask = (taskId: number, value: boolean) => {
completedTasks.value = {
...completedTasks.value,
@@ -1071,6 +1198,7 @@ onUnmounted(() => {
}
stopAutoRefresh();
stopActiveWashRefresh();
markDestroying();
setShowFooterInContent(true);
});
@@ -1198,6 +1326,15 @@ watch(
{ deep: true }
);
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
if (inProgress) {
startActiveWashRefresh();
return;
}
stopActiveWashRefresh();
});
watch(
() => nearestDepartment.value,
(newValue) => {
@@ -1219,11 +1356,7 @@ watch(
(newLaneId) => {
if (newLaneId && newLaneId !== "Any" && !isRestoring.value) {
fetchSelfServeData().then(() => {
if (
(!allVisibleQuestionsAnswered.value || shouldReturnToQuestionsForReview.value) &&
!washInProgress.value &&
currentStep.value > steps.QUESTIONS
) {
if (!allVisibleQuestionsAnswered.value && !washInProgress.value && currentStep.value > steps.QUESTIONS) {
currentStep.value = steps.QUESTIONS;
}
});
@@ -1513,7 +1646,7 @@ watch(
icon-left="arrow-left"
data-testid="self-serve-nav-previous"
:disabled="previous.disabled || currentStep === steps.COMPLETED || washInProgress"
@click.prevent="previous.action"
@click.prevent="onPreviousStep(previous)"
>
{{ $t("common.previous") }}
</b-button>
+57
View File
@@ -1280,4 +1280,61 @@ describe("MyWashStart", () => {
"display: none"
);
});
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 4,
};
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
return {
data: {
data: {
lane_id: 7,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
},
};
}
return undefined;
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await nextTick();
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
wrapper.unmount();
});
});