@7365admin1/layer-common 3.2.2-staging.189 → 3.2.2-staging.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +4 -2
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/package.json +1 -1
- package/types/nfc-patrol-report.ts +3 -2
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
</template>
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
21
|
+
for a 500 states something about the data the server never said. -->
|
|
22
|
+
<ReportEmptyState
|
|
23
|
+
v-else-if="!loading && failed"
|
|
24
|
+
title="Report unavailable"
|
|
25
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
26
|
+
/>
|
|
20
27
|
<ReportEmptyState
|
|
21
28
|
v-else-if="!loading && notFound"
|
|
22
29
|
title="No Daily Report"
|
|
@@ -51,7 +58,7 @@ defineEmits<{
|
|
|
51
58
|
export: [type: "pdf" | "csv"];
|
|
52
59
|
}>();
|
|
53
60
|
|
|
54
|
-
const { report, loading, notFound, fetchReport } = useNFCPatrolDailyReport(
|
|
61
|
+
const { report, loading, notFound, failed, fetchReport } = useNFCPatrolDailyReport(
|
|
55
62
|
props.filters,
|
|
56
63
|
props.site,
|
|
57
64
|
);
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
/>
|
|
22
22
|
</template>
|
|
23
23
|
|
|
24
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
25
|
+
for a 500 states something about the data the server never said. -->
|
|
26
|
+
<ReportEmptyState
|
|
27
|
+
v-else-if="!loading && failed"
|
|
28
|
+
title="Report unavailable"
|
|
29
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
30
|
+
/>
|
|
24
31
|
<ReportEmptyState
|
|
25
32
|
v-else-if="!loading"
|
|
26
33
|
title="No Monthly Report"
|
|
@@ -55,7 +62,7 @@ defineEmits<{
|
|
|
55
62
|
export: [type: "pdf" | "csv"];
|
|
56
63
|
}>();
|
|
57
64
|
|
|
58
|
-
const { report, loading, notFound, fetchReport } =
|
|
65
|
+
const { report, loading, notFound, failed, fetchReport } =
|
|
59
66
|
useNFCPatrolMonthlyReport(props.filters, props.site);
|
|
60
67
|
|
|
61
68
|
watch(
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
{{ row.checked }}
|
|
27
27
|
</td>
|
|
28
28
|
|
|
29
|
+
<!-- A month with no checkpoint due has no rate. "0%" there reads as
|
|
30
|
+
total failure, so it says nothing instead. -->
|
|
29
31
|
<td class="text-center">
|
|
30
|
-
{{ row.checkedPercentage }}
|
|
32
|
+
{{ row.checkedPercentage === null ? "—" : `${row.checkedPercentage}%` }}
|
|
31
33
|
</td>
|
|
32
34
|
|
|
33
35
|
<td class="text-center">
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
</td>
|
|
36
38
|
|
|
37
39
|
<td class="text-center">
|
|
38
|
-
{{ row.missedPercentage }}
|
|
40
|
+
{{ row.missedPercentage === null ? "—" : `${row.missedPercentage}%` }}
|
|
39
41
|
</td>
|
|
40
42
|
</tr>
|
|
41
43
|
</tbody>
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</template>
|
|
24
24
|
|
|
25
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
26
|
+
for a 500 states something about the data the server never said. -->
|
|
27
|
+
<ReportEmptyState
|
|
28
|
+
v-else-if="!loading && failed"
|
|
29
|
+
title="Report unavailable"
|
|
30
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
31
|
+
/>
|
|
25
32
|
<ReportEmptyState
|
|
26
33
|
v-else-if="!loading && notFound"
|
|
27
34
|
title="No Patrol Log"
|
|
@@ -63,6 +70,7 @@ const {
|
|
|
63
70
|
report,
|
|
64
71
|
loading,
|
|
65
72
|
notFound,
|
|
73
|
+
failed,
|
|
66
74
|
selectedActivityRemarks,
|
|
67
75
|
dialogRemarks,
|
|
68
76
|
fetchReport,
|
|
@@ -52,16 +52,24 @@ export function useNFCPatrolDailyReport(
|
|
|
52
52
|
const report = ref<NFCPatrolSummaryReport | null>(null);
|
|
53
53
|
const loading = ref(false);
|
|
54
54
|
const notFound = ref(false);
|
|
55
|
+
/**
|
|
56
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
57
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
58
|
+
* a statement about the data the server never made.
|
|
59
|
+
*/
|
|
60
|
+
const failed = ref(false);
|
|
55
61
|
|
|
56
62
|
async function fetchReport() {
|
|
57
63
|
if (!site || !filters.date || !filters.route) {
|
|
58
64
|
report.value = null;
|
|
59
65
|
notFound.value = false;
|
|
66
|
+
failed.value = false;
|
|
60
67
|
return;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
loading.value = true;
|
|
64
71
|
notFound.value = false;
|
|
72
|
+
failed.value = false;
|
|
65
73
|
|
|
66
74
|
try {
|
|
67
75
|
const [logsRes, siteInfo] = await Promise.all([
|
|
@@ -92,6 +100,10 @@ export function useNFCPatrolDailyReport(
|
|
|
92
100
|
report.value = null;
|
|
93
101
|
notFound.value = true;
|
|
94
102
|
}
|
|
103
|
+
} catch {
|
|
104
|
+
report.value = null;
|
|
105
|
+
notFound.value = false;
|
|
106
|
+
failed.value = true;
|
|
95
107
|
} finally {
|
|
96
108
|
loading.value = false;
|
|
97
109
|
}
|
|
@@ -101,6 +113,7 @@ export function useNFCPatrolDailyReport(
|
|
|
101
113
|
report,
|
|
102
114
|
loading,
|
|
103
115
|
notFound,
|
|
116
|
+
failed,
|
|
104
117
|
fetchReport,
|
|
105
118
|
};
|
|
106
119
|
}
|
|
@@ -2,11 +2,11 @@ import type {
|
|
|
2
2
|
NFCPatrolReportFilters,
|
|
3
3
|
NFCPatrolSummaryReport,
|
|
4
4
|
NFCPatrolReportSummaryRow,
|
|
5
|
-
NFCPatrolMonthlyReportRow,
|
|
6
5
|
NFCPatrolMonthlyReport,
|
|
7
6
|
} from "../types/nfc-patrol-report";
|
|
8
7
|
import useNFCPatrolLog from "./useNFCPatrolLog";
|
|
9
8
|
import useSite from "./useSite";
|
|
9
|
+
import { collectAllPages, mapLogsToMonthlyRows } from "../utils/nfc-patrol-report";
|
|
10
10
|
|
|
11
11
|
interface NFCPatrolLogListResponse {
|
|
12
12
|
items?: Record<string, any>[];
|
|
@@ -14,55 +14,6 @@ interface NFCPatrolLogListResponse {
|
|
|
14
14
|
pageRange?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const MONTHS = [
|
|
18
|
-
"January",
|
|
19
|
-
"February",
|
|
20
|
-
"March",
|
|
21
|
-
"April",
|
|
22
|
-
"May",
|
|
23
|
-
"June",
|
|
24
|
-
"July",
|
|
25
|
-
"August",
|
|
26
|
-
"September",
|
|
27
|
-
"October",
|
|
28
|
-
"November",
|
|
29
|
-
"December",
|
|
30
|
-
];
|
|
31
|
-
function mapLogsToMonthlyRows(
|
|
32
|
-
logs: Record<string, any>[],
|
|
33
|
-
): NFCPatrolMonthlyReportRow[] {
|
|
34
|
-
const rows = MONTHS.map((month) => ({
|
|
35
|
-
month,
|
|
36
|
-
checked: 0,
|
|
37
|
-
checkedPercentage: 0,
|
|
38
|
-
missed: 0,
|
|
39
|
-
missedPercentage: 0,
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
|
-
logs.forEach((log) => {
|
|
43
|
-
const monthIndex = new Date(log.date).getMonth();
|
|
44
|
-
|
|
45
|
-
for (const checkpoint of log.checkPoints ?? []) {
|
|
46
|
-
if (checkpoint.status === "Completed") {
|
|
47
|
-
rows[monthIndex].checked++;
|
|
48
|
-
} else if (checkpoint.status === "Skipped") {
|
|
49
|
-
rows[monthIndex].missed++;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
rows.forEach((row) => {
|
|
55
|
-
const total = row.checked + row.missed;
|
|
56
|
-
|
|
57
|
-
if (total > 0) {
|
|
58
|
-
row.checkedPercentage = Math.round((row.checked / total) * 100);
|
|
59
|
-
row.missedPercentage = Math.round((row.missed / total) * 100);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return rows;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
17
|
function buildAddress(address?: Record<string, string>) {
|
|
67
18
|
if (!address) return undefined;
|
|
68
19
|
|
|
@@ -90,32 +41,42 @@ export function useNFCPatrolMonthlyReport(
|
|
|
90
41
|
const report = ref<NFCPatrolMonthlyReport | null>(null);
|
|
91
42
|
const loading = ref(false);
|
|
92
43
|
const notFound = ref(false);
|
|
44
|
+
/**
|
|
45
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
46
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
47
|
+
* a statement about the data the server never made.
|
|
48
|
+
*/
|
|
49
|
+
const failed = ref(false);
|
|
93
50
|
|
|
94
51
|
async function fetchReport() {
|
|
95
52
|
if (!site || !filters.route) {
|
|
96
53
|
report.value = null;
|
|
97
54
|
notFound.value = false;
|
|
55
|
+
failed.value = false;
|
|
98
56
|
return;
|
|
99
57
|
}
|
|
100
58
|
|
|
101
59
|
loading.value = true;
|
|
102
60
|
notFound.value = false;
|
|
61
|
+
failed.value = false;
|
|
103
62
|
const currentYear = new Date().getFullYear().toString();
|
|
104
63
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
64
|
+
// The whole year, not page one of it. `collectAllPages` reads the page
|
|
65
|
+
// count out of the first reply and walks the rest.
|
|
66
|
+
const [logs, siteInfo] = await Promise.all([
|
|
67
|
+
collectAllPages((page) =>
|
|
68
|
+
getPatrolLogs({
|
|
69
|
+
page,
|
|
108
70
|
limit: 100,
|
|
109
71
|
site,
|
|
110
72
|
routeId: filters.route,
|
|
111
73
|
date: currentYear,
|
|
112
74
|
type: "month",
|
|
113
|
-
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
114
77
|
getSiteById(site),
|
|
115
78
|
]);
|
|
116
79
|
|
|
117
|
-
const logs = logsRes.items ?? [];
|
|
118
|
-
|
|
119
80
|
if (logs.length) {
|
|
120
81
|
const routeName = logs[0]?.route?.name ?? "-";
|
|
121
82
|
|
|
@@ -131,6 +92,10 @@ export function useNFCPatrolMonthlyReport(
|
|
|
131
92
|
report.value = null;
|
|
132
93
|
notFound.value = true;
|
|
133
94
|
}
|
|
95
|
+
} catch {
|
|
96
|
+
report.value = null;
|
|
97
|
+
notFound.value = false;
|
|
98
|
+
failed.value = true;
|
|
134
99
|
} finally {
|
|
135
100
|
loading.value = false;
|
|
136
101
|
}
|
|
@@ -140,6 +105,7 @@ export function useNFCPatrolMonthlyReport(
|
|
|
140
105
|
report,
|
|
141
106
|
loading,
|
|
142
107
|
notFound,
|
|
108
|
+
failed,
|
|
143
109
|
fetchReport,
|
|
144
110
|
};
|
|
145
111
|
}
|
|
@@ -91,6 +91,12 @@ export function useNFCPatrolReport(
|
|
|
91
91
|
const report = ref<NFCPatrolReport | null>(null);
|
|
92
92
|
const loading = ref(false);
|
|
93
93
|
const notFound = ref(false);
|
|
94
|
+
/**
|
|
95
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
96
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
97
|
+
* a statement about the data the server never made.
|
|
98
|
+
*/
|
|
99
|
+
const failed = ref(false);
|
|
94
100
|
const selectedActivityRemarks = ref("");
|
|
95
101
|
const dialogRemarks = ref(false);
|
|
96
102
|
|
|
@@ -98,11 +104,13 @@ export function useNFCPatrolReport(
|
|
|
98
104
|
if (!site || !filters.date || !filters.route || !filters.timeRange) {
|
|
99
105
|
report.value = null;
|
|
100
106
|
notFound.value = false;
|
|
107
|
+
failed.value = false;
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
loading.value = true;
|
|
105
112
|
notFound.value = false;
|
|
113
|
+
failed.value = false;
|
|
106
114
|
|
|
107
115
|
try {
|
|
108
116
|
const [logsRes, siteInfo] = await Promise.all([
|
|
@@ -128,6 +136,10 @@ export function useNFCPatrolReport(
|
|
|
128
136
|
report.value = null;
|
|
129
137
|
notFound.value = true;
|
|
130
138
|
}
|
|
139
|
+
} catch {
|
|
140
|
+
report.value = null;
|
|
141
|
+
notFound.value = false;
|
|
142
|
+
failed.value = true;
|
|
131
143
|
} finally {
|
|
132
144
|
loading.value = false;
|
|
133
145
|
}
|
|
@@ -165,6 +177,7 @@ export function useNFCPatrolReport(
|
|
|
165
177
|
report,
|
|
166
178
|
loading,
|
|
167
179
|
notFound,
|
|
180
|
+
failed,
|
|
168
181
|
selectedActivityRemarks,
|
|
169
182
|
dialogRemarks,
|
|
170
183
|
fetchReport,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.2-staging.
|
|
5
|
+
"version": "3.2.2-staging.190",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -85,9 +85,10 @@ export interface NFCPatrolSummaryReport {
|
|
|
85
85
|
export interface NFCPatrolMonthlyReportRow {
|
|
86
86
|
month: string;
|
|
87
87
|
checked: number;
|
|
88
|
-
|
|
88
|
+
/** `null` when no checkpoint was due that month - see `utils/nfc-patrol-report.ts`. */
|
|
89
|
+
checkedPercentage: number | null;
|
|
89
90
|
missed: number;
|
|
90
|
-
missedPercentage: number;
|
|
91
|
+
missedPercentage: number | null;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export interface NFCPatrolMonthlyReport {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { collectAllPages, mapLogsToMonthlyRows } from "./nfc-patrol-report.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
+
*
|
|
9
|
+
* The yearly patrol-compliance table was built from `page: 1, limit: 100` of a
|
|
10
|
+
* paged endpoint and never looked at `pages`. A route patrolled daily produces
|
|
11
|
+
* ~300 runs a year, so months past the first page rendered "0 / 0% / 0 / 0%" -
|
|
12
|
+
* indistinguishable from a month in which every checkpoint was missed, in a
|
|
13
|
+
* table a manager can print and hand to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 12 months x 25 runs x (4 completed + 1 skipped). Every month is 80%/20%. */
|
|
17
|
+
function yearOfLogs() {
|
|
18
|
+
const logs: Record<string, any>[] = [];
|
|
19
|
+
for (let m = 0; m < 12; m++) {
|
|
20
|
+
for (let d = 1; d <= 25; d++) {
|
|
21
|
+
const checkPoints = [
|
|
22
|
+
{ status: "Completed" },
|
|
23
|
+
{ status: "Completed" },
|
|
24
|
+
{ status: "Completed" },
|
|
25
|
+
{ status: "Completed" },
|
|
26
|
+
{ status: "Skipped" },
|
|
27
|
+
];
|
|
28
|
+
logs.push({
|
|
29
|
+
date: `2026-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T08:00:00.000Z`,
|
|
30
|
+
checkPoints,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return logs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pagedFetcher(all: Record<string, any>[], limit = 100) {
|
|
38
|
+
const pages = Math.ceil(all.length / limit);
|
|
39
|
+
const seen: number[] = [];
|
|
40
|
+
return {
|
|
41
|
+
seen,
|
|
42
|
+
fetchPage: async (page: number) => {
|
|
43
|
+
seen.push(page);
|
|
44
|
+
return { items: all.slice((page - 1) * limit, page * limit), pages };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("every page the endpoint reports is read, not just the first", async () => {
|
|
50
|
+
const all = yearOfLogs();
|
|
51
|
+
const { fetchPage, seen } = pagedFetcher(all);
|
|
52
|
+
|
|
53
|
+
const items = await collectAllPages(fetchPage);
|
|
54
|
+
|
|
55
|
+
assert.equal(items.length, all.length, "all 300 runs collected");
|
|
56
|
+
assert.deepEqual(seen, [1, 2, 3], "walked every page the reply declared");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a full year reports the same rate in every month", async () => {
|
|
60
|
+
const items = await collectAllPages(pagedFetcher(yearOfLogs()).fetchPage);
|
|
61
|
+
const rows = mapLogsToMonthlyRows(items);
|
|
62
|
+
|
|
63
|
+
assert.equal(rows.length, 12);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.equal(row.checked, 100, `${row.month} checked`);
|
|
66
|
+
assert.equal(row.missed, 25, `${row.month} missed`);
|
|
67
|
+
assert.equal(row.checkedPercentage, 80, `${row.month} % checked`);
|
|
68
|
+
assert.equal(row.missedPercentage, 20, `${row.month} % missed`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("only the first page would have zeroed the rest of the year", () => {
|
|
73
|
+
// What the screen used to do: one page of 100, the other 200 runs dropped.
|
|
74
|
+
const firstPageOnly = yearOfLogs().slice(0, 100);
|
|
75
|
+
const rows = mapLogsToMonthlyRows(firstPageOnly);
|
|
76
|
+
|
|
77
|
+
assert.equal(rows[0].checkedPercentage, 80, "January measured");
|
|
78
|
+
assert.equal(rows[4].checked, 0, "May had no runs on page 1");
|
|
79
|
+
assert.equal(
|
|
80
|
+
rows[4].checkedPercentage,
|
|
81
|
+
null,
|
|
82
|
+
"and it must NOT read 0% - nothing was measured",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a month with no checkpoint due reports no rate at all", () => {
|
|
87
|
+
const rows = mapLogsToMonthlyRows([]);
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
assert.equal(row.checked, 0);
|
|
91
|
+
assert.equal(row.missed, 0);
|
|
92
|
+
assert.equal(row.checkedPercentage, null);
|
|
93
|
+
assert.equal(row.missedPercentage, null);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a month where every checkpoint was missed still reports 0%", () => {
|
|
98
|
+
const rows = mapLogsToMonthlyRows([
|
|
99
|
+
{ date: "2026-03-04T08:00:00.000Z", checkPoints: [{ status: "Skipped" }, { status: "Skipped" }] },
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
assert.equal(rows[2].checkedPercentage, 0, "0% here is a real measurement");
|
|
103
|
+
assert.equal(rows[2].missedPercentage, 100);
|
|
104
|
+
assert.equal(rows[0].checkedPercentage, null, "January measured nothing");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a single-page reply is not re-fetched", async () => {
|
|
108
|
+
const { fetchPage, seen } = pagedFetcher(yearOfLogs().slice(0, 10));
|
|
109
|
+
await collectAllPages(fetchPage);
|
|
110
|
+
assert.deepEqual(seen, [1]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a reply with no page count is treated as one page", async () => {
|
|
114
|
+
const calls: number[] = [];
|
|
115
|
+
const items = await collectAllPages(async (page) => {
|
|
116
|
+
calls.push(page);
|
|
117
|
+
return { items: [{ date: "2026-01-02T00:00:00.000Z", checkPoints: [] }] };
|
|
118
|
+
});
|
|
119
|
+
assert.deepEqual(calls, [1]);
|
|
120
|
+
assert.equal(items.length, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("undated or malformed logs are skipped, not counted into January", () => {
|
|
124
|
+
const rows = mapLogsToMonthlyRows([
|
|
125
|
+
{ date: "not-a-date", checkPoints: [{ status: "Completed" }] },
|
|
126
|
+
{ checkPoints: [{ status: "Completed" }] },
|
|
127
|
+
]);
|
|
128
|
+
assert.equal(rows[0].checked, 0);
|
|
129
|
+
assert.equal(rows[0].checkedPercentage, null);
|
|
130
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patrol-report arithmetic that is worth testing on its own.
|
|
3
|
+
*
|
|
4
|
+
* The yearly compliance table used to be built from `page: 1, limit: 100` of a
|
|
5
|
+
* paged endpoint. A route patrolled daily produces far more than 100 runs a
|
|
6
|
+
* year, so every month past the first page rendered "0 / 0% / 0 / 0%" - eight
|
|
7
|
+
* months of measured zeros in a table a manager can print and hand to a
|
|
8
|
+
* client. The page walk and the month mapping live here so they have tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One page of the patrol-log endpoint's reply. */
|
|
12
|
+
export type TPatrolLogPage = {
|
|
13
|
+
items?: Record<string, any>[];
|
|
14
|
+
pages?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TPatrolMonthRow = {
|
|
18
|
+
month: string;
|
|
19
|
+
checked: number;
|
|
20
|
+
missed: number;
|
|
21
|
+
/** `null` when no checkpoint was due that month - a rate over nothing is not a measurement. */
|
|
22
|
+
checkedPercentage: number | null;
|
|
23
|
+
missedPercentage: number | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const MONTHS = [
|
|
27
|
+
"January",
|
|
28
|
+
"February",
|
|
29
|
+
"March",
|
|
30
|
+
"April",
|
|
31
|
+
"May",
|
|
32
|
+
"June",
|
|
33
|
+
"July",
|
|
34
|
+
"August",
|
|
35
|
+
"September",
|
|
36
|
+
"October",
|
|
37
|
+
"November",
|
|
38
|
+
"December",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read every page the endpoint says exists, not just the first.
|
|
43
|
+
*
|
|
44
|
+
* ponytail: sequential. A year of one route is a handful of pages, and firing
|
|
45
|
+
* them in parallel would only trade a few hundred ms for a burst on the API.
|
|
46
|
+
* `maxPages` is a runaway guard, not a product limit - if it ever bites, the
|
|
47
|
+
* caller is asking for a range the report was not designed for.
|
|
48
|
+
*/
|
|
49
|
+
export async function collectAllPages(
|
|
50
|
+
fetchPage: (page: number) => Promise<TPatrolLogPage>,
|
|
51
|
+
maxPages = 50,
|
|
52
|
+
): Promise<Record<string, any>[]> {
|
|
53
|
+
const first = await fetchPage(1);
|
|
54
|
+
const items = [...(first?.items ?? [])];
|
|
55
|
+
const pages = Math.min(Number(first?.pages) || 1, maxPages);
|
|
56
|
+
|
|
57
|
+
for (let page = 2; page <= pages; page++) {
|
|
58
|
+
const next = await fetchPage(page);
|
|
59
|
+
items.push(...(next?.items ?? []));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One row per calendar month. A month with no checkpoint due keeps its zero
|
|
67
|
+
* counts - zero checkpoints really were checked - but reports `null` for both
|
|
68
|
+
* percentages, because a percentage of nothing is not a measurement and "0%"
|
|
69
|
+
* reads exactly like total failure.
|
|
70
|
+
*/
|
|
71
|
+
export function mapLogsToMonthlyRows(
|
|
72
|
+
logs: Record<string, any>[],
|
|
73
|
+
): TPatrolMonthRow[] {
|
|
74
|
+
const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
|
|
75
|
+
month,
|
|
76
|
+
checked: 0,
|
|
77
|
+
missed: 0,
|
|
78
|
+
checkedPercentage: null,
|
|
79
|
+
missedPercentage: null,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
for (const log of logs) {
|
|
83
|
+
const monthIndex = new Date(log?.date).getMonth();
|
|
84
|
+
if (Number.isNaN(monthIndex)) continue;
|
|
85
|
+
|
|
86
|
+
for (const checkpoint of log?.checkPoints ?? []) {
|
|
87
|
+
if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
|
|
88
|
+
else if (checkpoint?.status === "Skipped") rows[monthIndex].missed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const total = row.checked + row.missed;
|
|
94
|
+
if (total > 0) {
|
|
95
|
+
row.checkedPercentage = Math.round((row.checked / total) * 100);
|
|
96
|
+
row.missedPercentage = Math.round((row.missed / total) * 100);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows;
|
|
101
|
+
}
|