@justair/justair-library 6.13.0 → 6.14.0-alpha.1b87447
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/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/quietHours.d.ts +19 -0
- package/dist/utils/quietHours.d.ts.map +1 -0
- package/dist/utils/tests/quietHours.test.d.ts +2 -0
- package/dist/utils/tests/quietHours.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/index.js +19 -0
- package/src/models/users.js +7 -0
- package/src/utils/quietHours.js +181 -0
- package/src/utils/tests/quietHours.test.js +359 -0
package/dist/index.d.ts
CHANGED
|
@@ -91,5 +91,13 @@ import { HEAVY_METALS } from "./constants/pollutants.js";
|
|
|
91
91
|
import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
|
|
92
92
|
import { getAlertWeight } from "./constants/alertLevels.js";
|
|
93
93
|
import { splitName } from "./utils/splitName.js";
|
|
94
|
-
|
|
94
|
+
import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
|
|
95
|
+
import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
|
|
96
|
+
import { isValidTimeString } from "./utils/quietHours.js";
|
|
97
|
+
import { isValidIANATimezone } from "./utils/quietHours.js";
|
|
98
|
+
import { validateQuietHours } from "./utils/quietHours.js";
|
|
99
|
+
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
100
|
+
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
101
|
+
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
102
|
+
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
95
103
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAsHA;;;iBAEC;yBAtDwB,oBAAoB;qBADxB,gBAAgB;4BAjEF,mBAAmB;sBAAnB,mBAAmB;qCAI/C,4BAA4B;+BAA5B,4BAA4B;mCAM5B,0BAA0B;6BAA1B,0BAA0B;sCAI1B,6BAA6B;gCAA7B,6BAA6B;+BAQ7B,sBAAsB;yBAAtB,sBAAsB;oCACsB,2BAA2B;8BAA3B,2BAA2B;4BAcvE,mBAAmB;sBAAnB,mBAAmB;0CAAnB,mBAAmB;sCAAnB,mBAAmB;sCAAnB,mBAAmB;6BAMnB,oBAAoB;uBAApB,oBAAoB;mCAnBuB,2BAA2B;8BAA3B,2BAA2B;uCAItE,8BAA8B;iCAA9B,8BAA8B;+BACI,sBAAsB;yBAAtB,sBAAsB;iCAClB,wBAAwB;2BAAxB,wBAAwB;mCAcnB,2BAA2B;8BAA3B,2BAA2B;2BAC5C,kBAAkB;qBAAlB,kBAAkB;6BACd,oBAAoB;uBAApB,oBAAoB;6BACR,0BAA0B;mCAA1B,0BAA0B;sBArCpE,0BAA0B;4BAA1B,0BAA0B;4BAiC1B,oBAAoB;kCAApB,oBAAoB;6BArBpB,sBAAsB;mCAAtB,sBAAsB;+BAAtB,sBAAsB;oCAAtB,sBAAsB;4BA+BtB,oBAAoB;uBAApB,oBAAoB;6BAApB,oBAAoB;kCAApB,oBAAoB;yBACc,sBAAsB;+BAAtB,sBAAsB;uCAIxD,8BAA8B;iCAA9B,8BAA8B;qCACgB,4BAA4B;+BAA5B,4BAA4B;mCAChC,0BAA0B;6BAA1B,0BAA0B;8CAIpE,qCAAqC;wCAArC,qCAAqC;4BAiCT,mBAAmB;sBAAnB,mBAAmB;kCAS/C,yBAAyB;4BAAzB,yBAAyB;sCAAzB,yBAAyB;gCAAzB,yBAAyB;qCAAzB,yBAAyB;8BASzB,qBAAqB;wBAArB,qBAAqB;mCAArB,qBAAqB;6BAArB,qBAAqB;uDAArB,qBAAqB;yCACiC,gCAAgC;mCAAhC,gCAAgC;sCA5CtF,6BAA6B;+BAA7B,6BAA6B;wCAW7B,+BAA+B;iCAA/B,+BAA+B;qCAA/B,+BAA+B;0CAA/B,+BAA+B;yCAA/B,+BAA+B;oCAA/B,+BAA+B;mCAA/B,+BAA+B;sCAA/B,+BAA+B;kCAA/B,+BAA+B;2BAhBG,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAgBtD,sBAAsB;oCAUzC,uBAAuB;iCAAvB,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAqCA,
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAqCA,8BAwDE;AAIF,wBAAmD;AA/FnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const DEFAULT_QUIET_HOURS: Readonly<{
|
|
2
|
+
start: "22:00";
|
|
3
|
+
end: "07:00";
|
|
4
|
+
}>;
|
|
5
|
+
export const DEFAULT_TIMEZONE: "America/New_York";
|
|
6
|
+
export function isValidTimeString(value: any): boolean;
|
|
7
|
+
export function isValidIANATimezone(timeZone: any): boolean;
|
|
8
|
+
export function validateQuietHours({ start, end }?: {}): string[];
|
|
9
|
+
export function getZoneMinutes(date: any, timeZone: any): number;
|
|
10
|
+
export function isInQuietWindow(minutesNow: any, window: any): boolean;
|
|
11
|
+
export function resolveQuietHours(user: any): {
|
|
12
|
+
window: Readonly<{
|
|
13
|
+
start: "22:00";
|
|
14
|
+
end: "07:00";
|
|
15
|
+
}>;
|
|
16
|
+
timezone: string;
|
|
17
|
+
warnings: string[];
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=quietHours.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":"AAkBA;;;GAGG;AAEH,kDAAmD;AAuB5C,uDACgD;AAEhD,4DAUN;AAKM,kEA0BN;AASM,iEASN;AAKM,uEAaN;AAWM;;;;;;;EA2CN"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quietHours.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/quietHours.test.js"],"names":[],"mappings":""}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -83,6 +83,16 @@ import {
|
|
|
83
83
|
DELIVERY_STATUSES,
|
|
84
84
|
} from "./models/userNotifications.js";
|
|
85
85
|
import { splitName } from "./utils/splitName.js";
|
|
86
|
+
import {
|
|
87
|
+
DEFAULT_QUIET_HOURS,
|
|
88
|
+
DEFAULT_TIMEZONE,
|
|
89
|
+
isValidTimeString,
|
|
90
|
+
isValidIANATimezone,
|
|
91
|
+
validateQuietHours,
|
|
92
|
+
getZoneMinutes,
|
|
93
|
+
isInQuietWindow,
|
|
94
|
+
resolveQuietHours,
|
|
95
|
+
} from "./utils/quietHours.js";
|
|
86
96
|
|
|
87
97
|
// Sites related imports
|
|
88
98
|
import { sitesSchema, Sites } from "./models/sites.js";
|
|
@@ -207,4 +217,13 @@ export {
|
|
|
207
217
|
getAlertWeight,
|
|
208
218
|
// Utilities
|
|
209
219
|
splitName,
|
|
220
|
+
// Quiet hours (JA-3054): shared validation + window evaluation
|
|
221
|
+
DEFAULT_QUIET_HOURS,
|
|
222
|
+
DEFAULT_TIMEZONE,
|
|
223
|
+
isValidTimeString,
|
|
224
|
+
isValidIANATimezone,
|
|
225
|
+
validateQuietHours,
|
|
226
|
+
getZoneMinutes,
|
|
227
|
+
isInQuietWindow,
|
|
228
|
+
resolveQuietHours,
|
|
210
229
|
};
|
package/src/models/users.js
CHANGED
|
@@ -80,6 +80,13 @@ const usersSchema = mongoose.Schema(
|
|
|
80
80
|
default: "Good",
|
|
81
81
|
},
|
|
82
82
|
cookie_consent: cookieConsentSchemaDefinition,
|
|
83
|
+
// Quiet hours (JA-3054): null = platform defaults apply (22:00–07:00
|
|
84
|
+
// America/New_York, see utils/quietHours.js). Pair rule: start/end are
|
|
85
|
+
// always both set or both null — enforced by validateQuietHours at the
|
|
86
|
+
// write path, never assumed by readers (resolveQuietHours re-checks).
|
|
87
|
+
quietHoursStart: { type: String, default: null },
|
|
88
|
+
quietHoursEnd: { type: String, default: null },
|
|
89
|
+
timezone: { type: String, default: null },
|
|
83
90
|
},
|
|
84
91
|
{
|
|
85
92
|
timestamps: true,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Quiet hours (JA-3054): shared validation + evaluation for per-user
|
|
2
|
+
// notification quiet windows. Single source of truth for every consumer —
|
|
3
|
+
// JustAirUserService validates writes with it, JustAirNotificationService
|
|
4
|
+
// evaluates suppression with it. Pure functions only: no logger, no clock
|
|
5
|
+
// reads — callers pass `now` in and log any returned warnings themselves.
|
|
6
|
+
//
|
|
7
|
+
// A window is two "HH:mm" wall-clock strings evaluated in the user's IANA
|
|
8
|
+
// timezone. `start > end` means the window crosses midnight:
|
|
9
|
+
//
|
|
10
|
+
// same-day window 09:00–17:00 midnight-crossing 22:00–07:00
|
|
11
|
+
// 00:00 09:00 17:00 24:00 00:00 07:00 22:00 24:00
|
|
12
|
+
// |----------|##########|------| |#########|------------|######|
|
|
13
|
+
// [start end) end) [start
|
|
14
|
+
// in-window: start <= m < end in-window: m >= start OR m < end
|
|
15
|
+
//
|
|
16
|
+
// Boundary rule: start is inclusive, end is exclusive — a 22:00–07:00 user
|
|
17
|
+
// is suppressed at 22:00 sharp and receives alerts again at 07:00 sharp.
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_QUIET_HOURS = Object.freeze({
|
|
20
|
+
start: "22:00",
|
|
21
|
+
end: "07:00",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_TIMEZONE = "America/New_York";
|
|
25
|
+
|
|
26
|
+
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
27
|
+
|
|
28
|
+
// Formatter construction is the expensive part of Intl (~ms); formatToParts
|
|
29
|
+
// on a cached formatter is cheap. The cache also makes validation and
|
|
30
|
+
// evaluation share one authority: a timezone is valid iff Intl accepts it.
|
|
31
|
+
const formatterCache = new Map();
|
|
32
|
+
|
|
33
|
+
const getFormatter = (timeZone) => {
|
|
34
|
+
let formatter = formatterCache.get(timeZone);
|
|
35
|
+
if (formatter === undefined) {
|
|
36
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
37
|
+
timeZone,
|
|
38
|
+
hourCycle: "h23",
|
|
39
|
+
hour: "2-digit",
|
|
40
|
+
minute: "2-digit",
|
|
41
|
+
});
|
|
42
|
+
formatterCache.set(timeZone, formatter);
|
|
43
|
+
}
|
|
44
|
+
return formatter;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const isValidTimeString = (value) =>
|
|
48
|
+
typeof value === "string" && HHMM_PATTERN.test(value);
|
|
49
|
+
|
|
50
|
+
export const isValidIANATimezone = (timeZone) => {
|
|
51
|
+
if (typeof timeZone !== "string" || timeZone.trim() === "") {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
getFormatter(timeZone);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Returns an array of error strings; empty array = valid. Valid states are
|
|
63
|
+
// exactly two: both null/undefined (defaults apply) or both well-formed and
|
|
64
|
+
// distinct. Everything else is rejected so no ambiguous window is ever stored.
|
|
65
|
+
export const validateQuietHours = ({ start, end } = {}) => {
|
|
66
|
+
const startUnset = start === null || start === undefined;
|
|
67
|
+
const endUnset = end === null || end === undefined;
|
|
68
|
+
|
|
69
|
+
if (startUnset && endUnset) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
if (startUnset || endUnset) {
|
|
73
|
+
return [
|
|
74
|
+
"quietHoursStart and quietHoursEnd must be provided together (or both null to use the default window)",
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const errors = [];
|
|
79
|
+
if (!isValidTimeString(start)) {
|
|
80
|
+
errors.push("quietHoursStart must be a time string in HH:mm format");
|
|
81
|
+
}
|
|
82
|
+
if (!isValidTimeString(end)) {
|
|
83
|
+
errors.push("quietHoursEnd must be a time string in HH:mm format");
|
|
84
|
+
}
|
|
85
|
+
if (errors.length === 0 && start === end) {
|
|
86
|
+
errors.push(
|
|
87
|
+
"quietHoursStart and quietHoursEnd must differ (set both to null to disable a custom window)"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return errors;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const timeToMinutes = (value) => {
|
|
94
|
+
const [hours, minutes] = value.split(":");
|
|
95
|
+
return Number(hours) * 60 + Number(minutes);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Minutes since local midnight in `timeZone` at instant `date`. Throws on an
|
|
99
|
+
// invalid timezone — resolve with resolveQuietHours/isValidIANATimezone first.
|
|
100
|
+
export const getZoneMinutes = (date, timeZone) => {
|
|
101
|
+
const parts = getFormatter(timeZone).formatToParts(date);
|
|
102
|
+
let hours = 0;
|
|
103
|
+
let minutes = 0;
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
if (part.type === "hour") hours = Number(part.value);
|
|
106
|
+
else if (part.type === "minute") minutes = Number(part.value);
|
|
107
|
+
}
|
|
108
|
+
return hours * 60 + minutes;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// `minutesNow` from getZoneMinutes; `window` of "HH:mm" strings or null.
|
|
112
|
+
// Null window (or an equal pair, which resolveQuietHours flags) → never
|
|
113
|
+
// suppressed. See the file-header diagram for the two window shapes.
|
|
114
|
+
export const isInQuietWindow = (minutesNow, window) => {
|
|
115
|
+
if (!window || !isValidTimeString(window.start) || !isValidTimeString(window.end)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const start = timeToMinutes(window.start);
|
|
119
|
+
const end = timeToMinutes(window.end);
|
|
120
|
+
if (start === end) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (start < end) {
|
|
124
|
+
return minutesNow >= start && minutesNow < end;
|
|
125
|
+
}
|
|
126
|
+
return minutesNow >= start || minutesNow < end;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Resolves a user's stored fields into an evaluable { window, timezone },
|
|
130
|
+
// never throwing on corrupt data (JA-3020 D16). Fallback semantics:
|
|
131
|
+
// - fields null/undefined → defaults, no warning (the normal case)
|
|
132
|
+
// - invalid timezone → DEFAULT_TIMEZONE + warning
|
|
133
|
+
// - half-set or malformed pair → DEFAULT_QUIET_HOURS + warning
|
|
134
|
+
// - stored equal pair → window: null (no suppression) + warning
|
|
135
|
+
// (JA-3020 D11 — "always quiet" must never arise from corrupt data)
|
|
136
|
+
// Callers log `warnings` (once per run, not per user) and evaluate with
|
|
137
|
+
// isInQuietWindow(getZoneMinutes(now, timezone), window).
|
|
138
|
+
export const resolveQuietHours = (user) => {
|
|
139
|
+
const { quietHoursStart, quietHoursEnd, timezone } = user ?? {};
|
|
140
|
+
const warnings = [];
|
|
141
|
+
|
|
142
|
+
let resolvedTimezone = DEFAULT_TIMEZONE;
|
|
143
|
+
if (timezone !== null && timezone !== undefined) {
|
|
144
|
+
if (isValidIANATimezone(timezone)) {
|
|
145
|
+
resolvedTimezone = timezone;
|
|
146
|
+
} else {
|
|
147
|
+
warnings.push(
|
|
148
|
+
`invalid stored timezone "${timezone}" — falling back to ${DEFAULT_TIMEZONE}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const startUnset = quietHoursStart === null || quietHoursStart === undefined;
|
|
154
|
+
const endUnset = quietHoursEnd === null || quietHoursEnd === undefined;
|
|
155
|
+
|
|
156
|
+
let window = DEFAULT_QUIET_HOURS;
|
|
157
|
+
if (!startUnset || !endUnset) {
|
|
158
|
+
const errors = validateQuietHours({
|
|
159
|
+
start: quietHoursStart,
|
|
160
|
+
end: quietHoursEnd,
|
|
161
|
+
});
|
|
162
|
+
if (errors.length === 0) {
|
|
163
|
+
window = { start: quietHoursStart, end: quietHoursEnd };
|
|
164
|
+
} else if (quietHoursStart === quietHoursEnd) {
|
|
165
|
+
window = null;
|
|
166
|
+
warnings.push(
|
|
167
|
+
`stored quiet hours are an equal pair ("${quietHoursStart}") — treating as no suppression`
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
warnings.push(
|
|
171
|
+
`stored quiet hours are invalid (start: ${JSON.stringify(
|
|
172
|
+
quietHoursStart
|
|
173
|
+
)}, end: ${JSON.stringify(
|
|
174
|
+
quietHoursEnd
|
|
175
|
+
)}) — falling back to the default window`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { window, timezone: resolvedTimezone, warnings };
|
|
181
|
+
};
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_QUIET_HOURS,
|
|
3
|
+
DEFAULT_TIMEZONE,
|
|
4
|
+
isValidTimeString,
|
|
5
|
+
isValidIANATimezone,
|
|
6
|
+
validateQuietHours,
|
|
7
|
+
getZoneMinutes,
|
|
8
|
+
isInQuietWindow,
|
|
9
|
+
resolveQuietHours,
|
|
10
|
+
} from '../quietHours.js';
|
|
11
|
+
|
|
12
|
+
describe('constants', () => {
|
|
13
|
+
test('defaults match the JA-3020 contract (22:00–07:00 America/New_York)', () => {
|
|
14
|
+
expect(DEFAULT_QUIET_HOURS).toEqual({ start: '22:00', end: '07:00' });
|
|
15
|
+
expect(DEFAULT_TIMEZONE).toBe('America/New_York');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('DEFAULT_QUIET_HOURS is frozen', () => {
|
|
19
|
+
expect(Object.isFrozen(DEFAULT_QUIET_HOURS)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('isValidTimeString', () => {
|
|
24
|
+
test.each(['00:00', '07:00', '09:30', '23:59', '13:05'])(
|
|
25
|
+
'accepts %s',
|
|
26
|
+
(value) => {
|
|
27
|
+
expect(isValidTimeString(value)).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
test.each([
|
|
32
|
+
['24:00', 'hour out of range'],
|
|
33
|
+
['07:60', 'minute out of range'],
|
|
34
|
+
['7:30', 'missing leading zero'],
|
|
35
|
+
['9:5', 'single digits'],
|
|
36
|
+
['0730', 'no separator'],
|
|
37
|
+
['07:30:00', 'seconds included'],
|
|
38
|
+
['', 'empty string'],
|
|
39
|
+
['ten pm', 'words'],
|
|
40
|
+
])('rejects %s (%s)', (value) => {
|
|
41
|
+
expect(isValidTimeString(value)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test.each([null, undefined, 730, { start: '07:30' }])(
|
|
45
|
+
'rejects non-string %p',
|
|
46
|
+
(value) => {
|
|
47
|
+
expect(isValidTimeString(value)).toBe(false);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('isValidIANATimezone', () => {
|
|
53
|
+
test.each([
|
|
54
|
+
'America/New_York',
|
|
55
|
+
'America/Detroit',
|
|
56
|
+
'America/Los_Angeles',
|
|
57
|
+
'UTC',
|
|
58
|
+
'Asia/Tokyo',
|
|
59
|
+
])('accepts %s', (tz) => {
|
|
60
|
+
expect(isValidIANATimezone(tz)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test.each([
|
|
64
|
+
'America/Fake_City',
|
|
65
|
+
'Not/A_Zone',
|
|
66
|
+
'EST GMT',
|
|
67
|
+
'',
|
|
68
|
+
' ',
|
|
69
|
+
])('rejects %p', (tz) => {
|
|
70
|
+
expect(isValidIANATimezone(tz)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test.each([null, undefined, 5, {}])('rejects non-string %p', (tz) => {
|
|
74
|
+
expect(isValidIANATimezone(tz)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('validateQuietHours', () => {
|
|
79
|
+
test('accepts both fields null (defaults apply)', () => {
|
|
80
|
+
expect(validateQuietHours({ start: null, end: null })).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('accepts both fields undefined / missing object', () => {
|
|
84
|
+
expect(validateQuietHours({})).toEqual([]);
|
|
85
|
+
expect(validateQuietHours()).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('accepts a well-formed distinct pair', () => {
|
|
89
|
+
expect(validateQuietHours({ start: '22:00', end: '07:00' })).toEqual([]);
|
|
90
|
+
expect(validateQuietHours({ start: '09:00', end: '17:00' })).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('rejects a half-set pair (start only)', () => {
|
|
94
|
+
const errors = validateQuietHours({ start: '22:00', end: null });
|
|
95
|
+
expect(errors).toHaveLength(1);
|
|
96
|
+
expect(errors[0]).toMatch(/together/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects a half-set pair (end only)', () => {
|
|
100
|
+
const errors = validateQuietHours({ start: undefined, end: '07:00' });
|
|
101
|
+
expect(errors).toHaveLength(1);
|
|
102
|
+
expect(errors[0]).toMatch(/together/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('rejects malformed times, reporting each field', () => {
|
|
106
|
+
const errors = validateQuietHours({ start: '25:00', end: '7pm' });
|
|
107
|
+
expect(errors).toHaveLength(2);
|
|
108
|
+
expect(errors[0]).toMatch(/quietHoursStart/);
|
|
109
|
+
expect(errors[1]).toMatch(/quietHoursEnd/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('rejects an equal pair (JA-3020 D11)', () => {
|
|
113
|
+
const errors = validateQuietHours({ start: '22:00', end: '22:00' });
|
|
114
|
+
expect(errors).toHaveLength(1);
|
|
115
|
+
expect(errors[0]).toMatch(/differ/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('getZoneMinutes', () => {
|
|
120
|
+
test('converts UTC instants to New York wall-clock minutes (EST, winter)', () => {
|
|
121
|
+
// 2026-01-15 15:30 UTC = 10:30 EST (UTC-5)
|
|
122
|
+
expect(
|
|
123
|
+
getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 15, 30)), 'America/New_York')
|
|
124
|
+
).toBe(10 * 60 + 30);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('converts UTC instants to New York wall-clock minutes (EDT, summer)', () => {
|
|
128
|
+
// 2026-07-15 15:30 UTC = 11:30 EDT (UTC-4)
|
|
129
|
+
expect(
|
|
130
|
+
getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 15, 30)), 'America/New_York')
|
|
131
|
+
).toBe(11 * 60 + 30);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('handles local midnight as 0 minutes (h23, not 24)', () => {
|
|
135
|
+
// 2026-01-15 05:00 UTC = 00:00 EST
|
|
136
|
+
expect(
|
|
137
|
+
getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 5, 0)), 'America/New_York')
|
|
138
|
+
).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('DST spring-forward: the 02:xx hour does not exist (2026-03-08)', () => {
|
|
142
|
+
// 06:59 UTC = 01:59 EST, one minute later 07:00 UTC = 03:00 EDT
|
|
143
|
+
expect(
|
|
144
|
+
getZoneMinutes(new Date(Date.UTC(2026, 2, 8, 6, 59)), 'America/New_York')
|
|
145
|
+
).toBe(1 * 60 + 59);
|
|
146
|
+
expect(
|
|
147
|
+
getZoneMinutes(new Date(Date.UTC(2026, 2, 8, 7, 0)), 'America/New_York')
|
|
148
|
+
).toBe(3 * 60);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('DST fall-back: 01:30 wall-clock occurs twice (2026-11-01)', () => {
|
|
152
|
+
// 05:30 UTC = 01:30 EDT and 06:30 UTC = 01:30 EST — same wall clock
|
|
153
|
+
expect(
|
|
154
|
+
getZoneMinutes(new Date(Date.UTC(2026, 10, 1, 5, 30)), 'America/New_York')
|
|
155
|
+
).toBe(1 * 60 + 30);
|
|
156
|
+
expect(
|
|
157
|
+
getZoneMinutes(new Date(Date.UTC(2026, 10, 1, 6, 30)), 'America/New_York')
|
|
158
|
+
).toBe(1 * 60 + 30);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('UTC zone is a passthrough of the instant wall clock', () => {
|
|
162
|
+
expect(
|
|
163
|
+
getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 15, 30)), 'UTC')
|
|
164
|
+
).toBe(15 * 60 + 30);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('throws on an invalid timezone (callers resolve first)', () => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
getZoneMinutes(new Date(), 'America/Fake_City')
|
|
170
|
+
).toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('isInQuietWindow', () => {
|
|
175
|
+
const minutes = (hhmm) => {
|
|
176
|
+
const [h, m] = hhmm.split(':');
|
|
177
|
+
return Number(h) * 60 + Number(m);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
describe('same-day window 09:00–17:00', () => {
|
|
181
|
+
const window = { start: '09:00', end: '17:00' };
|
|
182
|
+
|
|
183
|
+
test.each(['09:00', '12:00', '16:59'])('%s is inside', (time) => {
|
|
184
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test.each(['08:59', '17:00', '22:00', '00:00'])('%s is outside', (time) => {
|
|
188
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('midnight-crossing window 22:00–07:00 (the default shape)', () => {
|
|
193
|
+
const window = { start: '22:00', end: '07:00' };
|
|
194
|
+
|
|
195
|
+
test.each(['22:00', '23:59', '00:00', '03:00', '06:59'])(
|
|
196
|
+
'%s is inside',
|
|
197
|
+
(time) => {
|
|
198
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(true);
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
test.each(['21:59', '07:00', '12:00', '17:35'])('%s is outside', (time) => {
|
|
203
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('null or malformed window never suppresses', () => {
|
|
208
|
+
expect(isInQuietWindow(0, null)).toBe(false);
|
|
209
|
+
expect(isInQuietWindow(0, undefined)).toBe(false);
|
|
210
|
+
expect(isInQuietWindow(0, {})).toBe(false);
|
|
211
|
+
expect(isInQuietWindow(0, { start: '22:00', end: 'bad' })).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('equal pair never suppresses (defensive, JA-3020 D11)', () => {
|
|
215
|
+
expect(isInQuietWindow(minutes('22:00'), { start: '22:00', end: '22:00' })).toBe(
|
|
216
|
+
false
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('resolveQuietHours', () => {
|
|
222
|
+
test('all fields null → defaults with no warnings (every legacy user)', () => {
|
|
223
|
+
const resolved = resolveQuietHours({
|
|
224
|
+
quietHoursStart: null,
|
|
225
|
+
quietHoursEnd: null,
|
|
226
|
+
timezone: null,
|
|
227
|
+
});
|
|
228
|
+
expect(resolved).toEqual({
|
|
229
|
+
window: DEFAULT_QUIET_HOURS,
|
|
230
|
+
timezone: DEFAULT_TIMEZONE,
|
|
231
|
+
warnings: [],
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('fields absent entirely → defaults with no warnings', () => {
|
|
236
|
+
expect(resolveQuietHours({})).toEqual({
|
|
237
|
+
window: DEFAULT_QUIET_HOURS,
|
|
238
|
+
timezone: DEFAULT_TIMEZONE,
|
|
239
|
+
warnings: [],
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('null or undefined user → defaults, never throws', () => {
|
|
244
|
+
expect(resolveQuietHours(null)).toEqual({
|
|
245
|
+
window: DEFAULT_QUIET_HOURS,
|
|
246
|
+
timezone: DEFAULT_TIMEZONE,
|
|
247
|
+
warnings: [],
|
|
248
|
+
});
|
|
249
|
+
expect(resolveQuietHours(undefined).window).toEqual(DEFAULT_QUIET_HOURS);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('valid custom fields are used verbatim', () => {
|
|
253
|
+
const resolved = resolveQuietHours({
|
|
254
|
+
quietHoursStart: '23:00',
|
|
255
|
+
quietHoursEnd: '06:30',
|
|
256
|
+
timezone: 'America/Detroit',
|
|
257
|
+
});
|
|
258
|
+
expect(resolved).toEqual({
|
|
259
|
+
window: { start: '23:00', end: '06:30' },
|
|
260
|
+
timezone: 'America/Detroit',
|
|
261
|
+
warnings: [],
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('timezone set, hours unset → default window in THEIR zone (D5 matrix)', () => {
|
|
266
|
+
const resolved = resolveQuietHours({ timezone: 'America/Los_Angeles' });
|
|
267
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
268
|
+
expect(resolved.timezone).toBe('America/Los_Angeles');
|
|
269
|
+
expect(resolved.warnings).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('hours set, timezone unset → custom window in the default zone (D5 matrix)', () => {
|
|
273
|
+
const resolved = resolveQuietHours({
|
|
274
|
+
quietHoursStart: '23:00',
|
|
275
|
+
quietHoursEnd: '05:00',
|
|
276
|
+
});
|
|
277
|
+
expect(resolved.window).toEqual({ start: '23:00', end: '05:00' });
|
|
278
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
279
|
+
expect(resolved.warnings).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('invalid stored timezone → default zone + warning', () => {
|
|
283
|
+
const resolved = resolveQuietHours({ timezone: 'America/Fake_City' });
|
|
284
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
285
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
286
|
+
expect(resolved.warnings[0]).toMatch(/invalid stored timezone/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('half-set stored pair → default window + warning (D16)', () => {
|
|
290
|
+
const resolved = resolveQuietHours({ quietHoursStart: '22:00' });
|
|
291
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
292
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
293
|
+
expect(resolved.warnings[0]).toMatch(/invalid/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('malformed stored pair → default window + warning (D16)', () => {
|
|
297
|
+
const resolved = resolveQuietHours({
|
|
298
|
+
quietHoursStart: '25:00',
|
|
299
|
+
quietHoursEnd: '07:00',
|
|
300
|
+
});
|
|
301
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
302
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('stored equal pair → NO suppression + warning, never "always quiet" (D11)', () => {
|
|
306
|
+
const resolved = resolveQuietHours({
|
|
307
|
+
quietHoursStart: '22:00',
|
|
308
|
+
quietHoursEnd: '22:00',
|
|
309
|
+
});
|
|
310
|
+
expect(resolved.window).toBeNull();
|
|
311
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
312
|
+
expect(resolved.warnings[0]).toMatch(/equal pair/);
|
|
313
|
+
expect(isInQuietWindow(0, resolved.window)).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('corrupt window and corrupt timezone warn independently', () => {
|
|
317
|
+
const resolved = resolveQuietHours({
|
|
318
|
+
quietHoursStart: 'bad',
|
|
319
|
+
quietHoursEnd: '07:00',
|
|
320
|
+
timezone: 'Nope/Nope',
|
|
321
|
+
});
|
|
322
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
323
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
324
|
+
expect(resolved.warnings).toHaveLength(2);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('end-to-end: resolve + zone minutes + window (the cron gate path)', () => {
|
|
329
|
+
test('legacy user is suppressed at 3am ET and alertable at noon ET', () => {
|
|
330
|
+
const { window, timezone } = resolveQuietHours({});
|
|
331
|
+
// 2026-07-15 07:00 UTC = 03:00 EDT
|
|
332
|
+
const night = getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 7, 0)), timezone);
|
|
333
|
+
// 2026-07-15 16:00 UTC = 12:00 EDT
|
|
334
|
+
const noon = getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 16, 0)), timezone);
|
|
335
|
+
expect(isInQuietWindow(night, window)).toBe(true);
|
|
336
|
+
expect(isInQuietWindow(noon, window)).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('legacy user default window is DST-stable at the ET wall clock (winter)', () => {
|
|
340
|
+
const { window, timezone } = resolveQuietHours({});
|
|
341
|
+
// 2026-01-15 08:00 UTC = 03:00 EST — still inside 22:00–07:00
|
|
342
|
+
const night = getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 8, 0)), timezone);
|
|
343
|
+
expect(isInQuietWindow(night, window)).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('Detroit user with a custom 23:00–06:00 window gets a 22:30 local alert', () => {
|
|
347
|
+
const { window, timezone } = resolveQuietHours({
|
|
348
|
+
quietHoursStart: '23:00',
|
|
349
|
+
quietHoursEnd: '06:00',
|
|
350
|
+
timezone: 'America/Detroit',
|
|
351
|
+
});
|
|
352
|
+
// 2026-07-16 02:30 UTC = 22:30 EDT in Detroit — before their window opens
|
|
353
|
+
const at2230 = getZoneMinutes(new Date(Date.UTC(2026, 6, 16, 2, 30)), timezone);
|
|
354
|
+
expect(isInQuietWindow(at2230, window)).toBe(false);
|
|
355
|
+
// 2026-07-16 03:30 UTC = 23:30 EDT — inside
|
|
356
|
+
const at2330 = getZoneMinutes(new Date(Date.UTC(2026, 6, 16, 3, 30)), timezone);
|
|
357
|
+
expect(isInQuietWindow(at2330, window)).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|