@7365admin1/module-hygiene 4.29.1-staging.14 → 4.29.1-staging.16

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/module-hygiene",
3
3
  "license": "MIT",
4
- "version": "4.29.1-staging.14",
4
+ "version": "4.29.1-staging.16",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,7 +13,7 @@
13
13
  "build": "tsup src/index.ts --format cjs,esm --dts",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/utils/hygiene-dashboard-metrics.util.ts src/utils/hygiene-checkout-decision.util.ts src/utils/completion-photo.util.ts src/utils/area-unit.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test \"test/*.test.mjs\""
16
+ "test": "tsup src/utils/hygiene-dashboard-metrics.util.ts src/utils/hygiene-checkout-decision.util.ts src/utils/completion-photo.util.ts src/utils/area-unit.util.ts src/utils/escape-regex.util.ts src/utils/schedule-task-rules.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test \"test/*.test.mjs\""
17
17
  },
18
18
  "devDependencies": {
19
19
  "@changesets/cli": "^2.26.0",
@@ -0,0 +1,26 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { escapeRegex } from "./.build/escape-regex.util.mjs";
5
+
6
+ test("search text is matched literally, not run as a pattern", () => {
7
+ for (const typed of [".*", "(a+)+$", "[", "a|b", "^x$", "\\", "50% off (new)"]) {
8
+ const rx = new RegExp(escapeRegex(typed), "i");
9
+ assert.ok(rx.test(`xx${typed}yy`), `${typed} should match itself`);
10
+ }
11
+ assert.equal(new RegExp(escapeRegex(".*"), "i").test("anything"), false);
12
+ });
13
+
14
+ test("missing or non-string search stays harmless", () => {
15
+ assert.equal(escapeRegex(undefined), "");
16
+ assert.equal(escapeRegex(null), "");
17
+ assert.equal(escapeRegex(42), "42");
18
+ });
19
+
20
+ test("every repository $regex on a search box goes through escapeRegex", () => {
21
+ const dir = new URL("../src/repositories/", import.meta.url);
22
+ for (const f of readdirSync(dir)) {
23
+ const src = readFileSync(new URL(f, dir), "utf8");
24
+ assert.doesNotMatch(src, /\$regex:\s*search\b/, `${f} passes raw search into $regex`);
25
+ }
26
+ });
@@ -0,0 +1,185 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import Joi from "joi";
4
+ import {
5
+ occursOn,
6
+ shouldRunNow,
7
+ nextRun,
8
+ lastRun,
9
+ scheduleState,
10
+ scheduleDisplay,
11
+ validateScheduleRules,
12
+ scheduleTaskFields,
13
+ cleanScheduleInput,
14
+ splitScheduleUpdate,
15
+ regularSetsMissing,
16
+ sgtParts,
17
+ timingChanged,
18
+ } from "./.build/schedule-task-rules.util.mjs";
19
+
20
+ // 2026-10-20 is a Tuesday. All times Singapore time.
21
+ const at = (s) => new Date(`${s}:00+08:00`);
22
+ const base = { time: "08:00", createdAt: at("2026-10-01T09:00") };
23
+
24
+ test("old task (dates only) behaves as before", () => {
25
+ const t = { ...base, dates: ["2026-10-21"] };
26
+ assert.equal(occursOn(t, "2026-10-21"), true);
27
+ assert.equal(occursOn(t, "2026-10-22"), false);
28
+ assert.equal(shouldRunNow(t, at("2026-10-21T08:00")), true);
29
+ assert.equal(shouldRunNow(t, at("2026-10-22T08:00")), false);
30
+ assert.equal(shouldRunNow(t, at("2026-10-21T07:59")), false);
31
+ });
32
+
33
+ test("sgtParts uses the Singapore day, not UTC", () => {
34
+ // 23:30 UTC on the 20th is 07:30 on the 21st in Singapore
35
+ assert.deepEqual(sgtParts(new Date("2026-10-20T23:30:00Z")), { date: "2026-10-21", minutes: 450, weekday: 3 });
36
+ });
37
+
38
+ test("missed minute: runs later the same day, once", () => {
39
+ const t = { ...base, dates: ["2026-10-21"] };
40
+ assert.equal(shouldRunNow(t, at("2026-10-21T08:01")), true, "restart at 08:00 -> catches up at 08:01");
41
+ assert.equal(shouldRunNow(t, at("2026-10-21T17:45")), true, "still catches up in the evening");
42
+ assert.equal(shouldRunNow({ ...t, lastRunDate: "2026-10-21" }, at("2026-10-21T08:02")), false, "never twice");
43
+ assert.equal(shouldRunNow({ ...t, lastRunDate: "2026-10-20" }, at("2026-10-21T08:02")), true, "yesterday's marker does not block today");
44
+ });
45
+
46
+ test("a task saved at or after its due time today does not fire on the save", () => {
47
+ const t = { ...base, dates: ["2026-10-21"], updatedAt: at("2026-10-21T08:00") };
48
+ assert.equal(shouldRunNow(t, at("2026-10-21T08:00")), false);
49
+ assert.equal(shouldRunNow(t, at("2026-10-21T09:30")), false);
50
+ const early = { ...base, dates: ["2026-10-21"], updatedAt: at("2026-10-21T07:00") };
51
+ assert.equal(shouldRunNow(early, at("2026-10-21T08:00")), true);
52
+ // empty updatedAt (new rows) falls back to createdAt
53
+ assert.equal(shouldRunNow({ ...base, dates: ["2026-10-21"], updatedAt: "" }, at("2026-10-21T08:00")), true);
54
+ });
55
+
56
+ test("paused never runs; end date stops a repeat", () => {
57
+ const t = { ...base, repeat: "daily", startDate: "2026-10-01", dates: [] };
58
+ assert.equal(shouldRunNow(t, at("2026-10-21T08:00")), true);
59
+ assert.equal(shouldRunNow({ ...t, paused: true }, at("2026-10-21T08:00")), false);
60
+ assert.equal(shouldRunNow({ ...t, endDate: "2026-10-20" }, at("2026-10-21T08:00")), false);
61
+ assert.equal(shouldRunNow({ ...t, endDate: "2026-10-21" }, at("2026-10-21T08:00")), true);
62
+ assert.equal(shouldRunNow({ ...t, startDate: "2026-10-22" }, at("2026-10-21T08:00")), false);
63
+ });
64
+
65
+ test("repeat rules: weekdays, weekly, monthly", () => {
66
+ const wk = { ...base, repeat: "weekdays", startDate: "2026-10-01" };
67
+ assert.equal(occursOn(wk, "2026-10-23"), true); // Fri
68
+ assert.equal(occursOn(wk, "2026-10-24"), false); // Sat
69
+ assert.equal(occursOn(wk, "2026-10-25"), false); // Sun
70
+ const weekly = { ...base, repeat: "weekly", daysOfWeek: [1, 3], startDate: "2026-10-01" };
71
+ assert.equal(occursOn(weekly, "2026-10-21"), true); // Wed
72
+ assert.equal(occursOn(weekly, "2026-10-22"), false);
73
+ assert.equal(occursOn({ ...weekly, daysOfWeek: undefined }, "2026-10-21"), false);
74
+ const monthly = { ...base, repeat: "monthly", dayOfMonth: 31, startDate: "2026-01-31" };
75
+ assert.equal(occursOn(monthly, "2026-10-31"), true);
76
+ assert.equal(occursOn(monthly, "2026-11-30"), true, "31st runs on the last day of a short month");
77
+ assert.equal(occursOn(monthly, "2026-11-29"), false);
78
+ assert.equal(occursOn({ ...monthly, dayOfMonth: undefined, startDate: "2026-10-15" }, "2026-11-15"), true, "default day = day of start date");
79
+ assert.equal(occursOn({ ...base, repeat: "none", dates: ["2026-10-21"] }, "2026-10-21"), true, "repeat none = dates");
80
+ });
81
+
82
+ test("end date also trims a plain dates list", () => {
83
+ const t = { ...base, dates: ["2026-10-21", "2026-10-28"], endDate: "2026-10-25" };
84
+ assert.equal(occursOn(t, "2026-10-28"), false);
85
+ assert.equal(nextRun(t, at("2026-10-22T09:00")), null);
86
+ });
87
+
88
+ test("nextRun / lastRun / state", () => {
89
+ const t = { ...base, repeat: "weekdays", startDate: "2026-10-01" };
90
+ assert.equal(nextRun(t, at("2026-10-20T07:00")), "2026-10-20T08:00:00+08:00");
91
+ assert.equal(nextRun({ ...t, lastRunDate: "2026-10-20" }, at("2026-10-20T09:00")), "2026-10-21T08:00:00+08:00");
92
+ assert.equal(nextRun(t, at("2026-10-23T09:00")), "2026-10-23T08:00:00+08:00", "due today and not run yet: the runner is about to catch it up");
93
+ assert.equal(nextRun({ ...t, lastRunDate: "2026-10-23" }, at("2026-10-23T09:00")), "2026-10-26T08:00:00+08:00", "Friday already run -> Monday");
94
+ assert.equal(nextRun({ ...t, paused: true }, at("2026-10-20T07:00")), null);
95
+ assert.equal(scheduleState({ ...t, paused: true }, at("2026-10-20T07:00")), "paused");
96
+ assert.equal(scheduleState(t, at("2026-10-20T07:00")), "active");
97
+ assert.equal(scheduleState({ ...t, endDate: "2026-10-19" }, at("2026-10-20T07:00")), "ended");
98
+ const once = { ...base, dates: ["2026-10-20"], lastRunDate: "2026-10-20" };
99
+ assert.equal(scheduleState(once, at("2026-10-20T12:00")), "ended");
100
+ assert.equal(lastRun(once), "2026-10-20T08:00:00+08:00");
101
+ assert.equal(lastRun(base), null);
102
+ assert.deepEqual(Object.keys(scheduleDisplay(once, at("2026-10-20T12:00"))), ["nextRun", "lastRun", "scheduleStatus"]);
103
+ const future = { ...base, repeat: "daily", startDate: "2030-01-01" };
104
+ assert.equal(nextRun(future, at("2026-10-20T07:00")), "2030-01-01T08:00:00+08:00", "start date far ahead");
105
+ assert.equal(validateScheduleRules(future, at("2026-10-20T07:00")), null);
106
+ const monthly = { ...base, repeat: "monthly", dayOfMonth: 31, startDate: "2026-01-31" };
107
+ assert.equal(nextRun(monthly, at("2026-11-01T00:00")), "2026-11-30T08:00:00+08:00");
108
+ });
109
+
110
+ test("validation: plain-English problems", () => {
111
+ const now = at("2026-10-20T10:00");
112
+ const ok = { ...base, dates: ["2026-10-21"] };
113
+ assert.equal(validateScheduleRules(ok, now), null);
114
+ assert.match(validateScheduleRules({ ...base, dates: ["2026-10-19"] }, now), /already passed/);
115
+ assert.match(validateScheduleRules({ ...base, dates: ["2026-10-20"] }, now), /time has already passed today/);
116
+ assert.equal(validateScheduleRules({ ...base, time: "11:00", dates: ["2026-10-20"] }, now), null);
117
+ assert.match(validateScheduleRules({ ...ok, endDate: "2026-10-19" }, now), /end date/);
118
+ assert.match(validateScheduleRules({ ...base, repeat: "daily" }, now), /start date/);
119
+ assert.match(validateScheduleRules({ ...base, repeat: "weekly", startDate: "2026-10-21" }, now), /at least one weekday/);
120
+ assert.match(validateScheduleRules({ ...base, repeat: "daily", startDate: "2026-10-25", endDate: "2026-10-22" }, now), /on or after the start date/);
121
+ assert.equal(validateScheduleRules({ ...base, repeat: "daily", startDate: "2026-10-01", endDate: "2026-12-31" }, now), null);
122
+ assert.match(validateScheduleRules({ ...base, repeat: "daily", startDate: "2026-10-01", endDate: "2026-10-20" }, now), /never run/);
123
+ });
124
+
125
+ test("Joi rules: required fields and time format", () => {
126
+ const schema = Joi.object(scheduleTaskFields);
127
+ const good = { title: "Extra wipe", time: "08:30", dates: ["2026-10-21"], areas: [{ name: "Lobby", value: "a" }] };
128
+ assert.equal(schema.validate(good).error, undefined);
129
+ const msg = (v) => schema.validate(v).error?.message;
130
+ assert.equal(msg({ ...good, title: "" }), "Please enter a title.");
131
+ assert.equal(msg({ ...good, title: undefined }), "Please enter a title.");
132
+ assert.equal(msg({ ...good, time: "8:30" }), "Time must be in 24-hour format like 08:30.");
133
+ assert.equal(msg({ ...good, time: undefined }), "Please choose a time.");
134
+ assert.equal(msg({ ...good, areas: [] }), "Pick at least one area.");
135
+ assert.equal(msg({ ...good, dates: [] }), "Pick at least one date, or choose a repeat.");
136
+ assert.equal(msg({ ...good, dates: undefined }), "Pick at least one date, or choose a repeat.");
137
+ assert.equal(msg({ ...good, dates: ["2026-02-30"] }), "That date does not exist. Dates must look like 2026-10-21.");
138
+ assert.equal(msg({ ...good, repeat: "yearly" }), "Repeat must be one of: none, daily, weekdays, weekly, monthly.");
139
+ assert.equal(msg({ ...good, daysOfWeek: [7] }), "Weekdays must be numbers from 0 (Sunday) to 6 (Saturday).");
140
+ // a repeat does not need picked dates
141
+ assert.equal(schema.validate({ ...good, dates: undefined, repeat: "daily", startDate: "2026-10-21" }).error, undefined);
142
+ // "" / null on the optional fields is accepted (cleaned away afterwards)
143
+ assert.equal(schema.validate({ ...good, repeat: "", endDate: null, paused: null }).error, undefined);
144
+ });
145
+
146
+ test("cleanScheduleInput / splitScheduleUpdate", () => {
147
+ assert.deepEqual(cleanScheduleInput({ title: "x", repeat: "", endDate: null }), { title: "x" });
148
+ assert.deepEqual(cleanScheduleInput({ repeat: "none", startDate: "2026-10-21", daysOfWeek: [1] }), { repeat: "none" });
149
+ const { set, unset } = splitScheduleUpdate({ title: "y", endDate: null, paused: true });
150
+ assert.deepEqual(set, { title: "y", paused: true });
151
+ assert.deepEqual(unset, ["endDate"]);
152
+ const none = splitScheduleUpdate({ repeat: "none", daysOfWeek: [1] });
153
+ assert.deepEqual(none.set, { repeat: "none" });
154
+ assert.deepEqual(none.unset.sort(), ["dayOfMonth", "daysOfWeek", "startDate"]);
155
+ });
156
+
157
+ test("extra visit before the daily list does not cancel the regular visits", () => {
158
+ const extraOnly = [{ set: 1, isScheduleTask: true }, { set: 2, isScheduleTask: true }];
159
+ assert.equal(regularSetsMissing(extraOnly, 2), 2, "area wants 2 visits a day: both still to be added");
160
+ assert.equal(regularSetsMissing([...extraOnly, { set: 3, isScheduleTask: false }], 2), 1);
161
+ assert.equal(regularSetsMissing([{ set: 1 }, { set: 2 }], 2), 0, "sets with no flag are regular");
162
+ assert.equal(regularSetsMissing(undefined, 3), 3);
163
+ });
164
+
165
+ test("edit of a finished task: only a real timing change is re-checked", () => {
166
+ // stored: all dates past, time already passed. The apps resend the whole form.
167
+ const stored = { time: "08:00", dates: ["2026-10-19", "2026-10-18"], repeat: "none", description: "old" };
168
+ const sent = { ...stored, dates: ["2026-10-18", "2026-10-19"], repeat: "", endDate: null, startDate: "", description: "new" };
169
+ assert.equal(timingChanged(stored, sent), false, "same values, other order / blanks");
170
+ assert.equal(timingChanged({ ...stored, repeat: undefined }, { ...stored, repeat: "none" }), false);
171
+ assert.equal(timingChanged(stored, { ...sent, time: "09:00" }), true);
172
+ assert.equal(timingChanged(stored, { ...sent, dates: ["2026-10-19"] }), true);
173
+ assert.equal(timingChanged(stored, { ...sent, repeat: "daily", startDate: "2026-10-01" }), true);
174
+ assert.equal(timingChanged(stored, { ...sent, endDate: "2026-12-31" }), true);
175
+ });
176
+
177
+ test("a row with no / null time never throws and has no next run", () => {
178
+ const now = at("2026-10-20T10:00");
179
+ for (const time of [undefined, null, "", "8am"]) {
180
+ const row = { time, dates: ["2026-10-21"] };
181
+ assert.deepEqual(scheduleDisplay(row, now), { nextRun: null, lastRun: null, scheduleStatus: "ended" });
182
+ assert.equal(shouldRunNow(row, now), false);
183
+ }
184
+ assert.doesNotThrow(() => scheduleDisplay({ time: null, dates: null, repeat: "weekly" }, now));
185
+ });