@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 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
- 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 };
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA4GA;;;iBAEC;yBA5CwB,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;4BAuBT,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;sCAlCtF,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"}
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,8BAiDE;AAIF,wBAAmD;AAxFnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=quietHours.test.d.ts.map
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "6.13.0",
3
+ "version": "6.14.0-alpha.1b87447",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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
  };
@@ -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
+ });