@justair/justair-library 6.13.0 → 6.14.0-alpha.3432ed7
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 +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/quietHours.d.ts +33 -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 +21 -0
- package/src/models/users.js +61 -0
- package/src/utils/quietHours.js +210 -0
- package/src/utils/tests/quietHours.test.js +386 -0
package/dist/index.d.ts
CHANGED
|
@@ -91,5 +91,14 @@ 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 { QUIET_HOURS_ERRORS } from "./utils/quietHours.js";
|
|
97
|
+
import { isValidTimeString } from "./utils/quietHours.js";
|
|
98
|
+
import { isValidIANATimezone } from "./utils/quietHours.js";
|
|
99
|
+
import { validateQuietHours } from "./utils/quietHours.js";
|
|
100
|
+
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
101
|
+
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
102
|
+
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
103
|
+
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, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
95
104
|
//# 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":"AAuHA;;;iBAEC;yBAvDwB,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;4BAkCT,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;sCA7CtF,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;oCAWzC,uBAAuB;iCAAvB,uBAAuB;mCAAvB,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":"
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AA2CA,8BAiFE;AA2BF,wBAAmD;AA/InD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 const QUIET_HOURS_ERRORS: Readonly<{
|
|
7
|
+
HALF_PAIR: "half_pair";
|
|
8
|
+
FORMAT: "format";
|
|
9
|
+
EQUAL_PAIR: "equal_pair";
|
|
10
|
+
}>;
|
|
11
|
+
export function isValidTimeString(value: any): boolean;
|
|
12
|
+
export function isValidIANATimezone(timeZone: any): boolean;
|
|
13
|
+
export function validateQuietHours({ start, end }?: {}): {
|
|
14
|
+
code: "half_pair";
|
|
15
|
+
message: string;
|
|
16
|
+
}[] | ({
|
|
17
|
+
code: "format";
|
|
18
|
+
message: string;
|
|
19
|
+
} | {
|
|
20
|
+
code: "equal_pair";
|
|
21
|
+
message: string;
|
|
22
|
+
})[];
|
|
23
|
+
export function getZoneMinutes(date: any, timeZone: any): number;
|
|
24
|
+
export function isInQuietWindow(minutesNow: any, window: any): boolean;
|
|
25
|
+
export function resolveQuietHours(user: any): {
|
|
26
|
+
window: Readonly<{
|
|
27
|
+
start: "22:00";
|
|
28
|
+
end: "07:00";
|
|
29
|
+
}>;
|
|
30
|
+
timezone: string;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
};
|
|
33
|
+
//# 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;AAKnD;;;;GAIG;AAuBI,uDACgD;AAEhD,4DAUN;AAOM;;;;;;;;;KAsCN;AASM,iEASN;AAKM,uEAaN;AAeM;;;;;;;EA6CN"}
|
|
@@ -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,17 @@ 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
|
+
QUIET_HOURS_ERRORS,
|
|
90
|
+
isValidTimeString,
|
|
91
|
+
isValidIANATimezone,
|
|
92
|
+
validateQuietHours,
|
|
93
|
+
getZoneMinutes,
|
|
94
|
+
isInQuietWindow,
|
|
95
|
+
resolveQuietHours,
|
|
96
|
+
} from "./utils/quietHours.js";
|
|
86
97
|
|
|
87
98
|
// Sites related imports
|
|
88
99
|
import { sitesSchema, Sites } from "./models/sites.js";
|
|
@@ -207,4 +218,14 @@ export {
|
|
|
207
218
|
getAlertWeight,
|
|
208
219
|
// Utilities
|
|
209
220
|
splitName,
|
|
221
|
+
// Quiet hours (JA-3054): shared validation + window evaluation
|
|
222
|
+
DEFAULT_QUIET_HOURS,
|
|
223
|
+
DEFAULT_TIMEZONE,
|
|
224
|
+
QUIET_HOURS_ERRORS,
|
|
225
|
+
isValidTimeString,
|
|
226
|
+
isValidIANATimezone,
|
|
227
|
+
validateQuietHours,
|
|
228
|
+
getZoneMinutes,
|
|
229
|
+
isInQuietWindow,
|
|
230
|
+
resolveQuietHours,
|
|
210
231
|
};
|
package/src/models/users.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import mongoose from "mongoose";
|
|
2
|
+
import {
|
|
3
|
+
isValidTimeString,
|
|
4
|
+
isValidIANATimezone,
|
|
5
|
+
validateQuietHours,
|
|
6
|
+
QUIET_HOURS_ERRORS,
|
|
7
|
+
} from "../utils/quietHours.js";
|
|
2
8
|
|
|
3
9
|
const COOKIE_CONSENT_CATEGORIES = ["analytics_performance"];
|
|
4
10
|
const COOKIE_CONSENT_VALUES = ["accept", "reject"];
|
|
@@ -80,12 +86,67 @@ const usersSchema = mongoose.Schema(
|
|
|
80
86
|
default: "Good",
|
|
81
87
|
},
|
|
82
88
|
cookie_consent: cookieConsentSchemaDefinition,
|
|
89
|
+
// Quiet hours (JA-3054): null = platform defaults apply (22:00–07:00
|
|
90
|
+
// America/New_York, see utils/quietHours.js). Pair rule: start/end are
|
|
91
|
+
// always both set or both null — enforced here at document validation
|
|
92
|
+
// (field validators + the pre-validate hook below), so create()/save()
|
|
93
|
+
// cannot persist an invalid window even if a consumer skips
|
|
94
|
+
// validateQuietHours. Query updates ($set via updateOne/findOneAndUpdate)
|
|
95
|
+
// bypass document validation, so readers still defend with
|
|
96
|
+
// resolveQuietHours.
|
|
97
|
+
quietHoursStart: {
|
|
98
|
+
type: String,
|
|
99
|
+
default: null,
|
|
100
|
+
validate: {
|
|
101
|
+
validator: (value) => value === null || isValidTimeString(value),
|
|
102
|
+
message: "quietHoursStart must be a time string in HH:mm format",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
quietHoursEnd: {
|
|
106
|
+
type: String,
|
|
107
|
+
default: null,
|
|
108
|
+
validate: {
|
|
109
|
+
validator: (value) => value === null || isValidTimeString(value),
|
|
110
|
+
message: "quietHoursEnd must be a time string in HH:mm format",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
timezone: {
|
|
114
|
+
type: String,
|
|
115
|
+
default: null,
|
|
116
|
+
validate: {
|
|
117
|
+
validator: (value) => value === null || isValidIANATimezone(value),
|
|
118
|
+
message: "timezone must be a valid IANA timezone identifier",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
83
121
|
},
|
|
84
122
|
{
|
|
85
123
|
timestamps: true,
|
|
86
124
|
}
|
|
87
125
|
);
|
|
88
126
|
|
|
127
|
+
// Cross-field quiet-hours rules (both-or-neither, start ≠ end) delegated to
|
|
128
|
+
// validateQuietHours so the schema and the standalone validator can never
|
|
129
|
+
// drift. Field-format errors are reported by the per-field validators above;
|
|
130
|
+
// this hook re-attaches only the cross-field codes those can't see (they
|
|
131
|
+
// need both fields). Allowlist on stable codes — never on message wording.
|
|
132
|
+
const CROSS_FIELD_QUIET_HOURS_CODES = new Set([
|
|
133
|
+
QUIET_HOURS_ERRORS.HALF_PAIR,
|
|
134
|
+
QUIET_HOURS_ERRORS.EQUAL_PAIR,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
usersSchema.pre("validate", function (next) {
|
|
138
|
+
const errors = validateQuietHours({
|
|
139
|
+
start: this.quietHoursStart,
|
|
140
|
+
end: this.quietHoursEnd,
|
|
141
|
+
});
|
|
142
|
+
for (const error of errors) {
|
|
143
|
+
if (CROSS_FIELD_QUIET_HOURS_CODES.has(error.code)) {
|
|
144
|
+
this.invalidate("quietHoursStart", error.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
next();
|
|
148
|
+
});
|
|
149
|
+
|
|
89
150
|
usersSchema.index({ phone: 1 });
|
|
90
151
|
|
|
91
152
|
const Users = mongoose.model("Users", usersSchema);
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
// Stable error codes for validateQuietHours. Consumers filter on `code`
|
|
27
|
+
// (e.g. the users schema pre-validate hook re-attaches only cross-field
|
|
28
|
+
// errors) — never on message wording, which is free to change.
|
|
29
|
+
export const QUIET_HOURS_ERRORS = Object.freeze({
|
|
30
|
+
HALF_PAIR: "half_pair",
|
|
31
|
+
FORMAT: "format",
|
|
32
|
+
EQUAL_PAIR: "equal_pair",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
36
|
+
|
|
37
|
+
// Formatter construction is the expensive part of Intl (~ms); formatToParts
|
|
38
|
+
// on a cached formatter is cheap. The cache also makes validation and
|
|
39
|
+
// evaluation share one authority: a timezone is valid iff Intl accepts it.
|
|
40
|
+
const formatterCache = new Map();
|
|
41
|
+
|
|
42
|
+
const getFormatter = (timeZone) => {
|
|
43
|
+
let formatter = formatterCache.get(timeZone);
|
|
44
|
+
if (formatter === undefined) {
|
|
45
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
46
|
+
timeZone,
|
|
47
|
+
hourCycle: "h23",
|
|
48
|
+
hour: "2-digit",
|
|
49
|
+
minute: "2-digit",
|
|
50
|
+
});
|
|
51
|
+
formatterCache.set(timeZone, formatter);
|
|
52
|
+
}
|
|
53
|
+
return formatter;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const isValidTimeString = (value) =>
|
|
57
|
+
typeof value === "string" && HHMM_PATTERN.test(value);
|
|
58
|
+
|
|
59
|
+
export const isValidIANATimezone = (timeZone) => {
|
|
60
|
+
if (typeof timeZone !== "string" || timeZone.trim() === "") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
getFormatter(timeZone);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Returns an array of { code, message } errors; empty array = valid. Codes
|
|
72
|
+
// come from QUIET_HOURS_ERRORS and are the stable API — messages are
|
|
73
|
+
// human-facing and may be reworded freely. Valid states are exactly two:
|
|
74
|
+
// both null/undefined (defaults apply) or both well-formed and distinct.
|
|
75
|
+
// Everything else is rejected so no ambiguous window is ever stored.
|
|
76
|
+
export const validateQuietHours = ({ start, end } = {}) => {
|
|
77
|
+
const startUnset = start === null || start === undefined;
|
|
78
|
+
const endUnset = end === null || end === undefined;
|
|
79
|
+
|
|
80
|
+
if (startUnset && endUnset) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (startUnset || endUnset) {
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
code: QUIET_HOURS_ERRORS.HALF_PAIR,
|
|
87
|
+
message:
|
|
88
|
+
"quietHoursStart and quietHoursEnd must be provided together (or both null to use the default window)",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const errors = [];
|
|
94
|
+
if (!isValidTimeString(start)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
code: QUIET_HOURS_ERRORS.FORMAT,
|
|
97
|
+
message: "quietHoursStart must be a time string in HH:mm format",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!isValidTimeString(end)) {
|
|
101
|
+
errors.push({
|
|
102
|
+
code: QUIET_HOURS_ERRORS.FORMAT,
|
|
103
|
+
message: "quietHoursEnd must be a time string in HH:mm format",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (errors.length === 0 && start === end) {
|
|
107
|
+
errors.push({
|
|
108
|
+
code: QUIET_HOURS_ERRORS.EQUAL_PAIR,
|
|
109
|
+
message:
|
|
110
|
+
"quietHoursStart and quietHoursEnd must differ (set both to null to disable a custom window)",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return errors;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const timeToMinutes = (value) => {
|
|
117
|
+
const [hours, minutes] = value.split(":");
|
|
118
|
+
return Number(hours) * 60 + Number(minutes);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Minutes since local midnight in `timeZone` at instant `date`. Throws on an
|
|
122
|
+
// invalid timezone — resolve with resolveQuietHours/isValidIANATimezone first.
|
|
123
|
+
export const getZoneMinutes = (date, timeZone) => {
|
|
124
|
+
const parts = getFormatter(timeZone).formatToParts(date);
|
|
125
|
+
let hours = 0;
|
|
126
|
+
let minutes = 0;
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
if (part.type === "hour") hours = Number(part.value);
|
|
129
|
+
else if (part.type === "minute") minutes = Number(part.value);
|
|
130
|
+
}
|
|
131
|
+
return hours * 60 + minutes;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// `minutesNow` from getZoneMinutes; `window` of "HH:mm" strings or null.
|
|
135
|
+
// Null window (or an equal pair, which resolveQuietHours flags) → never
|
|
136
|
+
// suppressed. See the file-header diagram for the two window shapes.
|
|
137
|
+
export const isInQuietWindow = (minutesNow, window) => {
|
|
138
|
+
if (!window || !isValidTimeString(window.start) || !isValidTimeString(window.end)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const start = timeToMinutes(window.start);
|
|
142
|
+
const end = timeToMinutes(window.end);
|
|
143
|
+
if (start === end) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (start < end) {
|
|
147
|
+
return minutesNow >= start && minutesNow < end;
|
|
148
|
+
}
|
|
149
|
+
return minutesNow >= start || minutesNow < end;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Resolves a user's stored fields into an evaluable { window, timezone },
|
|
153
|
+
// never throwing on corrupt data (JA-3020 D16). Fallback semantics:
|
|
154
|
+
// - fields null/undefined → defaults, no warning (the normal case)
|
|
155
|
+
// - invalid timezone → DEFAULT_TIMEZONE + warning
|
|
156
|
+
// - half-set or malformed pair → DEFAULT_QUIET_HOURS + warning (corrupt
|
|
157
|
+
// data keeps the protective default; a malformed-AND-identical pair like
|
|
158
|
+
// "25:00"/"25:00" is classified FORMAT by the validator and lands here)
|
|
159
|
+
// - well-formed equal pair → window: null (no suppression) + warning
|
|
160
|
+
// (JA-3020 D11 — ambiguous intent must never become "always quiet")
|
|
161
|
+
// Branching consumes the validator's stable error codes — never re-derives
|
|
162
|
+
// the classification from the raw values.
|
|
163
|
+
// Callers log `warnings` (once per run, not per user) and evaluate with
|
|
164
|
+
// isInQuietWindow(getZoneMinutes(now, timezone), window).
|
|
165
|
+
export const resolveQuietHours = (user) => {
|
|
166
|
+
const { quietHoursStart, quietHoursEnd, timezone } = user ?? {};
|
|
167
|
+
const warnings = [];
|
|
168
|
+
|
|
169
|
+
let resolvedTimezone = DEFAULT_TIMEZONE;
|
|
170
|
+
if (timezone !== null && timezone !== undefined) {
|
|
171
|
+
if (isValidIANATimezone(timezone)) {
|
|
172
|
+
resolvedTimezone = timezone;
|
|
173
|
+
} else {
|
|
174
|
+
warnings.push(
|
|
175
|
+
`invalid stored timezone "${timezone}" — falling back to ${DEFAULT_TIMEZONE}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const startUnset = quietHoursStart === null || quietHoursStart === undefined;
|
|
181
|
+
const endUnset = quietHoursEnd === null || quietHoursEnd === undefined;
|
|
182
|
+
|
|
183
|
+
let window = DEFAULT_QUIET_HOURS;
|
|
184
|
+
if (!startUnset || !endUnset) {
|
|
185
|
+
const errors = validateQuietHours({
|
|
186
|
+
start: quietHoursStart,
|
|
187
|
+
end: quietHoursEnd,
|
|
188
|
+
});
|
|
189
|
+
if (errors.length === 0) {
|
|
190
|
+
window = { start: quietHoursStart, end: quietHoursEnd };
|
|
191
|
+
} else if (
|
|
192
|
+
errors.some((e) => e.code === QUIET_HOURS_ERRORS.EQUAL_PAIR)
|
|
193
|
+
) {
|
|
194
|
+
window = null;
|
|
195
|
+
warnings.push(
|
|
196
|
+
`stored quiet hours are an equal pair ("${quietHoursStart}") — treating as no suppression`
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
warnings.push(
|
|
200
|
+
`stored quiet hours are invalid (start: ${JSON.stringify(
|
|
201
|
+
quietHoursStart
|
|
202
|
+
)}, end: ${JSON.stringify(
|
|
203
|
+
quietHoursEnd
|
|
204
|
+
)}) — falling back to the default window`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { window, timezone: resolvedTimezone, warnings };
|
|
210
|
+
};
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_QUIET_HOURS,
|
|
3
|
+
DEFAULT_TIMEZONE,
|
|
4
|
+
QUIET_HOURS_ERRORS,
|
|
5
|
+
isValidTimeString,
|
|
6
|
+
isValidIANATimezone,
|
|
7
|
+
validateQuietHours,
|
|
8
|
+
getZoneMinutes,
|
|
9
|
+
isInQuietWindow,
|
|
10
|
+
resolveQuietHours,
|
|
11
|
+
} from '../quietHours.js';
|
|
12
|
+
|
|
13
|
+
describe('constants', () => {
|
|
14
|
+
test('defaults match the JA-3020 contract (22:00–07:00 America/New_York)', () => {
|
|
15
|
+
expect(DEFAULT_QUIET_HOURS).toEqual({ start: '22:00', end: '07:00' });
|
|
16
|
+
expect(DEFAULT_TIMEZONE).toBe('America/New_York');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('DEFAULT_QUIET_HOURS is frozen', () => {
|
|
20
|
+
expect(Object.isFrozen(DEFAULT_QUIET_HOURS)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('isValidTimeString', () => {
|
|
25
|
+
test.each(['00:00', '07:00', '09:30', '23:59', '13:05'])(
|
|
26
|
+
'accepts %s',
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(isValidTimeString(value)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
test.each([
|
|
33
|
+
['24:00', 'hour out of range'],
|
|
34
|
+
['07:60', 'minute out of range'],
|
|
35
|
+
['7:30', 'missing leading zero'],
|
|
36
|
+
['9:5', 'single digits'],
|
|
37
|
+
['0730', 'no separator'],
|
|
38
|
+
['07:30:00', 'seconds included'],
|
|
39
|
+
['', 'empty string'],
|
|
40
|
+
['ten pm', 'words'],
|
|
41
|
+
])('rejects %s (%s)', (value) => {
|
|
42
|
+
expect(isValidTimeString(value)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test.each([null, undefined, 730, { start: '07:30' }])(
|
|
46
|
+
'rejects non-string %p',
|
|
47
|
+
(value) => {
|
|
48
|
+
expect(isValidTimeString(value)).toBe(false);
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('isValidIANATimezone', () => {
|
|
54
|
+
test.each([
|
|
55
|
+
'America/New_York',
|
|
56
|
+
'America/Detroit',
|
|
57
|
+
'America/Los_Angeles',
|
|
58
|
+
'UTC',
|
|
59
|
+
'Asia/Tokyo',
|
|
60
|
+
])('accepts %s', (tz) => {
|
|
61
|
+
expect(isValidIANATimezone(tz)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each([
|
|
65
|
+
'America/Fake_City',
|
|
66
|
+
'Not/A_Zone',
|
|
67
|
+
'EST GMT',
|
|
68
|
+
'',
|
|
69
|
+
' ',
|
|
70
|
+
])('rejects %p', (tz) => {
|
|
71
|
+
expect(isValidIANATimezone(tz)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test.each([null, undefined, 5, {}])('rejects non-string %p', (tz) => {
|
|
75
|
+
expect(isValidIANATimezone(tz)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('validateQuietHours', () => {
|
|
80
|
+
test('accepts both fields null (defaults apply)', () => {
|
|
81
|
+
expect(validateQuietHours({ start: null, end: null })).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('accepts both fields undefined / missing object', () => {
|
|
85
|
+
expect(validateQuietHours({})).toEqual([]);
|
|
86
|
+
expect(validateQuietHours()).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('accepts a well-formed distinct pair', () => {
|
|
90
|
+
expect(validateQuietHours({ start: '22:00', end: '07:00' })).toEqual([]);
|
|
91
|
+
expect(validateQuietHours({ start: '09:00', end: '17:00' })).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('rejects a half-set pair (start only) with a stable code', () => {
|
|
95
|
+
const errors = validateQuietHours({ start: '22:00', end: null });
|
|
96
|
+
expect(errors).toHaveLength(1);
|
|
97
|
+
expect(errors[0].code).toBe(QUIET_HOURS_ERRORS.HALF_PAIR);
|
|
98
|
+
expect(errors[0].message).toMatch(/together/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('rejects a half-set pair (end only) with a stable code', () => {
|
|
102
|
+
const errors = validateQuietHours({ start: undefined, end: '07:00' });
|
|
103
|
+
expect(errors).toHaveLength(1);
|
|
104
|
+
expect(errors[0].code).toBe(QUIET_HOURS_ERRORS.HALF_PAIR);
|
|
105
|
+
expect(errors[0].message).toMatch(/together/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('rejects malformed times, reporting each field with the format code', () => {
|
|
109
|
+
const errors = validateQuietHours({ start: '25:00', end: '7pm' });
|
|
110
|
+
expect(errors).toHaveLength(2);
|
|
111
|
+
expect(errors.every((e) => e.code === QUIET_HOURS_ERRORS.FORMAT)).toBe(true);
|
|
112
|
+
expect(errors[0].message).toMatch(/quietHoursStart/);
|
|
113
|
+
expect(errors[1].message).toMatch(/quietHoursEnd/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('rejects an equal pair with a stable code (JA-3020 D11)', () => {
|
|
117
|
+
const errors = validateQuietHours({ start: '22:00', end: '22:00' });
|
|
118
|
+
expect(errors).toHaveLength(1);
|
|
119
|
+
expect(errors[0].code).toBe(QUIET_HOURS_ERRORS.EQUAL_PAIR);
|
|
120
|
+
expect(errors[0].message).toMatch(/differ/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('error codes are frozen shared vocabulary', () => {
|
|
124
|
+
expect(Object.isFrozen(QUIET_HOURS_ERRORS)).toBe(true);
|
|
125
|
+
expect(QUIET_HOURS_ERRORS).toEqual({
|
|
126
|
+
HALF_PAIR: 'half_pair',
|
|
127
|
+
FORMAT: 'format',
|
|
128
|
+
EQUAL_PAIR: 'equal_pair',
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('getZoneMinutes', () => {
|
|
134
|
+
test('converts UTC instants to New York wall-clock minutes (EST, winter)', () => {
|
|
135
|
+
// 2026-01-15 15:30 UTC = 10:30 EST (UTC-5)
|
|
136
|
+
expect(
|
|
137
|
+
getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 15, 30)), 'America/New_York')
|
|
138
|
+
).toBe(10 * 60 + 30);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('converts UTC instants to New York wall-clock minutes (EDT, summer)', () => {
|
|
142
|
+
// 2026-07-15 15:30 UTC = 11:30 EDT (UTC-4)
|
|
143
|
+
expect(
|
|
144
|
+
getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 15, 30)), 'America/New_York')
|
|
145
|
+
).toBe(11 * 60 + 30);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('handles local midnight as 0 minutes (h23, not 24)', () => {
|
|
149
|
+
// 2026-01-15 05:00 UTC = 00:00 EST
|
|
150
|
+
expect(
|
|
151
|
+
getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 5, 0)), 'America/New_York')
|
|
152
|
+
).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('DST spring-forward: the 02:xx hour does not exist (2026-03-08)', () => {
|
|
156
|
+
// 06:59 UTC = 01:59 EST, one minute later 07:00 UTC = 03:00 EDT
|
|
157
|
+
expect(
|
|
158
|
+
getZoneMinutes(new Date(Date.UTC(2026, 2, 8, 6, 59)), 'America/New_York')
|
|
159
|
+
).toBe(1 * 60 + 59);
|
|
160
|
+
expect(
|
|
161
|
+
getZoneMinutes(new Date(Date.UTC(2026, 2, 8, 7, 0)), 'America/New_York')
|
|
162
|
+
).toBe(3 * 60);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('DST fall-back: 01:30 wall-clock occurs twice (2026-11-01)', () => {
|
|
166
|
+
// 05:30 UTC = 01:30 EDT and 06:30 UTC = 01:30 EST — same wall clock
|
|
167
|
+
expect(
|
|
168
|
+
getZoneMinutes(new Date(Date.UTC(2026, 10, 1, 5, 30)), 'America/New_York')
|
|
169
|
+
).toBe(1 * 60 + 30);
|
|
170
|
+
expect(
|
|
171
|
+
getZoneMinutes(new Date(Date.UTC(2026, 10, 1, 6, 30)), 'America/New_York')
|
|
172
|
+
).toBe(1 * 60 + 30);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('UTC zone is a passthrough of the instant wall clock', () => {
|
|
176
|
+
expect(
|
|
177
|
+
getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 15, 30)), 'UTC')
|
|
178
|
+
).toBe(15 * 60 + 30);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('throws on an invalid timezone (callers resolve first)', () => {
|
|
182
|
+
expect(() =>
|
|
183
|
+
getZoneMinutes(new Date(), 'America/Fake_City')
|
|
184
|
+
).toThrow();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('isInQuietWindow', () => {
|
|
189
|
+
const minutes = (hhmm) => {
|
|
190
|
+
const [h, m] = hhmm.split(':');
|
|
191
|
+
return Number(h) * 60 + Number(m);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
describe('same-day window 09:00–17:00', () => {
|
|
195
|
+
const window = { start: '09:00', end: '17:00' };
|
|
196
|
+
|
|
197
|
+
test.each(['09:00', '12:00', '16:59'])('%s is inside', (time) => {
|
|
198
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test.each(['08:59', '17:00', '22:00', '00:00'])('%s is outside', (time) => {
|
|
202
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('midnight-crossing window 22:00–07:00 (the default shape)', () => {
|
|
207
|
+
const window = { start: '22:00', end: '07:00' };
|
|
208
|
+
|
|
209
|
+
test.each(['22:00', '23:59', '00:00', '03:00', '06:59'])(
|
|
210
|
+
'%s is inside',
|
|
211
|
+
(time) => {
|
|
212
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(true);
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
test.each(['21:59', '07:00', '12:00', '17:35'])('%s is outside', (time) => {
|
|
217
|
+
expect(isInQuietWindow(minutes(time), window)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('null or malformed window never suppresses', () => {
|
|
222
|
+
expect(isInQuietWindow(0, null)).toBe(false);
|
|
223
|
+
expect(isInQuietWindow(0, undefined)).toBe(false);
|
|
224
|
+
expect(isInQuietWindow(0, {})).toBe(false);
|
|
225
|
+
expect(isInQuietWindow(0, { start: '22:00', end: 'bad' })).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('equal pair never suppresses (defensive, JA-3020 D11)', () => {
|
|
229
|
+
expect(isInQuietWindow(minutes('22:00'), { start: '22:00', end: '22:00' })).toBe(
|
|
230
|
+
false
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('resolveQuietHours', () => {
|
|
236
|
+
test('all fields null → defaults with no warnings (every legacy user)', () => {
|
|
237
|
+
const resolved = resolveQuietHours({
|
|
238
|
+
quietHoursStart: null,
|
|
239
|
+
quietHoursEnd: null,
|
|
240
|
+
timezone: null,
|
|
241
|
+
});
|
|
242
|
+
expect(resolved).toEqual({
|
|
243
|
+
window: DEFAULT_QUIET_HOURS,
|
|
244
|
+
timezone: DEFAULT_TIMEZONE,
|
|
245
|
+
warnings: [],
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('fields absent entirely → defaults with no warnings', () => {
|
|
250
|
+
expect(resolveQuietHours({})).toEqual({
|
|
251
|
+
window: DEFAULT_QUIET_HOURS,
|
|
252
|
+
timezone: DEFAULT_TIMEZONE,
|
|
253
|
+
warnings: [],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('null or undefined user → defaults, never throws', () => {
|
|
258
|
+
expect(resolveQuietHours(null)).toEqual({
|
|
259
|
+
window: DEFAULT_QUIET_HOURS,
|
|
260
|
+
timezone: DEFAULT_TIMEZONE,
|
|
261
|
+
warnings: [],
|
|
262
|
+
});
|
|
263
|
+
expect(resolveQuietHours(undefined).window).toEqual(DEFAULT_QUIET_HOURS);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('valid custom fields are used verbatim', () => {
|
|
267
|
+
const resolved = resolveQuietHours({
|
|
268
|
+
quietHoursStart: '23:00',
|
|
269
|
+
quietHoursEnd: '06:30',
|
|
270
|
+
timezone: 'America/Detroit',
|
|
271
|
+
});
|
|
272
|
+
expect(resolved).toEqual({
|
|
273
|
+
window: { start: '23:00', end: '06:30' },
|
|
274
|
+
timezone: 'America/Detroit',
|
|
275
|
+
warnings: [],
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('timezone set, hours unset → default window in THEIR zone (D5 matrix)', () => {
|
|
280
|
+
const resolved = resolveQuietHours({ timezone: 'America/Los_Angeles' });
|
|
281
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
282
|
+
expect(resolved.timezone).toBe('America/Los_Angeles');
|
|
283
|
+
expect(resolved.warnings).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('hours set, timezone unset → custom window in the default zone (D5 matrix)', () => {
|
|
287
|
+
const resolved = resolveQuietHours({
|
|
288
|
+
quietHoursStart: '23:00',
|
|
289
|
+
quietHoursEnd: '05:00',
|
|
290
|
+
});
|
|
291
|
+
expect(resolved.window).toEqual({ start: '23:00', end: '05:00' });
|
|
292
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
293
|
+
expect(resolved.warnings).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('invalid stored timezone → default zone + warning', () => {
|
|
297
|
+
const resolved = resolveQuietHours({ timezone: 'America/Fake_City' });
|
|
298
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
299
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
300
|
+
expect(resolved.warnings[0]).toMatch(/invalid stored timezone/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('half-set stored pair → default window + warning (D16)', () => {
|
|
304
|
+
const resolved = resolveQuietHours({ quietHoursStart: '22:00' });
|
|
305
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
306
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
307
|
+
expect(resolved.warnings[0]).toMatch(/invalid/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('malformed stored pair → default window + warning (D16)', () => {
|
|
311
|
+
const resolved = resolveQuietHours({
|
|
312
|
+
quietHoursStart: '25:00',
|
|
313
|
+
quietHoursEnd: '07:00',
|
|
314
|
+
});
|
|
315
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
316
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('stored equal pair → NO suppression + warning, never "always quiet" (D11)', () => {
|
|
320
|
+
const resolved = resolveQuietHours({
|
|
321
|
+
quietHoursStart: '22:00',
|
|
322
|
+
quietHoursEnd: '22:00',
|
|
323
|
+
});
|
|
324
|
+
expect(resolved.window).toBeNull();
|
|
325
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
326
|
+
expect(resolved.warnings[0]).toMatch(/equal pair/);
|
|
327
|
+
expect(isInQuietWindow(0, resolved.window)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('malformed-AND-identical pair is corrupt data, not an equal pair — default window + invalid warning', () => {
|
|
331
|
+
const resolved = resolveQuietHours({
|
|
332
|
+
quietHoursStart: '25:00',
|
|
333
|
+
quietHoursEnd: '25:00',
|
|
334
|
+
});
|
|
335
|
+
// FORMAT classification wins: the user keeps the protective default
|
|
336
|
+
// window instead of losing suppression entirely.
|
|
337
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
338
|
+
expect(resolved.warnings).toHaveLength(1);
|
|
339
|
+
expect(resolved.warnings[0]).toMatch(/invalid/);
|
|
340
|
+
expect(resolved.warnings[0]).not.toMatch(/equal pair/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('corrupt window and corrupt timezone warn independently', () => {
|
|
344
|
+
const resolved = resolveQuietHours({
|
|
345
|
+
quietHoursStart: 'bad',
|
|
346
|
+
quietHoursEnd: '07:00',
|
|
347
|
+
timezone: 'Nope/Nope',
|
|
348
|
+
});
|
|
349
|
+
expect(resolved.window).toEqual(DEFAULT_QUIET_HOURS);
|
|
350
|
+
expect(resolved.timezone).toBe(DEFAULT_TIMEZONE);
|
|
351
|
+
expect(resolved.warnings).toHaveLength(2);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('end-to-end: resolve + zone minutes + window (the cron gate path)', () => {
|
|
356
|
+
test('legacy user is suppressed at 3am ET and alertable at noon ET', () => {
|
|
357
|
+
const { window, timezone } = resolveQuietHours({});
|
|
358
|
+
// 2026-07-15 07:00 UTC = 03:00 EDT
|
|
359
|
+
const night = getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 7, 0)), timezone);
|
|
360
|
+
// 2026-07-15 16:00 UTC = 12:00 EDT
|
|
361
|
+
const noon = getZoneMinutes(new Date(Date.UTC(2026, 6, 15, 16, 0)), timezone);
|
|
362
|
+
expect(isInQuietWindow(night, window)).toBe(true);
|
|
363
|
+
expect(isInQuietWindow(noon, window)).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('legacy user default window is DST-stable at the ET wall clock (winter)', () => {
|
|
367
|
+
const { window, timezone } = resolveQuietHours({});
|
|
368
|
+
// 2026-01-15 08:00 UTC = 03:00 EST — still inside 22:00–07:00
|
|
369
|
+
const night = getZoneMinutes(new Date(Date.UTC(2026, 0, 15, 8, 0)), timezone);
|
|
370
|
+
expect(isInQuietWindow(night, window)).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('Detroit user with a custom 23:00–06:00 window gets a 22:30 local alert', () => {
|
|
374
|
+
const { window, timezone } = resolveQuietHours({
|
|
375
|
+
quietHoursStart: '23:00',
|
|
376
|
+
quietHoursEnd: '06:00',
|
|
377
|
+
timezone: 'America/Detroit',
|
|
378
|
+
});
|
|
379
|
+
// 2026-07-16 02:30 UTC = 22:30 EDT in Detroit — before their window opens
|
|
380
|
+
const at2230 = getZoneMinutes(new Date(Date.UTC(2026, 6, 16, 2, 30)), timezone);
|
|
381
|
+
expect(isInQuietWindow(at2230, window)).toBe(false);
|
|
382
|
+
// 2026-07-16 03:30 UTC = 23:30 EDT — inside
|
|
383
|
+
const at2330 = getZoneMinutes(new Date(Date.UTC(2026, 6, 16, 3, 30)), timezone);
|
|
384
|
+
expect(isInQuietWindow(at2330, window)).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
});
|