@7365admin1/module-hygiene 4.28.1-staging.7 → 4.28.1-staging.9

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.
@@ -0,0 +1,29 @@
1
+ ---
2
+ "@7365admin1/module-hygiene": patch
3
+ ---
4
+
5
+ Hygiene dashboard metrics: report the selected period honestly instead of fabricating figures
6
+
7
+ The dashboard's metric calculations were computed inline inside
8
+ `hygiene-dashboard.repository.ts` and were wrong in four ways. They now live in
9
+ `src/utils/hygiene-dashboard-metrics.util.ts`, covered by unit tests (`yarn test`),
10
+ so a change to a metric fails a test rather than a screen. What a consumer of the
11
+ dashboard endpoints sees change:
12
+
13
+ - **Trend comparisons follow the selected period.** The "vs previous" figure was
14
+ always computed against yesterday, whatever range the user picked, so a
15
+ month-to-month comparison was really a day-to-day one. It now compares the
16
+ selected period against the period immediately before it.
17
+ - **Day boundaries are Singapore time.** Period start/end were taken from the
18
+ server host's midnight, so a host on UTC put work into the wrong day. All
19
+ boundaries are now computed in `Asia/Singapore`.
20
+ - **No invented percentages on an empty prior period.** When the previous period
21
+ had no data, the change was reported as `100%` (or `0`) as if measured. It now
22
+ returns `null`, so the UI can show "no comparison available" rather than a
23
+ number nobody can reproduce.
24
+ - **The supply alert percentage is no longer hard-coded.** It was emitted as a
25
+ fixed value regardless of stock; it is now derived, and reports its reason when
26
+ there is no prior period to compare against.
27
+
28
+ No API surface changes and no consumer code change is required — the same fields
29
+ are returned, with `null` now possible where a comparison genuinely cannot be made.
package/dist/index.js CHANGED
@@ -93,9 +93,57 @@ var allowedStatus = [
93
93
  var allowedPeriods = ["today", "thisWeek", "thisMonth"];
94
94
 
95
95
  // src/repositories/hygiene-dashboard.repository.ts
96
- var import_moment_timezone = __toESM(require("moment-timezone"));
96
+ var import_moment_timezone2 = __toESM(require("moment-timezone"));
97
97
  var import_mongodb = require("mongodb");
98
98
  var import_node_server_utils = require("@7365admin1/node-server-utils");
99
+
100
+ // src/utils/hygiene-dashboard-metrics.util.ts
101
+ var import_moment_timezone = __toESM(require("moment-timezone"));
102
+ var DASHBOARD_TIMEZONE = "Asia/Singapore";
103
+ var PERIOD_UNITS = {
104
+ thisWeek: { boundary: "isoWeek", step: "week" },
105
+ thisMonth: { boundary: "month", step: "month" }
106
+ };
107
+ var DAY_UNIT = {
108
+ boundary: "day",
109
+ step: "day"
110
+ };
111
+ function unitsOf(period) {
112
+ return PERIOD_UNITS[period] ?? DAY_UNIT;
113
+ }
114
+ function at(now) {
115
+ return now === void 0 || now === null ? import_moment_timezone.default.tz(DASHBOARD_TIMEZONE) : import_moment_timezone.default.tz(now, DASHBOARD_TIMEZONE);
116
+ }
117
+ function getPeriodRange(period, now) {
118
+ const { boundary } = unitsOf(period);
119
+ const point = at(now);
120
+ return {
121
+ $gte: point.clone().startOf(boundary).toDate(),
122
+ $lte: point.clone().endOf(boundary).toDate()
123
+ };
124
+ }
125
+ function getPreviousPeriodRange(period, now) {
126
+ const { boundary, step } = unitsOf(period);
127
+ const point = at(now).subtract(1, step);
128
+ return {
129
+ $gte: point.clone().startOf(boundary).toDate(),
130
+ $lte: point.clone().endOf(boundary).toDate()
131
+ };
132
+ }
133
+ function calculatePercentageChange(currentCount, previousCount) {
134
+ if (typeof currentCount !== "number" || !Number.isFinite(currentCount)) {
135
+ return null;
136
+ }
137
+ if (typeof previousCount !== "number" || !Number.isFinite(previousCount)) {
138
+ return null;
139
+ }
140
+ if (previousCount === 0)
141
+ return null;
142
+ return Math.round((currentCount - previousCount) / previousCount * 100 * 100) / 100;
143
+ }
144
+ var SUPPLY_ALERT_HAS_NO_PRIOR_PERIOD = "Supply alerts count what is out of stock right now, and the supply record keeps no history, so there is no earlier period to compare against.";
145
+
146
+ // src/repositories/hygiene-dashboard.repository.ts
99
147
  function useHygieneDashboardRepository() {
100
148
  const db = import_node_server_utils.useAtlas.getDb();
101
149
  if (!db) {
@@ -166,59 +214,16 @@ function useHygieneDashboardRepository() {
166
214
  import_node_server_utils.logger.info(`Cache hit for dashboard: ${dashboardCacheKey}`);
167
215
  return cachedDashboard;
168
216
  }
169
- function formatEndDate(date) {
170
- if (typeof date === "string") {
171
- const dateWithoutTimezone = date.replace(/\s*\+\d{2}:\d{2}$/, "");
172
- date = new Date(dateWithoutTimezone);
173
- }
174
- const singaporeMoment = import_moment_timezone.default.tz(date, "Asia/Singapore");
175
- singaporeMoment.set({
176
- hour: 23,
177
- minute: 59,
178
- second: 59,
179
- millisecond: 999
180
- });
181
- return singaporeMoment.toDate();
182
- }
183
- function getDateRange(p) {
184
- const start = /* @__PURE__ */ new Date();
185
- start.setHours(0, 0, 0, 0);
186
- if (p === "today") {
187
- return { $gte: start, $lte: formatEndDate(start) };
188
- }
189
- const days = p === "thisWeek" ? 7 : 30;
190
- const rangeStart = /* @__PURE__ */ new Date();
191
- rangeStart.setDate(start.getDate() - days);
192
- rangeStart.setHours(0, 0, 0, 0);
193
- return { $gte: rangeStart, $lte: formatEndDate(/* @__PURE__ */ new Date()) };
194
- }
195
- const calculatePercentageChange = (currentCount, previousCount) => {
196
- if (previousCount === 0) {
197
- return currentCount > 0 ? 100 : 0;
198
- }
199
- return Math.round(
200
- (currentCount - previousCount) / previousCount * 100 * 100
201
- ) / 100;
202
- };
203
217
  const workOrderService = workOrderServiceMap[serviceType] ?? serviceType;
204
218
  try {
205
- const today = /* @__PURE__ */ new Date();
206
- today.setHours(0, 0, 0, 0);
207
- const yesterday = /* @__PURE__ */ new Date();
208
- yesterday.setDate(today.getDate() - 1);
209
- yesterday.setHours(0, 0, 0, 0);
210
- const yesterdayEnd = new Date(yesterday);
211
- yesterdayEnd.setHours(23, 59, 59, 999);
212
- const todayEnd = formatEndDate(/* @__PURE__ */ new Date());
213
- const periodRange = getDateRange(period);
219
+ const periodRange = getPeriodRange(period);
220
+ const priorPeriodRange = getPreviousPeriodRange(period);
214
221
  const [
215
222
  workOrderReport,
216
223
  supplyAlertReport,
217
224
  taskCompletedReport,
218
- yesterdayWorkOrderReport,
219
- todayWorkOrderReport,
220
- yesterdayTaskCompletedReport,
221
- todayTaskCompletedReport
225
+ priorWorkOrderReport,
226
+ priorTaskCompletedReport
222
227
  ] = await Promise.all([
223
228
  // 1. Open work orders (period-filtered) with inProgress breakdown
224
229
  workOrderCollection.aggregate([
@@ -262,45 +267,28 @@ function useHygieneDashboardRepository() {
262
267
  }
263
268
  }
264
269
  ]).toArray(),
265
- // 4. Yesterday open work orders (for % change)
270
+ /*
271
+ 4. Open work orders in the PREVIOUS period (for % change).
272
+
273
+ The current side of the comparison is no longer queried separately:
274
+ it is now the same range as tile 1, so `wFacet.total` below IS the
275
+ current count. Two aggregations dropped, not by cutting a measure but
276
+ because the fix made them duplicates.
277
+ */
266
278
  workOrderCollection.aggregate([
267
279
  {
268
280
  $match: {
269
281
  site,
270
282
  service: workOrderService,
271
- createdAt: { $gte: yesterday, $lte: yesterdayEnd },
283
+ createdAt: priorPeriodRange,
272
284
  status: { $nin: ["completed"] }
273
285
  }
274
286
  },
275
287
  { $count: "count" }
276
288
  ]).toArray(),
277
- // 5. Today open work orders (for % change)
278
- workOrderCollection.aggregate([
279
- {
280
- $match: {
281
- site,
282
- service: workOrderService,
283
- createdAt: { $gte: today, $lte: todayEnd },
284
- status: { $nin: ["completed"] }
285
- }
286
- },
287
- { $count: "count" }
288
- ]).toArray(),
289
- // 6. Yesterday task completed — area checklists (for % change)
290
- areaChecklistCollection.aggregate([
291
- ...areaChecklistScheduleLookup(site, serviceType, {
292
- $gte: yesterday,
293
- $lte: yesterdayEnd
294
- }),
295
- { $match: { status: "completed" } },
296
- { $count: "count" }
297
- ]).toArray(),
298
- // 7. Today task completed — area checklists (for % change)
289
+ // 5. Task completed in the PREVIOUS period — area checklists (for % change)
299
290
  areaChecklistCollection.aggregate([
300
- ...areaChecklistScheduleLookup(site, serviceType, {
301
- $gte: today,
302
- $lte: todayEnd
303
- }),
291
+ ...areaChecklistScheduleLookup(site, serviceType, priorPeriodRange),
304
292
  { $match: { status: "completed" } },
305
293
  { $count: "count" }
306
294
  ]).toArray()
@@ -316,21 +304,26 @@ function useHygieneDashboardRepository() {
316
304
  count: wFacet.total[0]?.count ?? 0,
317
305
  inProgress: wFacet.inProgress[0]?.count ?? 0,
318
306
  percentage: calculatePercentageChange(
319
- todayWorkOrderReport[0]?.count ?? 0,
320
- yesterdayWorkOrderReport[0]?.count ?? 0
307
+ wFacet.total[0]?.count ?? 0,
308
+ priorWorkOrderReport[0]?.count ?? 0
321
309
  )
322
310
  },
323
311
  supplyAlert: {
312
+ // The count is real: active supplies at qty 0 for this site and
313
+ // service. The percentage beside it was the literal 0 and had never
314
+ // been computed from anything — see the util for why there is no
315
+ // prior period to compute one from.
324
316
  count: supplyAlertReport[0]?.count ?? 0,
325
- percentage: 0
317
+ percentage: null,
318
+ percentageUnavailableReason: SUPPLY_ALERT_HAS_NO_PRIOR_PERIOD
326
319
  },
327
320
  taskCompleted: {
328
321
  completed: tFacet.completed[0]?.count ?? 0,
329
322
  total: tFacet.total[0]?.count ?? 0,
330
323
  inProgress: tFacet.inProgress[0]?.count ?? 0,
331
324
  percentage: calculatePercentageChange(
332
- todayTaskCompletedReport[0]?.count ?? 0,
333
- yesterdayTaskCompletedReport[0]?.count ?? 0
325
+ tFacet.completed[0]?.count ?? 0,
326
+ priorTaskCompletedReport[0]?.count ?? 0
334
327
  )
335
328
  }
336
329
  };
@@ -368,8 +361,8 @@ function useHygieneDashboardRepository() {
368
361
  return cachedData;
369
362
  }
370
363
  const workOrderService = workOrderServiceMap[serviceType] ?? serviceType;
371
- const weekStart = import_moment_timezone.default.tz("Asia/Singapore").startOf("isoWeek").toDate();
372
- const weekEnd = import_moment_timezone.default.tz("Asia/Singapore").endOf("isoWeek").toDate();
364
+ const weekStart = import_moment_timezone2.default.tz("Asia/Singapore").startOf("isoWeek").toDate();
365
+ const weekEnd = import_moment_timezone2.default.tz("Asia/Singapore").endOf("isoWeek").toDate();
373
366
  const weekRange = { $gte: weekStart, $lte: weekEnd };
374
367
  const dayLabels = {
375
368
  1: "Sun",
@@ -449,8 +442,8 @@ function useHygieneDashboardRepository() {
449
442
  import_node_server_utils.logger.info(`Cache hit for todayTaskSchedule: ${cacheKey}`);
450
443
  return cachedData;
451
444
  }
452
- const todayStart = import_moment_timezone.default.tz("Asia/Singapore").startOf("day").toDate();
453
- const todayEnd = import_moment_timezone.default.tz("Asia/Singapore").endOf("day").toDate();
445
+ const todayStart = import_moment_timezone2.default.tz("Asia/Singapore").startOf("day").toDate();
446
+ const todayEnd = import_moment_timezone2.default.tz("Asia/Singapore").endOf("day").toDate();
454
447
  const todayRange = { $gte: todayStart, $lte: todayEnd };
455
448
  try {
456
449
  const [items, countResult] = await Promise.all([
@@ -554,8 +547,8 @@ function useHygieneDashboardRepository() {
554
547
  import_node_server_utils.logger.info(`Cache hit for staffAttendance: ${cacheKey}`);
555
548
  return cachedData;
556
549
  }
557
- const todayStart = import_moment_timezone.default.tz("Asia/Singapore").startOf("day").toDate();
558
- const todayEnd = import_moment_timezone.default.tz("Asia/Singapore").endOf("day").toDate();
550
+ const todayStart = import_moment_timezone2.default.tz("Asia/Singapore").startOf("day").toDate();
551
+ const todayEnd = import_moment_timezone2.default.tz("Asia/Singapore").endOf("day").toDate();
559
552
  const todayStartStr = todayStart.toISOString();
560
553
  const todayEndStr = todayEnd.toISOString();
561
554
  try {
@@ -2578,7 +2571,7 @@ function MParentChecklist(value) {
2578
2571
 
2579
2572
  // src/repositories/hygiene-parent-checklist.repository.ts
2580
2573
  var import_mongodb7 = require("mongodb");
2581
- var import_moment_timezone2 = __toESM(require("moment-timezone"));
2574
+ var import_moment_timezone3 = __toESM(require("moment-timezone"));
2582
2575
  var import_node_server_utils14 = require("@7365admin1/node-server-utils");
2583
2576
  var import_core7 = require("@7365admin1/core");
2584
2577
  function useParentChecklistRepo() {
@@ -2874,11 +2867,11 @@ function useParentChecklistRepo() {
2874
2867
  cacheOptions.search = search;
2875
2868
  }
2876
2869
  const singaporeTz = "Asia/Singapore";
2877
- const normalizedStartDate = typeof startDate === "string" ? startDate : (0, import_moment_timezone2.default)(startDate).tz(singaporeTz).format("YYYY-MM-DD");
2878
- const normalizedEndDate = typeof endDate === "string" ? endDate : (0, import_moment_timezone2.default)(endDate).tz(singaporeTz).format("YYYY-MM-DD");
2870
+ const normalizedStartDate = typeof startDate === "string" ? startDate : (0, import_moment_timezone3.default)(startDate).tz(singaporeTz).format("YYYY-MM-DD");
2871
+ const normalizedEndDate = typeof endDate === "string" ? endDate : (0, import_moment_timezone3.default)(endDate).tz(singaporeTz).format("YYYY-MM-DD");
2879
2872
  if (startDate && endDate) {
2880
- const startOfDay = import_moment_timezone2.default.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate();
2881
- const endOfDay = import_moment_timezone2.default.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate();
2873
+ const startOfDay = import_moment_timezone3.default.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate();
2874
+ const endOfDay = import_moment_timezone3.default.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate();
2882
2875
  query.createdAt = {
2883
2876
  $gte: startOfDay,
2884
2877
  $lte: endOfDay
@@ -2887,12 +2880,12 @@ function useParentChecklistRepo() {
2887
2880
  cacheOptions.endDate = normalizedEndDate;
2888
2881
  } else if (startDate) {
2889
2882
  query.createdAt = {
2890
- $gte: import_moment_timezone2.default.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate()
2883
+ $gte: import_moment_timezone3.default.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate()
2891
2884
  };
2892
2885
  cacheOptions.startDate = normalizedStartDate;
2893
2886
  } else if (endDate) {
2894
2887
  query.createdAt = {
2895
- $lte: import_moment_timezone2.default.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate()
2888
+ $lte: import_moment_timezone3.default.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate()
2896
2889
  };
2897
2890
  cacheOptions.endDate = normalizedEndDate;
2898
2891
  }