Add goal progress resolver utility with extensive tests and DepartmentGoals refactor.
- Implemented `goalProgressResolver` utility for resolving progress keys, slices, and periods. - Refactored `DepartmentGoals` to integrate `goalProgressResolver` for improved clarity and modularity. - Updated progress calculations with cadence-aware key handling and fallback resolution. - Add isLoading handling and loader animations to `DepartmentWeather` and improve `GET` request concurrency.
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ export const MIGRATION_ORIGIN = 'https://truckwash.io';
|
||||
export const REQUEST_QUEUE_CONFIG = Object.freeze({
|
||||
// Per-method concurrency limits
|
||||
concurrency: Object.freeze({
|
||||
GET: 5,
|
||||
GET: 10,
|
||||
POST: 1,
|
||||
PATCH: 1,
|
||||
PUT: 1,
|
||||
|
||||
+42
-2
@@ -19,6 +19,7 @@ const props = defineProps({
|
||||
|
||||
const state = ref([]);
|
||||
const latestRequestToken = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const summarizeDaily = ref(false);
|
||||
const statusPriority = {
|
||||
unknown: 0,
|
||||
@@ -178,10 +179,13 @@ const getWeather = async () => {
|
||||
latestRequestToken.value = requestToken;
|
||||
|
||||
if (ids.length === 0 || !hasDateFrom || !hasDateTo) {
|
||||
isLoading.value = false;
|
||||
state.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await ObjectsGlobal.get.objects("/departments/weather", {
|
||||
ids: ids.join(","),
|
||||
@@ -204,6 +208,10 @@ const getWeather = async () => {
|
||||
return;
|
||||
}
|
||||
state.value = [];
|
||||
} finally {
|
||||
if (requestToken === latestRequestToken.value) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,8 +229,12 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="displayedState.length > 0" class="daily-metrics">
|
||||
<div class="table-wrapper">
|
||||
<section v-if="isLoading || displayedState.length > 0" class="daily-metrics">
|
||||
<div v-if="isLoading" class="daily-metrics-loading" aria-live="polite">
|
||||
<span class="loader"></span>
|
||||
<span>Loading weather...</span>
|
||||
</div>
|
||||
<div v-else class="table-wrapper">
|
||||
<div class="daily-metrics-actions" data-auto-excel-export-actions="1">
|
||||
<button
|
||||
v-if="isMultiDaySelection"
|
||||
@@ -276,6 +288,34 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.daily-metrics .daily-metrics-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 120px;
|
||||
color: #001244;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.daily-metrics .daily-metrics-loading .loader {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #d9d9d9;
|
||||
border-top-color: #001244;
|
||||
border-radius: 50%;
|
||||
animation: weather-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes weather-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.daily-metrics .daily-metrics-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -12,6 +12,12 @@ import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
import {BToast, BTooltip} from "buefy";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
normalizeTimeframeKey,
|
||||
resolveGoalProgressPeriod,
|
||||
resolveGoalProgressSlice,
|
||||
toProgressNumber
|
||||
} from "./functions/goalProgressResolver";
|
||||
|
||||
const departmentId = ref(SessionUser.functions.getDepartmentIdFromUrl());
|
||||
const goals = ref([]);
|
||||
@@ -24,6 +30,32 @@ const timeframe = ref('all');
|
||||
|
||||
const currentTimeframe = computed(() => timeframe.value === 'until_now' ? 'to_date' : timeframe.value);
|
||||
|
||||
const getProgressScope = (goal, deptId = null) => {
|
||||
if (deptId !== null && deptId !== undefined) {
|
||||
return goal?.progress?.departmental_distribution?.[deptId] || goal?.progress?.departmental_distribution?.[String(deptId)] || null;
|
||||
}
|
||||
return goal?.progress || null;
|
||||
};
|
||||
|
||||
const getProgressSlice = (goal, deptId = null, timeframeValue = currentTimeframe.value) => {
|
||||
return resolveGoalProgressSlice(
|
||||
getProgressScope(goal, deptId),
|
||||
normalizeTimeframeKey(timeframeValue),
|
||||
goal?.criteria || {}
|
||||
);
|
||||
};
|
||||
|
||||
const getProgressCount = (goal, deptId = null, timeframeValue = currentTimeframe.value) => {
|
||||
const count = toProgressNumber(getProgressSlice(goal, deptId, timeframeValue)?.count);
|
||||
return count ?? 0;
|
||||
};
|
||||
|
||||
const getGoalProgressTarget = (goal, timeframeValue = currentTimeframe.value) => {
|
||||
const sliceTarget = toProgressNumber(getProgressSlice(goal, null, timeframeValue)?.target);
|
||||
if (sliceTarget !== null && sliceTarget > 0) return sliceTarget;
|
||||
return toProgressNumber(goal?.criteria?.target) ?? 0;
|
||||
};
|
||||
|
||||
const fetchGoals = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -88,18 +120,13 @@ const getGoalIconColor = (type) => {
|
||||
};
|
||||
|
||||
const getGoalProgress = (goal, deptId = null) => {
|
||||
const key = currentTimeframe.value;
|
||||
// Use the .value to divided by .target and multiply by 100
|
||||
if (deptId && goal.progress?.departmental_distribution?.[deptId]?.[key]) {
|
||||
const deptValue = parseFloat(goal.progress.departmental_distribution[deptId][key].count);
|
||||
const deptTarget = getDepartmentProgressTarget(goal, deptId);
|
||||
if (isNaN(deptValue) || isNaN(deptTarget) || deptTarget === 0) return 0;
|
||||
const deptProgress = (deptValue / deptTarget) * 100;
|
||||
return Math.min(Math.round(deptProgress), 100);
|
||||
}
|
||||
const value = parseFloat(goal.progress?.[key]?.count);
|
||||
const target = parseFloat(goal.progress?.[key]?.target);
|
||||
if (isNaN(value) || isNaN(target) || target === 0) return 0;
|
||||
const value = deptId === null
|
||||
? getProgressCount(goal)
|
||||
: getProgressCount(goal, deptId);
|
||||
const target = deptId === null
|
||||
? getGoalProgressTarget(goal)
|
||||
: getDepartmentProgressTarget(goal, deptId);
|
||||
if (!Number.isFinite(value) || !Number.isFinite(target) || target === 0) return 0;
|
||||
const progress = (value / target) * 100;
|
||||
return Math.min(Math.round(progress), 100);
|
||||
};
|
||||
@@ -339,14 +366,15 @@ const getDailyGoalDepartmentTarget = (goal, deptId, progressUntilNow = false) =>
|
||||
const x = parseInt(n, 10);
|
||||
return isNaN(x) ? 0 : x;
|
||||
};
|
||||
const apiTargetForSelectedTimeframe = toProgressNumber(getProgressSlice(goal, deptId)?.target);
|
||||
const getEvenSplitPerDay = () => {
|
||||
if (daysTotal === 0) return goal.progress?.departmental_distribution?.[deptId]?.[currentTimeframe.value]?.target || 0;
|
||||
if (daysTotal === 0) return apiTargetForSelectedTimeframe ?? 0;
|
||||
const deptCount = Math.max(1, goal.departments.length || 1);
|
||||
// If the department has a custom daily target, use that, otherwise use the even split
|
||||
if (hasOverride) {
|
||||
return normalizeInt(overrides[String(deptId)]);
|
||||
}
|
||||
return (normalizeInt(goal.progress?.departmental_distribution?.[deptId]?.[currentTimeframe.value]?.target) || (normalizeInt(goal.criteria.target) / deptCount)) / daysTotal;
|
||||
return (normalizeInt(apiTargetForSelectedTimeframe) || (normalizeInt(goal.criteria.target) / deptCount)) / daysTotal;
|
||||
};
|
||||
const dailyTarget = hasOverride ? normalizeInt(overrides[String(deptId)]) : getEvenSplitPerDay();
|
||||
|
||||
@@ -365,11 +393,23 @@ const getDailyGoalDepartmentTarget = (goal, deptId, progressUntilNow = false) =>
|
||||
};
|
||||
|
||||
const getDepartmentProgressTarget = (goal, deptId) => {
|
||||
const apiTarget = parseFloat(goal.progress?.departmental_distribution?.[deptId]?.[currentTimeframe.value]?.target);
|
||||
if (!isNaN(apiTarget) && apiTarget > 0) return apiTarget;
|
||||
const apiTarget = toProgressNumber(getProgressSlice(goal, deptId)?.target);
|
||||
if (Number.isFinite(apiTarget) && apiTarget > 0) return apiTarget;
|
||||
return getDailyGoalDepartmentTarget(goal, deptId, currentTimeframe.value === 'to_date');
|
||||
};
|
||||
|
||||
const getDepartmentProgressCount = (goal, deptId, timeframeValue = currentTimeframe.value) => {
|
||||
return getProgressCount(goal, deptId, timeframeValue);
|
||||
};
|
||||
|
||||
const getDepartmentProgressPercentage = (goal, deptId, timeframeValue = currentTimeframe.value) => {
|
||||
const target = getDepartmentProgressTarget(goal, deptId);
|
||||
if (!Number.isFinite(target) || target <= 0) return 0;
|
||||
const count = getDepartmentProgressCount(goal, deptId, timeframeValue);
|
||||
if (!Number.isFinite(count)) return 0;
|
||||
return Math.min(Math.round((count / target) * 100), 100);
|
||||
};
|
||||
|
||||
const getGoalApplicableDaysInPeriod = (goal, fromDate, toDate) => {
|
||||
const weekdays = goal.criteria.progress_alert_weekdays || ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
|
||||
return SessionUser.functions.getBusinessDaysBetweenDates(
|
||||
@@ -380,15 +420,8 @@ const getGoalApplicableDaysInPeriod = (goal, fromDate, toDate) => {
|
||||
};
|
||||
|
||||
const getGoalPeriod = (goal, deptId = null) => {
|
||||
const key = currentTimeframe.value;
|
||||
const progressScope = deptId
|
||||
? goal.progress?.departmental_distribution?.[deptId]?.[key]
|
||||
: goal.progress?.[key];
|
||||
|
||||
return {
|
||||
from: progressScope?.date_from || goal.criteria.start,
|
||||
end: progressScope?.date_end || goal.criteria.end,
|
||||
};
|
||||
const progressSlice = getProgressSlice(goal, deptId);
|
||||
return resolveGoalProgressPeriod(progressSlice, goal.criteria);
|
||||
};
|
||||
|
||||
const getGoalDaysProgress = (goal) => {
|
||||
@@ -606,12 +639,12 @@ const getGoalDaysProgress = (goal) => {
|
||||
<div class="mb-5">
|
||||
<div class="is-flex is-justify-content-space-between mb-2">
|
||||
<span class="is-size-7 has-text-weight-semibold">Fremskridt</span>
|
||||
<span class="is-size-7 has-text-weight-bold"><small>{{ goal.progress[currentTimeframe]?.count || 0 }} / {{ Math.round(goal.progress[currentTimeframe]?.target || goal.progress[currentTimeframe]?.target || goal.criteria.target) }}</small> - <strong>{{ getGoalProgress(goal) }}%</strong></span>
|
||||
<span class="is-size-7 has-text-weight-bold"><small>{{ Math.round(getProgressCount(goal)) }} / {{ Math.round(getGoalProgressTarget(goal)) }}</small> - <strong>{{ getGoalProgress(goal) }}%</strong></span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress is-small mb-0"
|
||||
:value="goal.progress[currentTimeframe]?.count || 0"
|
||||
:max="goal.progress[currentTimeframe]?.target || goal.criteria.target"
|
||||
:value="getProgressCount(goal)"
|
||||
:max="getGoalProgressTarget(goal)"
|
||||
:class="getProgressClass(getGoalProgress(goal))"
|
||||
>
|
||||
{{ getGoalProgress(goal) }}%
|
||||
@@ -621,7 +654,7 @@ const getGoalDaysProgress = (goal) => {
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<p class="heading mb-1">Mål</p>
|
||||
<p class="subtitle is-6 has-text-weight-bold">{{ Math.round(goal.criteria.type === 'REVENUE' ? SessionUser.functions.currency.toLocal(goal.progress[currentTimeframe]?.target || goal.criteria.target) : goal.progress[currentTimeframe]?.target || goal.criteria.target) }}</p>
|
||||
<p class="subtitle is-6 has-text-weight-bold">{{ Math.round(goal.criteria.type === 'REVENUE' ? SessionUser.functions.currency.toLocal(getGoalProgressTarget(goal)) : getGoalProgressTarget(goal)) }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="heading mb-1">Periode</p>
|
||||
@@ -651,13 +684,13 @@ const getGoalDaysProgress = (goal) => {
|
||||
<!-- The target for this department in selected timeframe -->
|
||||
<div class="is-flex is-justify-content-space-between mb-1">
|
||||
<strong :class="{'has-text-warning': tmpDeptId === deptId, 'has-text-grey': tmpDeptId !== deptId}">{{ getDepartmentName(tmpDeptId) }}</strong>
|
||||
<span :class="{'has-text-warning': tmpDeptId === deptId, 'has-text-grey': tmpDeptId !== deptId}">{{ Math.round(goal.progress.departmental_distribution[tmpDeptId]?.[currentTimeframe]?.count || 0) }} / {{ Math.round(getDepartmentProgressTarget(goal, tmpDeptId)) }} - <strong :class="{'has-text-warning': tmpDeptId === deptId, 'has-text-grey': tmpDeptId !== deptId}">{{ Math.min(Math.round(((goal.progress.departmental_distribution[tmpDeptId]?.[currentTimeframe]?.count || 0) / getDepartmentProgressTarget(goal, tmpDeptId)) * 100, true), 100) }}%</strong></span>
|
||||
<span :class="{'has-text-warning': tmpDeptId === deptId, 'has-text-grey': tmpDeptId !== deptId}">{{ Math.round(getDepartmentProgressCount(goal, tmpDeptId)) }} / {{ Math.round(getDepartmentProgressTarget(goal, tmpDeptId)) }} - <strong :class="{'has-text-warning': tmpDeptId === deptId, 'has-text-grey': tmpDeptId !== deptId}">{{ getDepartmentProgressPercentage(goal, tmpDeptId) }}%</strong></span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Total progress in selected timeframe for all departments -->
|
||||
<div class="is-flex is-justify-content-space-between mb-1">
|
||||
<strong class="has-text-white">I alt</strong>
|
||||
<span>{{ Math.round(goal.progress[currentTimeframe]?.count || 0) }} / {{ Math.round(goal.progress[currentTimeframe]?.target || goal.criteria.target) }} - <strong class="has-text-white">{{ getGoalProgress(goal) }}%</strong></span>
|
||||
<span>{{ Math.round(getProgressCount(goal)) }} / {{ Math.round(getGoalProgressTarget(goal)) }} - <strong class="has-text-white">{{ getGoalProgress(goal) }}%</strong></span>
|
||||
</div>
|
||||
<!-- Total divider -->
|
||||
<div class="divider">I alt</div>
|
||||
@@ -665,7 +698,7 @@ const getGoalDaysProgress = (goal) => {
|
||||
<div class="is-flex is-justify-content-space-between mb-1">
|
||||
<strong class="has-text-white">Mål</strong>
|
||||
<!-- Overall goal progress and then % with 2 decimal places -->
|
||||
<span>{{ Math.round(goal.progress[currentTimeframe]?.count || 0) }} / {{ Math.round(goal.progress[currentTimeframe]?.target || goal.criteria.target) }} - <strong class="has-text-white">{{ getGoalProgress(goal) }}%</strong></span>
|
||||
<span>{{ Math.round(getProgressCount(goal)) }} / {{ Math.round(getGoalProgressTarget(goal)) }} - <strong class="has-text-white">{{ getGoalProgress(goal) }}%</strong></span>
|
||||
</div>
|
||||
<!-- Days since -->
|
||||
<div class="is-flex is-justify-content-space-between mb-1">
|
||||
@@ -680,19 +713,19 @@ const getGoalDaysProgress = (goal) => {
|
||||
</template>
|
||||
<template v-slot:default>
|
||||
<span>
|
||||
{{ Math.round(goal.progress.departmental_distribution[deptId]?.[currentTimeframe]?.count || 0) }} / {{ Math.round(getDepartmentProgressTarget(goal, deptId)) }} -
|
||||
<strong>{{ Math.min(Math.round(((goal.progress.departmental_distribution[deptId]?.[currentTimeframe]?.count || 0) / getDepartmentProgressTarget(goal, deptId)) * 100, true), 100) }}%</strong>
|
||||
{{ Math.round(getDepartmentProgressCount(goal, deptId)) }} / {{ Math.round(getDepartmentProgressTarget(goal, deptId)) }} -
|
||||
<strong>{{ getDepartmentProgressPercentage(goal, deptId) }}%</strong>
|
||||
</span>
|
||||
</template>
|
||||
</b-tooltip>
|
||||
</div>
|
||||
<progress
|
||||
class="progress is-small mb-0"
|
||||
:value="goal.progress.departmental_distribution[deptId]?.[currentTimeframe]?.count || 0"
|
||||
:value="getDepartmentProgressCount(goal, deptId)"
|
||||
:max="getDepartmentProgressTarget(goal, deptId)"
|
||||
:class="getProgressClass(getDepartmentProgressTarget(goal, deptId) === 0 ? 0 : Math.min(Math.round(((goal.progress.departmental_distribution[deptId]?.[currentTimeframe]?.count || 0) / getDepartmentProgressTarget(goal, deptId)) * 100, true), 100))"
|
||||
:class="getProgressClass(getDepartmentProgressPercentage(goal, deptId))"
|
||||
>
|
||||
{{ Math.min(Math.round(((goal.progress.departmental_distribution[deptId]?.[currentTimeframe]?.count || 0) / getDepartmentProgressTarget(goal, deptId)) * 100, true), 100) }}%
|
||||
{{ getDepartmentProgressPercentage(goal, deptId) }}%
|
||||
</progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
const TIMEFRAME_ALIASES = {
|
||||
all: ['all', 'total'],
|
||||
to_date: ['to_date', 'toDate', 'until_now', 'untilNow'],
|
||||
today: ['today', 'day'],
|
||||
week: ['week', 'this_week', 'thisWeek'],
|
||||
month: ['month', 'this_month', 'thisMonth'],
|
||||
year: ['year', 'this_year', 'thisYear'],
|
||||
};
|
||||
|
||||
const FALLBACK_PROGRESS_PRIORITY = [
|
||||
'to_date',
|
||||
'year',
|
||||
'month',
|
||||
'week',
|
||||
'today',
|
||||
'all',
|
||||
'this_year',
|
||||
'this_month',
|
||||
'this_week',
|
||||
];
|
||||
|
||||
const toRecord = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const toProgressNumber = (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const normalizeTimeframeKey = (value) => {
|
||||
if (!value || typeof value !== 'string') return 'all';
|
||||
return value === 'until_now' ? 'to_date' : value;
|
||||
};
|
||||
|
||||
const isProgressSlice = (value) => {
|
||||
const record = toRecord(value);
|
||||
if (!record) return false;
|
||||
if (toProgressNumber(record.count) !== null) return true;
|
||||
if (toProgressNumber(record.target) !== null) return true;
|
||||
if (typeof record.date_from === 'string' || record.date_from === null) return true;
|
||||
if (typeof record.date_end === 'string' || record.date_end === null) return true;
|
||||
if (typeof record.dateFrom === 'string' || record.dateFrom === null) return true;
|
||||
if (typeof record.dateEnd === 'string' || record.dateEnd === null) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cadenceRootByMode = {
|
||||
WEEKS: 'week',
|
||||
MONTHS: 'month',
|
||||
YEARS: 'year',
|
||||
};
|
||||
|
||||
const resolveCadenceEvery = (criteria) => {
|
||||
const parsed = Number.parseInt(String(criteria?.target_duration_every ?? ''), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 2) return null;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const resolveCadenceRoot = (criteria) => {
|
||||
const mode = String(criteria?.target_duration || '').toUpperCase();
|
||||
return cadenceRootByMode[mode] || null;
|
||||
};
|
||||
|
||||
const buildCadenceKeyCandidates = (criteria, timeframeKey) => {
|
||||
const cadenceRoot = resolveCadenceRoot(criteria);
|
||||
const cadenceEvery = resolveCadenceEvery(criteria);
|
||||
if (!cadenceRoot || !cadenceEvery) return [];
|
||||
if (timeframeKey !== cadenceRoot) return [];
|
||||
|
||||
return [
|
||||
`${cadenceEvery}_${cadenceRoot}`,
|
||||
`${cadenceEvery}_${cadenceRoot}s`,
|
||||
`${cadenceRoot}_${cadenceEvery}`,
|
||||
`${cadenceRoot}s_${cadenceEvery}`,
|
||||
`every_${cadenceEvery}_${cadenceRoot}`,
|
||||
`every_${cadenceEvery}_${cadenceRoot}s`,
|
||||
`${cadenceRoot}${cadenceEvery}`,
|
||||
`${cadenceRoot}s${cadenceEvery}`,
|
||||
];
|
||||
};
|
||||
|
||||
export const resolveGoalProgressKey = (progressScope, timeframeKey, criteria = {}) => {
|
||||
const scope = toRecord(progressScope);
|
||||
if (!scope) return null;
|
||||
|
||||
const normalizedTimeframe = normalizeTimeframeKey(timeframeKey);
|
||||
const aliases = TIMEFRAME_ALIASES[normalizedTimeframe] || [normalizedTimeframe];
|
||||
const cadenceCandidates = buildCadenceKeyCandidates(criteria, normalizedTimeframe);
|
||||
|
||||
const orderedKeys = [
|
||||
...cadenceCandidates,
|
||||
...aliases,
|
||||
...FALLBACK_PROGRESS_PRIORITY,
|
||||
...Object.keys(scope),
|
||||
];
|
||||
|
||||
const seen = new Set();
|
||||
for (const key of orderedKeys) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (isProgressSlice(scope[key])) return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveGoalProgressSlice = (progressScope, timeframeKey, criteria = {}) => {
|
||||
const scope = toRecord(progressScope);
|
||||
if (!scope) return null;
|
||||
|
||||
const key = resolveGoalProgressKey(scope, timeframeKey, criteria);
|
||||
if (!key) return null;
|
||||
return toRecord(scope[key]);
|
||||
};
|
||||
|
||||
export const resolveGoalProgressPeriod = (progressSlice, criteria = {}) => {
|
||||
const slice = toRecord(progressSlice);
|
||||
return {
|
||||
from: slice?.date_from ?? slice?.dateFrom ?? criteria?.start ?? null,
|
||||
end: slice?.date_end ?? slice?.dateEnd ?? criteria?.end ?? null,
|
||||
};
|
||||
};
|
||||
@@ -189,6 +189,29 @@ describe("DepartmentWeather", () => {
|
||||
expect(getFirstWashesCellText(wrapper)).toBe("9002");
|
||||
});
|
||||
|
||||
it("shows loading state while weather request is in flight", async () => {
|
||||
const pending = createDeferred();
|
||||
getObjectsMock.mockImplementationOnce(() => pending.promise);
|
||||
|
||||
const wrapper = mount(DepartmentWeather, {
|
||||
props: {
|
||||
department_ids: [1],
|
||||
date_from: "2026-03-24",
|
||||
date_to: "2026-03-24",
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(wrapper.find(".daily-metrics-loading").exists()).toBe(true);
|
||||
expect(wrapper.find("table.table").exists()).toBe(false);
|
||||
|
||||
pending.resolve([makeEntry({ time: "06:00", washes: 4, hours: 2 })]);
|
||||
await flushAll();
|
||||
|
||||
expect(wrapper.find(".daily-metrics-loading").exists()).toBe(false);
|
||||
expect(wrapper.find("table.table").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("hides hour columns when both washes and hours are zero", async () => {
|
||||
getObjectsMock.mockResolvedValueOnce([
|
||||
makeEntry({ time: "00:00", washes: 0, hours: 0 }),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeTimeframeKey,
|
||||
resolveGoalProgressKey,
|
||||
resolveGoalProgressPeriod,
|
||||
resolveGoalProgressSlice,
|
||||
toProgressNumber
|
||||
} from '@/views/dashboards/departmentDashboard/modules/goals/functions/goalProgressResolver.js';
|
||||
|
||||
describe('goal progress resolver', () => {
|
||||
it('normalizes until_now to to_date', () => {
|
||||
expect(normalizeTimeframeKey('until_now')).toBe('to_date');
|
||||
expect(normalizeTimeframeKey('month')).toBe('month');
|
||||
});
|
||||
|
||||
it('resolves legacy this_week alias for week timeframe', () => {
|
||||
const scope = {
|
||||
this_week: { count: 3, target: 7 }
|
||||
};
|
||||
|
||||
expect(resolveGoalProgressKey(scope, 'week', {})).toBe('this_week');
|
||||
expect(resolveGoalProgressSlice(scope, 'week', {})).toEqual({ count: 3, target: 7 });
|
||||
});
|
||||
|
||||
it('resolves cadence-aware keys for bi-weekly targets', () => {
|
||||
const scope = {
|
||||
every_2_weeks: { count: 5, target: 14 },
|
||||
week: { count: 1, target: 7 }
|
||||
};
|
||||
const criteria = {
|
||||
target_duration: 'WEEKS',
|
||||
target_duration_every: 2
|
||||
};
|
||||
|
||||
expect(resolveGoalProgressKey(scope, 'week', criteria)).toBe('every_2_weeks');
|
||||
expect(resolveGoalProgressSlice(scope, 'week', criteria)).toEqual({ count: 5, target: 14 });
|
||||
});
|
||||
|
||||
it('falls back to preferred OpenAPI keys when selected key is missing', () => {
|
||||
const scope = {
|
||||
to_date: { count: 21, target: 30 },
|
||||
all: { count: 42, target: 100 }
|
||||
};
|
||||
|
||||
expect(resolveGoalProgressKey(scope, 'month', {})).toBe('to_date');
|
||||
});
|
||||
|
||||
it('extracts period from snake_case or camelCase date fields', () => {
|
||||
expect(resolveGoalProgressPeriod(
|
||||
{ date_from: '2026-03-24T00:00:00+01:00', date_end: '2026-04-06T23:59:59+01:00' },
|
||||
{ start: '2026-03-01T00:00:00+01:00', end: '2026-05-01T23:59:59+01:00' }
|
||||
)).toEqual({
|
||||
from: '2026-03-24T00:00:00+01:00',
|
||||
end: '2026-04-06T23:59:59+01:00'
|
||||
});
|
||||
|
||||
expect(resolveGoalProgressPeriod(
|
||||
{ dateFrom: '2026-03-24T00:00:00+01:00', dateEnd: '2026-04-06T23:59:59+01:00' },
|
||||
{ start: '2026-03-01T00:00:00+01:00', end: '2026-05-01T23:59:59+01:00' }
|
||||
)).toEqual({
|
||||
from: '2026-03-24T00:00:00+01:00',
|
||||
end: '2026-04-06T23:59:59+01:00'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses numeric strings safely for progress values', () => {
|
||||
expect(toProgressNumber('12.5')).toBe(12.5);
|
||||
expect(toProgressNumber(8)).toBe(8);
|
||||
expect(toProgressNumber('')).toBe(null);
|
||||
expect(toProgressNumber('abc')).toBe(null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user