Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
This commit is contained in:
+29
-11
@@ -2,25 +2,43 @@
|
||||
import { useGeolocation } from "@vueuse/core";
|
||||
import { watch } from "vue";
|
||||
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
const emits = defineEmits<{
|
||||
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
||||
}>();
|
||||
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
enableHighAccuracy?: boolean;
|
||||
maximumAge?: number;
|
||||
timeout?: number;
|
||||
}>(), {
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 30000,
|
||||
timeout: 27000,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
||||
}>();
|
||||
|
||||
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||
enableHighAccuracy: props.enableHighAccuracy,
|
||||
maximumAge: props.maximumAge,
|
||||
timeout: props.timeout,
|
||||
});
|
||||
|
||||
const getLocationTimestamp = () => {
|
||||
const timestamp = Number(locatedAt.value);
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
};
|
||||
|
||||
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
||||
if (newCoords.latitude && newCoords.longitude) {
|
||||
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
|
||||
if (normalizedCoords) {
|
||||
const timestamp = getLocationTimestamp();
|
||||
locations.set({
|
||||
coords: {
|
||||
latitude: newCoords.latitude,
|
||||
longitude: newCoords.longitude,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
coords: normalizedCoords,
|
||||
timestamp: new Date(timestamp),
|
||||
locatedAt: timestamp,
|
||||
errorMessage: error.value ? error.value.message : null,
|
||||
})
|
||||
emits('location-updated', newCoords);
|
||||
emits('location-updated', normalizedCoords);
|
||||
}
|
||||
};
|
||||
watch(coords, (newCoords) => {
|
||||
|
||||
+47
-6
@@ -100,18 +100,57 @@ const getLocation = () => {
|
||||
const clearLocation = () => {
|
||||
location.value = null;
|
||||
};
|
||||
type CoordinatePair = {
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
};
|
||||
|
||||
export const normalizeCoordinatePair = (
|
||||
coordinates: CoordinatePair | null | undefined,
|
||||
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
|
||||
): { latitude: number; longitude: number } | null => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowZeroPair && latitude === 0 && longitude === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { latitude, longitude };
|
||||
};
|
||||
|
||||
export const hasValidCoordinatePair = (
|
||||
coordinates: CoordinatePair | null | undefined,
|
||||
options: { allowZeroPair?: boolean } = {}
|
||||
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
|
||||
|
||||
// Get distance in kilometers between two locations
|
||||
const getDistance = (
|
||||
from: { latitude: number; longitude: number },
|
||||
to: { latitude: number; longitude: number }
|
||||
from: CoordinatePair,
|
||||
to: CoordinatePair
|
||||
): number => {
|
||||
const normalizedFrom = normalizeCoordinatePair(from);
|
||||
const normalizedTo = normalizeCoordinatePair(to);
|
||||
|
||||
if (!normalizedFrom || !normalizedTo) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(to.latitude - from.latitude);
|
||||
const dLon = toRad(to.longitude - from.longitude);
|
||||
const lat1 = toRad(from.latitude);
|
||||
const lat2 = toRad(to.latitude);
|
||||
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
|
||||
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
|
||||
const lat1 = toRad(normalizedFrom.latitude);
|
||||
const lat2 = toRad(normalizedTo.latitude);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
|
||||
@@ -124,6 +163,8 @@ const locations = {
|
||||
set: setLocation,
|
||||
get: getLocation,
|
||||
clear: clearLocation,
|
||||
normalizeCoordinatePair,
|
||||
hasValidCoordinatePair,
|
||||
getDistance,
|
||||
defaultTimeout: locationTimeout,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,9 @@ export type PosLocation = {
|
||||
coords?: PosLocationCoords | null;
|
||||
/** Timestamp */
|
||||
timestamp?: Date | null;
|
||||
/** Browser geolocation timestamp in milliseconds */
|
||||
locatedAt?: number | null;
|
||||
/** Error message */
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -91,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
|
||||
return 0;
|
||||
}
|
||||
const department = optionList.find((dept) => dept.value === departmentId);
|
||||
if (department && locations.location.value?.coords) {
|
||||
return locations.getDistance(
|
||||
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude },
|
||||
{ latitude: department.latitude, longitude: department.longitude }
|
||||
const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
|
||||
const departmentCoords = locations.normalizeCoordinatePair(
|
||||
{ latitude: department?.latitude, longitude: department?.longitude },
|
||||
{ allowZeroPair: false }
|
||||
);
|
||||
if (department && currentCoords && departmentCoords) {
|
||||
const distance = locations.getDistance(
|
||||
currentCoords,
|
||||
departmentCoords
|
||||
);
|
||||
return Number.isFinite(distance) ? distance : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,18 @@ const isLaneSelfServeEnabled = (lane) =>
|
||||
const hasSelfServeEnabledLane = (department) =>
|
||||
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||
|
||||
const normalizeLocationCoordinates = (locationValue = locations.location.value) =>
|
||||
locations.normalizeCoordinatePair(locationValue?.coords);
|
||||
|
||||
const normalizeDepartmentCoordinates = (department) =>
|
||||
locations.normalizeCoordinatePair(
|
||||
{
|
||||
latitude: department?.latitude,
|
||||
longitude: department?.longitude,
|
||||
},
|
||||
{ allowZeroPair: false }
|
||||
);
|
||||
|
||||
const toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
distance,
|
||||
@@ -81,7 +93,8 @@ export function useWashDepartments(options = {}) {
|
||||
|
||||
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) =>
|
||||
normalizeLocationCoordinates(locationValue) !== null;
|
||||
|
||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||
|
||||
@@ -138,23 +151,21 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
const from = normalizeLocationCoordinates(locationValue);
|
||||
let currentNearestDepartment = {
|
||||
id: null,
|
||||
distance: Infinity,
|
||||
};
|
||||
|
||||
guestDepartments.value.forEach((department) => {
|
||||
const from = {
|
||||
latitude: locationValue.coords.latitude,
|
||||
longitude: locationValue.coords.longitude,
|
||||
};
|
||||
const to = {
|
||||
latitude: department.latitude,
|
||||
longitude: department.longitude,
|
||||
};
|
||||
const to = normalizeDepartmentCoordinates(department);
|
||||
if (!from || !to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = locations.getDistance(from, to);
|
||||
|
||||
if (distance < currentNearestDepartment.distance) {
|
||||
if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
|
||||
currentNearestDepartment = toDepartmentViewModel(department, distance);
|
||||
}
|
||||
});
|
||||
@@ -179,20 +190,33 @@ export function useWashDepartments(options = {}) {
|
||||
};
|
||||
|
||||
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
const currentCoords = normalizeLocationCoordinates(locations.location.value);
|
||||
if (!currentCoords) {
|
||||
return departmentsList;
|
||||
}
|
||||
|
||||
const distanceFromCurrentLocation = (department) => {
|
||||
const departmentCoords = normalizeDepartmentCoordinates(department);
|
||||
if (!departmentCoords) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return locations.getDistance(currentCoords, departmentCoords);
|
||||
};
|
||||
|
||||
return departmentsList.slice().sort((departmentA, departmentB) => {
|
||||
const currentCoords = locations.location.value.coords;
|
||||
const distanceA = locations.getDistance(
|
||||
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
|
||||
{ latitude: departmentA.latitude, longitude: departmentA.longitude }
|
||||
);
|
||||
const distanceB = locations.getDistance(
|
||||
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
|
||||
{ latitude: departmentB.latitude, longitude: departmentB.longitude }
|
||||
);
|
||||
const distanceA = distanceFromCurrentLocation(departmentA);
|
||||
const distanceB = distanceFromCurrentLocation(departmentB);
|
||||
|
||||
if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(distanceA)) {
|
||||
return 1;
|
||||
}
|
||||
if (!Number.isFinite(distanceB)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return distanceA - distanceB;
|
||||
});
|
||||
|
||||
@@ -1150,6 +1150,14 @@
|
||||
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}",
|
||||
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgængelige'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Vælg afdeling manuelt",
|
||||
"manual_title": "Vælg din afdeling",
|
||||
"manual_loading": "Indlæser afdelinger...",
|
||||
"use_department": "Vælg {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
|
||||
|
||||
@@ -1260,6 +1260,14 @@
|
||||
"field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}",
|
||||
"no_entity_available": "@.capitalize:{'words.generated.keine'} {entity} @:{'words.generated.verfugbar'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Abteilung manuell auswählen",
|
||||
"manual_title": "Wählen Sie Ihre Abteilung",
|
||||
"manual_loading": "Abteilungen werden geladen...",
|
||||
"use_department": "{name} auswählen"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
|
||||
|
||||
@@ -984,6 +984,14 @@
|
||||
"field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}",
|
||||
"no_entity_available": "@.capitalize:{'words.generated.no'} {entity} @:{'words.generated.available'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Select department manually",
|
||||
"manual_title": "Select your department",
|
||||
"manual_loading": "Loading departments...",
|
||||
"use_department": "Select {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'words.replication.services.database'} @:{'words.replication.host'}",
|
||||
|
||||
@@ -1261,6 +1261,14 @@
|
||||
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}",
|
||||
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgjengelige'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Velg avdeling manuelt",
|
||||
"manual_title": "Velg din avdeling",
|
||||
"manual_loading": "Laster avdelinger...",
|
||||
"use_department": "Velg {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
|
||||
|
||||
@@ -1311,6 +1311,14 @@
|
||||
"field_required": "{field} @:{'words.generated.kravs'}",
|
||||
"no_entity_available": "@.capitalize:{'words.generated.inga'} {entity} @:{'words.generated.tillgangliga'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Välj avdelning manuellt",
|
||||
"manual_title": "Välj din avdelning",
|
||||
"manual_loading": "Laddar avdelningar...",
|
||||
"use_department": "Välj {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"all": "Alle"
|
||||
}
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Vælg afdeling manuelt",
|
||||
"manual_title": "Vælg din afdeling",
|
||||
"manual_loading": "Indlæser afdelinger...",
|
||||
"use_department": "Vælg {name}"
|
||||
}
|
||||
},
|
||||
"about_us": {
|
||||
"solutions": {
|
||||
"customer": {
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"all": "Alle"
|
||||
}
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Abteilung manuell auswählen",
|
||||
"manual_title": "Wählen Sie Ihre Abteilung",
|
||||
"manual_loading": "Abteilungen werden geladen...",
|
||||
"use_department": "{name} auswählen"
|
||||
}
|
||||
},
|
||||
"about_us": {
|
||||
"solutions": {
|
||||
"customer": {
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"all": "All"
|
||||
}
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Select department manually",
|
||||
"manual_title": "Select your department",
|
||||
"manual_loading": "Loading departments...",
|
||||
"use_department": "Select {name}"
|
||||
}
|
||||
},
|
||||
"about_us": {
|
||||
"solutions": {
|
||||
"customer": {
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"all": "Alle"
|
||||
}
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Velg avdeling manuelt",
|
||||
"manual_title": "Velg din avdeling",
|
||||
"manual_loading": "Laster avdelinger...",
|
||||
"use_department": "Velg {name}"
|
||||
}
|
||||
},
|
||||
"about_us": {
|
||||
"solutions": {
|
||||
"customer": {
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"all": "Alla"
|
||||
}
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Välj avdelning manuellt",
|
||||
"manual_title": "Välj din avdelning",
|
||||
"manual_loading": "Laddar avdelningar...",
|
||||
"use_department": "Välj {name}"
|
||||
}
|
||||
},
|
||||
"about_us": {
|
||||
"solutions": {
|
||||
"customer": {
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}",
|
||||
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgængelige'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Vælg afdeling manuelt",
|
||||
"manual_title": "Vælg din afdeling",
|
||||
"manual_loading": "Indlæser afdelinger...",
|
||||
"use_department": "Vælg {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}",
|
||||
"no_entity_available": "@.capitalize:{'terms.glossary.keine'} {entity} @:{'terms.glossary.verfugbar'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Abteilung manuell auswählen",
|
||||
"manual_title": "Wählen Sie Ihre Abteilung",
|
||||
"manual_loading": "Abteilungen werden geladen...",
|
||||
"use_department": "{name} auswählen"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}",
|
||||
"no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Select department manually",
|
||||
"manual_title": "Select your department",
|
||||
"manual_loading": "Loading departments...",
|
||||
"use_department": "Select {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrevd'}",
|
||||
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgjengelige'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Velg avdeling manuelt",
|
||||
"manual_title": "Velg din avdeling",
|
||||
"manual_loading": "Laster avdelinger...",
|
||||
"use_department": "Velg {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"field_required": "{field} @:{'terms.glossary.kravs'}",
|
||||
"no_entity_available": "@.capitalize:{'terms.glossary.inga'} {entity} @:{'terms.glossary.tillgangliga'}."
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Välj avdelning manuellt",
|
||||
"manual_title": "Välj din avdelning",
|
||||
"manual_loading": "Laddar avdelningar...",
|
||||
"use_department": "Välj {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"host_targets": {
|
||||
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
|
||||
|
||||
+194
-27
@@ -48,13 +48,22 @@ const hiarchyAccessLevels = {
|
||||
|
||||
const attemptAutoSelectDepartment = ref(false);
|
||||
const autoSelectDepartmentResolved = ref(false);
|
||||
const autoSelectDepartmentStartedAt = ref(null);
|
||||
const autoSelectGeolocationMaximumAge = 0;
|
||||
const autoSelectGeolocationTimeout = 27000;
|
||||
const manualDepartmentSelectionDelay = 5000;
|
||||
const manualDepartmentSelectionAvailable = ref(false);
|
||||
const manualDepartmentSelectionOpen = ref(false);
|
||||
let departmentsLoadPromise = null;
|
||||
let autoSelectFallbackTimeout = null;
|
||||
let manualDepartmentSelectionTimeout = null;
|
||||
|
||||
const getAccessibleDepartments = () => {
|
||||
return departments.value.filter(dept => SessionUser.canAccessAssignedDepartment(dept.id));
|
||||
}
|
||||
|
||||
const accessibleDepartmentsForManualSelection = computed(() => getAccessibleDepartments());
|
||||
|
||||
const isMobileDepartmentAutoSelectRoute = (accessLevel) => {
|
||||
return isMobile.value && (accessLevel.route === 'superuser' || accessLevel.route === 'admin');
|
||||
}
|
||||
@@ -66,9 +75,17 @@ const clearAutoSelectFallbackTimeout = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearManualDepartmentSelectionTimeout = () => {
|
||||
if (manualDepartmentSelectionTimeout !== null) {
|
||||
clearTimeout(manualDepartmentSelectionTimeout);
|
||||
manualDepartmentSelectionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
const markAutoSelectDepartmentResolved = () => {
|
||||
autoSelectDepartmentResolved.value = true;
|
||||
clearAutoSelectFallbackTimeout();
|
||||
clearManualDepartmentSelectionTimeout();
|
||||
}
|
||||
|
||||
const redirectToDepartmentPos = (department) => {
|
||||
@@ -81,18 +98,47 @@ const redirectToDepartmentPos = (department) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parseLocationTimestamp = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
const timestamp = value.getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
const timestamp = Number(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
const getLocationTimestamp = (location) => {
|
||||
return parseLocationTimestamp(location?.locatedAt) ?? parseLocationTimestamp(location?.timestamp);
|
||||
}
|
||||
|
||||
const isFreshLocationForAutoSelect = (location) => {
|
||||
const startedAt = parseLocationTimestamp(autoSelectDepartmentStartedAt.value);
|
||||
const locationTimestamp = getLocationTimestamp(location);
|
||||
return startedAt !== null && locationTimestamp !== null && locationTimestamp >= startedAt;
|
||||
}
|
||||
|
||||
const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDepartments()) => {
|
||||
const location = locations.get();
|
||||
if (!location?.coords || accessibleDepartments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const from = {
|
||||
latitude: Number(location.coords.latitude),
|
||||
longitude: Number(location.coords.longitude)
|
||||
};
|
||||
if (!isFreshLocationForAutoSelect(location)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(from.latitude) || !Number.isFinite(from.longitude)) {
|
||||
const from = locations.normalizeCoordinatePair(location.coords);
|
||||
if (!from) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -102,12 +148,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
|
||||
}
|
||||
|
||||
accessibleDepartments.forEach(department => {
|
||||
const to = {
|
||||
latitude: Number(department.latitude),
|
||||
longitude: Number(department.longitude)
|
||||
};
|
||||
const to = locations.normalizeCoordinatePair({
|
||||
latitude: department.latitude,
|
||||
longitude: department.longitude
|
||||
}, { allowZeroPair: false });
|
||||
|
||||
if (!Number.isFinite(to.latitude) || !Number.isFinite(to.longitude)) {
|
||||
if (!to) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,7 +170,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
|
||||
}
|
||||
|
||||
const resolveMobileDepartmentAutoSelect = () => {
|
||||
if (!attemptAutoSelectDepartment.value || autoSelectDepartmentResolved.value || !SessionUser.isInitiated()) {
|
||||
if (
|
||||
!attemptAutoSelectDepartment.value ||
|
||||
autoSelectDepartmentResolved.value ||
|
||||
manualDepartmentSelectionOpen.value ||
|
||||
!SessionUser.isInitiated()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -173,7 +224,32 @@ const scheduleAutoSelectFallback = () => {
|
||||
autoSelectFallbackTimeout = setTimeout(() => {
|
||||
autoSelectFallbackTimeout = null;
|
||||
fallbackMobileDepartmentAutoSelect();
|
||||
}, locations.defaultTimeout.value);
|
||||
}, autoSelectGeolocationTimeout);
|
||||
}
|
||||
|
||||
const scheduleManualDepartmentSelection = () => {
|
||||
if (manualDepartmentSelectionTimeout !== null || manualDepartmentSelectionAvailable.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualDepartmentSelectionTimeout = setTimeout(() => {
|
||||
manualDepartmentSelectionTimeout = null;
|
||||
if (!autoSelectDepartmentResolved.value) {
|
||||
manualDepartmentSelectionAvailable.value = true;
|
||||
}
|
||||
}, manualDepartmentSelectionDelay);
|
||||
}
|
||||
|
||||
const openManualDepartmentSelection = () => {
|
||||
manualDepartmentSelectionAvailable.value = true;
|
||||
manualDepartmentSelectionOpen.value = true;
|
||||
clearManualDepartmentSelectionTimeout();
|
||||
clearAutoSelectFallbackTimeout();
|
||||
ensureDepartmentsLoadedForAutoSelect();
|
||||
}
|
||||
|
||||
const selectManualDepartment = (department) => {
|
||||
redirectToDepartmentPos(department);
|
||||
}
|
||||
|
||||
const startMobileDepartmentAutoSelect = () => {
|
||||
@@ -181,8 +257,14 @@ const startMobileDepartmentAutoSelect = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
attemptAutoSelectDepartment.value = true;
|
||||
if (!attemptAutoSelectDepartment.value) {
|
||||
autoSelectDepartmentStartedAt.value = Date.now();
|
||||
locations.clear();
|
||||
attemptAutoSelectDepartment.value = true;
|
||||
}
|
||||
|
||||
ensureDepartmentsLoadedForAutoSelect();
|
||||
scheduleManualDepartmentSelection();
|
||||
scheduleAutoSelectFallback();
|
||||
resolveMobileDepartmentAutoSelect();
|
||||
}
|
||||
@@ -256,18 +338,6 @@ watch(() => SessionUser.isSubuser.value, (newValue) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Debug set the locations data
|
||||
const debugSetLocationData = () => {
|
||||
locations.set({
|
||||
coords: {
|
||||
latitude: 55.635587,
|
||||
longitude: 12.254885
|
||||
},
|
||||
accuracy: 10,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => locations.get(), () => {
|
||||
resolveMobileDepartmentAutoSelect();
|
||||
});
|
||||
@@ -278,6 +348,7 @@ watch(departments, () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
clearAutoSelectFallbackTimeout();
|
||||
clearManualDepartmentSelectionTimeout();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -292,13 +363,109 @@ onUnmounted(() => {
|
||||
<PageLoader title="Omdirigerer til login..."/>
|
||||
</div>
|
||||
<div v-else-if="SessionUser.isInitiated()">
|
||||
<PageLoader :title="`Finder din nærmeste afdeling...`" @click="debugSetLocationData"/>
|
||||
<PosDepartmentStepMobile1Location/>
|
||||
<PageLoader :title="`Finder din nærmeste afdeling...`"/>
|
||||
<PosDepartmentStepMobile1Location
|
||||
:maximum-age="autoSelectGeolocationMaximumAge"
|
||||
:timeout="autoSelectGeolocationTimeout"
|
||||
/>
|
||||
<div
|
||||
v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved"
|
||||
class="redirect-manual-selector"
|
||||
data-testid="redirect-manual-department-selector"
|
||||
>
|
||||
<button
|
||||
v-if="!manualDepartmentSelectionOpen"
|
||||
type="button"
|
||||
class="button is-primary is-medium"
|
||||
data-testid="redirect-manual-department-button"
|
||||
@click="openManualDepartmentSelection"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-building"></i>
|
||||
</span>
|
||||
<span>{{ $t("redirect.mobile_department_auto_select.manual_button") }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="redirect-manual-selector__panel"
|
||||
data-testid="redirect-manual-department-options"
|
||||
>
|
||||
<p class="redirect-manual-selector__title">
|
||||
{{ $t("redirect.mobile_department_auto_select.manual_title") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="accessibleDepartmentsForManualSelection.length === 0"
|
||||
class="redirect-manual-selector__status"
|
||||
data-testid="redirect-manual-department-loading"
|
||||
>
|
||||
{{ $t("redirect.mobile_department_auto_select.manual_loading") }}
|
||||
</p>
|
||||
<div v-else class="redirect-manual-selector__options">
|
||||
<button
|
||||
v-for="department in accessibleDepartmentsForManualSelection"
|
||||
:key="department.id"
|
||||
type="button"
|
||||
class="button is-light redirect-manual-selector__option"
|
||||
:data-testid="`redirect-manual-department-option-${department.id}`"
|
||||
@click="selectManualDepartment(department)"
|
||||
>
|
||||
{{ $t("redirect.mobile_department_auto_select.use_department", { name: department.name }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redirect-manual-selector {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||
left: 1rem;
|
||||
z-index: 100001;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.redirect-manual-selector > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.redirect-manual-selector__panel {
|
||||
width: min(100%, 28rem);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.redirect-manual-selector__title {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redirect-manual-selector__status {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redirect-manual-selector__options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.redirect-manual-selector__option {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ const {
|
||||
} = useWashDepartments({ includeLanes: true });
|
||||
|
||||
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
|
||||
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
|
||||
|
||||
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
|
||||
@@ -67,20 +67,28 @@ const selfServeUnavailableMessage = computed(() => {
|
||||
});
|
||||
|
||||
const getDepartmentDistance = (department) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
|
||||
if (!currentCoords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return locations.getDistance(
|
||||
{
|
||||
latitude: locations.location.value.coords.latitude,
|
||||
longitude: locations.location.value.coords.longitude,
|
||||
},
|
||||
const departmentCoords = locations.normalizeCoordinatePair(
|
||||
{
|
||||
latitude: department.latitude,
|
||||
longitude: department.longitude,
|
||||
}
|
||||
},
|
||||
{ allowZeroPair: false }
|
||||
);
|
||||
if (!departmentCoords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distance = locations.getDistance(
|
||||
currentCoords,
|
||||
departmentCoords
|
||||
);
|
||||
|
||||
return Number.isFinite(distance) ? distance : null;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -716,7 +716,7 @@ const applyResolvedVehicleTypeSelection = () => {
|
||||
|
||||
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
|
||||
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
|
||||
|
||||
const canUseDepartmentHeaderSelection = computed(
|
||||
() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("vue-router", () => {
|
||||
const push = vi.fn();
|
||||
@@ -32,8 +32,28 @@ vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmen
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "MockPosDepartmentStepMobile1Location",
|
||||
setup() {
|
||||
return () => h("div", { "data-testid": "location-probe" });
|
||||
props: {
|
||||
enableHighAccuracy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maximumAge: {
|
||||
type: Number,
|
||||
default: 30000,
|
||||
},
|
||||
timeout: {
|
||||
type: Number,
|
||||
default: 27000,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h("div", {
|
||||
"data-testid": "location-probe",
|
||||
"data-enable-high-accuracy": String(props.enableHighAccuracy),
|
||||
"data-maximum-age": String(props.maximumAge),
|
||||
"data-timeout": String(props.timeout),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -67,6 +87,20 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
|
||||
const locations = {
|
||||
location,
|
||||
defaultTimeout,
|
||||
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
if (!allowZeroPair && latitude === 0 && longitude === 0) {
|
||||
return null;
|
||||
}
|
||||
return { latitude, longitude };
|
||||
},
|
||||
set: (newLocation) => {
|
||||
location.value = newLocation;
|
||||
},
|
||||
@@ -129,7 +163,18 @@ import {
|
||||
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { __sessionState } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const mountDefaultPage = () => mount(DefaultPage);
|
||||
let wrapper = null;
|
||||
|
||||
const mountDefaultPage = () => {
|
||||
wrapper = mount(DefaultPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key, params = {}) => (params.name ? `${key}:${params.name}` : key),
|
||||
},
|
||||
},
|
||||
});
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const setAdminSession = (permissions = ["admin"]) => {
|
||||
__sessionState.initiated.value = true;
|
||||
@@ -138,9 +183,21 @@ const setAdminSession = (permissions = ["admin"]) => {
|
||||
__sessionState.token.value = "unit-token";
|
||||
};
|
||||
|
||||
const setLocation = ({ latitude, longitude, timestamp = Date.now() }) => {
|
||||
__locationRef.value = {
|
||||
coords: {
|
||||
latitude,
|
||||
longitude,
|
||||
},
|
||||
timestamp: new Date(timestamp),
|
||||
locatedAt: timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
describe("DefaultPage mobile department redirect", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-11T10:00:00.000Z"));
|
||||
__routerPush.mockReset();
|
||||
__getDepartmentsMock.mockReset();
|
||||
__getDepartmentsMock.mockResolvedValue(undefined);
|
||||
@@ -151,16 +208,21 @@ describe("DefaultPage mobile department redirect", () => {
|
||||
setAdminSession(["admin", "department_access_1", "department_access_2"]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = null;
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes when geolocation arrives before departments finish loading", async () => {
|
||||
mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
__locationRef.value = {
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.5,
|
||||
},
|
||||
};
|
||||
setLocation({
|
||||
latitude: 55.5,
|
||||
longitude: 12.5,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
|
||||
@@ -175,24 +237,155 @@ describe("DefaultPage mobile department redirect", () => {
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
|
||||
});
|
||||
|
||||
it("requests a fresh geolocation fix for mobile department auto-selection", async () => {
|
||||
const mounted = mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
const probe = mounted.find('[data-testid="location-probe"]');
|
||||
expect(probe.attributes("data-enable-high-accuracy")).toBe("true");
|
||||
expect(probe.attributes("data-maximum-age")).toBe("0");
|
||||
expect(probe.attributes("data-timeout")).toBe("27000");
|
||||
});
|
||||
|
||||
it("shows manual department selection after five seconds", async () => {
|
||||
const mounted = mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the user manually select a department after GPS wait", async () => {
|
||||
setAdminSession(["admin", "department_access_4", "department_access_6"]);
|
||||
__departmentsRef.value = [
|
||||
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
|
||||
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
|
||||
];
|
||||
|
||||
const mounted = mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await nextTick();
|
||||
|
||||
await mounted.get('[data-testid="redirect-manual-department-button"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.find('[data-testid="redirect-manual-department-options"]').exists()).toBe(true);
|
||||
|
||||
setLocation({
|
||||
latitude: 55.458,
|
||||
longitude: 12.182,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(27000);
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).not.toHaveBeenCalledWith({ name: "admin" });
|
||||
|
||||
await mounted.get('[data-testid="redirect-manual-department-option-6"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
|
||||
});
|
||||
|
||||
it("ignores stale cached location and waits for a fresh Roskilde location", async () => {
|
||||
setAdminSession(["admin", "department_access_4", "department_access_6"]);
|
||||
__departmentsRef.value = [
|
||||
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
|
||||
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
|
||||
];
|
||||
setLocation({
|
||||
latitude: 55.458,
|
||||
longitude: 12.182,
|
||||
timestamp: Date.now() - 30000,
|
||||
});
|
||||
|
||||
mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
expect(__locationRef.value).toBeNull();
|
||||
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
setLocation({
|
||||
latitude: 55.642,
|
||||
longitude: 12.08,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
|
||||
});
|
||||
|
||||
it("does not redirect from a location timestamp older than the auto-select attempt", async () => {
|
||||
setAdminSession(["admin", "department_access_4", "department_access_6"]);
|
||||
__departmentsRef.value = [
|
||||
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
|
||||
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
|
||||
];
|
||||
const staleTimestamp = Date.now() - 1000;
|
||||
|
||||
mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
setLocation({
|
||||
latitude: 55.458,
|
||||
longitude: 12.182,
|
||||
timestamp: staleTimestamp,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(27000);
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
|
||||
});
|
||||
|
||||
it("routes immediately when only one accessible department exists", async () => {
|
||||
setAdminSession(["admin", "department_access_6"]);
|
||||
__departmentsRef.value = [
|
||||
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
|
||||
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
|
||||
];
|
||||
|
||||
mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
|
||||
});
|
||||
|
||||
it("falls back after timeout even when location exists but nearest cannot be resolved", async () => {
|
||||
__departmentsRef.value = [
|
||||
{ id: 1, name: "Missing coordinates" },
|
||||
{ id: 2, name: "Also missing coordinates" },
|
||||
];
|
||||
__locationRef.value = {
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.5,
|
||||
},
|
||||
};
|
||||
|
||||
mountDefaultPage();
|
||||
await nextTick();
|
||||
|
||||
setLocation({
|
||||
latitude: 55.5,
|
||||
longitude: 12.5,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(27000);
|
||||
await nextTick();
|
||||
|
||||
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
|
||||
|
||||
@@ -32,6 +32,16 @@ vi.mock("@/composables/useWashDepartments", () => ({
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
|
||||
locations: {
|
||||
location: mocks.locationRef,
|
||||
normalizeCoordinatePair: (coordinates) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
|
||||
},
|
||||
hasValidCoordinatePair: (coordinates) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude);
|
||||
},
|
||||
getDistance: mocks.getDistance,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -140,6 +140,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
|
||||
},
|
||||
},
|
||||
},
|
||||
hasValidCoordinatePair: (coordinates) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
|
||||
return {
|
||||
locations: {
|
||||
location: ref(null),
|
||||
normalizeCoordinatePair: (coordinates) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
|
||||
},
|
||||
getDistance: () => 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,20 @@ vi.mock("@/components/pagination/departmentTabs.vue", () => ({
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
|
||||
locations: {
|
||||
location: mocks.locationRef,
|
||||
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
if (!allowZeroPair && latitude === 0 && longitude === 0) {
|
||||
return null;
|
||||
}
|
||||
return { latitude, longitude };
|
||||
},
|
||||
getDistance: mocks.getDistance,
|
||||
},
|
||||
}));
|
||||
@@ -217,6 +231,106 @@ describe("useWashDepartments", () => {
|
||||
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
|
||||
});
|
||||
|
||||
it("treats non-finite browser coordinates as missing instead of distance-selecting a department", async () => {
|
||||
mocks.locationRef.value = {
|
||||
coords: {
|
||||
latitude: Number.POSITIVE_INFINITY,
|
||||
longitude: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
};
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Fallback",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Other",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: true }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(mocks.getDistance).not.toHaveBeenCalled();
|
||||
expect(departments.nearestDepartment.value).toMatchObject({ id: 1, name: "Fallback", distance: null });
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
|
||||
});
|
||||
|
||||
it("skips default zero department coordinates when selecting by GPS", async () => {
|
||||
mocks.getDistance.mockImplementation((_from, to) => {
|
||||
if (to.latitude === 55.7) {
|
||||
return 12;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Default coordinate row",
|
||||
address: "A",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Real coordinate row",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: true }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(mocks.getDistance.mock.calls.every(([, to]) => to.latitude === 55.7 && to.longitude === 12.6)).toBe(true);
|
||||
expect(departments.nearestDepartment.value).toMatchObject({
|
||||
id: 2,
|
||||
name: "Real coordinate row",
|
||||
distance: 12,
|
||||
});
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("distance");
|
||||
});
|
||||
|
||||
it("pushes departments with default zero coordinates after valid coordinates when sorting by distance", async () => {
|
||||
mocks.getDistance.mockImplementation((_from, to) => {
|
||||
if (to.latitude === 55.7) {
|
||||
return 12;
|
||||
}
|
||||
if (to.latitude === 55.8) {
|
||||
return 8;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
const unsortedDepartments = [
|
||||
{ id: 1, name: "Default coordinate row", latitude: 0, longitude: 0 },
|
||||
{ id: 2, name: "Farther valid row", latitude: 55.7, longitude: 12.6 },
|
||||
{ id: 3, name: "Nearer valid row", latitude: 55.8, longitude: 12.7 },
|
||||
];
|
||||
|
||||
expect(departments.orderDepartmentsByDistance(unsortedDepartments).map((department) => department.id)).toEqual([
|
||||
3, 2, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears the selected department for empty department responses", async () => {
|
||||
mocks.getDepartmentsGuest.mockResolvedValueOnce([
|
||||
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
|
||||
|
||||
Reference in New Issue
Block a user