@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.
@@ -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
- const [logsRes, siteInfo] = await Promise.all([
106
- getPatrolLogs({
107
- page: 1,
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.189",
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
- checkedPercentage: number;
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
+ }