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:
Jeppe Bundgaard
2026-03-24 14:58:48 +01:00
parent 8b8780bbaf
commit 1e6590e2df
6 changed files with 334 additions and 40 deletions
+1 -1
View File
@@ -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,
@@ -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>
@@ -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,
};
};
+23
View File
@@ -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 }),
+72
View File
@@ -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);
});
});