@justair/justair-library 7.2.0 → 7.3.0-alpha.139e195

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.
@@ -1,4 +1,5 @@
1
1
  export * from "./pollutants.js";
2
2
  export * from "./alertLevels.js";
3
3
  export * from "./userNotifications.js";
4
+ export * from "./timezones.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_TIMEZONE: "America/New_York";
2
+ //# sourceMappingURL=timezones.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAKA,kDAAmD"}
package/dist/index.d.ts CHANGED
@@ -86,19 +86,29 @@ import { AccountNotification } from "./models/userNotifications.js";
86
86
  import { NOTIFICATION_KINDS } from "./models/userNotifications.js";
87
87
  import { NOTIFICATION_CHANNELS } from "./models/userNotifications.js";
88
88
  import { DELIVERY_STATUSES } from "./models/userNotifications.js";
89
+ import { dataDownloadSchema } from "./models/dataDownloads.js";
90
+ import { DataDownloads } from "./models/dataDownloads.js";
91
+ import { AqiDataDownload } from "./models/dataDownloads.js";
92
+ import { SampleDataDownload } from "./models/dataDownloads.js";
93
+ import { MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS } from "./utils/dataDownloadValidation.js";
94
+ import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadValidation.js";
95
+ import { validateDateOrder } from "./utils/dataDownloadValidation.js";
96
+ import { validateMaxDateRange } from "./utils/dataDownloadValidation.js";
97
+ import { validateDownloadEmail } from "./utils/dataDownloadValidation.js";
98
+ import { normalizeDownloadEmail } from "./utils/dataDownloadValidation.js";
89
99
  import { PARAMETERS } from "./constants/pollutants.js";
90
100
  import { HEAVY_METALS } from "./constants/pollutants.js";
91
101
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
92
102
  import { getAlertWeight } from "./constants/alertLevels.js";
93
103
  import { splitName } from "./utils/splitName.js";
94
104
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
95
- import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
105
+ import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
96
106
  import { QUIET_HOURS_ERRORS } from "./utils/quietHours.js";
97
107
  import { isValidTimeString } from "./utils/quietHours.js";
98
- import { isValidIANATimezone } from "./utils/quietHours.js";
108
+ import { isValidIANATimezone } from "./utils/timezone.js";
99
109
  import { validateQuietHours } from "./utils/quietHours.js";
100
110
  import { getZoneMinutes } from "./utils/quietHours.js";
101
111
  import { isInQuietWindow } from "./utils/quietHours.js";
102
112
  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 };
113
+ 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, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDateOrder, validateMaxDateRange, validateDownloadEmail, normalizeDownloadEmail, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
104
114
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
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
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA6IA;;;iBAEC;yBA7EwB,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;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;mCAuC/B,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAgB3B,mCAAmC;qCAAnC,mCAAmC;kCAAnC,mCAAmC;qCAAnC,mCAAmC;sCAAnC,mCAAmC;uCAAnC,mCAAmC;2BAxED,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAiBtD,sBAAsB;oCASzC,uBAAuB;iCAzBG,0BAA0B;mCAyBpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
@@ -0,0 +1,5 @@
1
+ export const dataDownloadSchema: any;
2
+ export const DataDownloads: any;
3
+ export const AqiDataDownload: any;
4
+ export const SampleDataDownload: any;
5
+ //# sourceMappingURL=dataDownloads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAsBA,qCA4DE;AAqDF,gCAA0E;AAM1E,kCAEG;AAEH,qCAIE"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dataDownloads.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloads.test.d.ts","sourceRoot":"","sources":["../../../src/models/tests/dataDownloads.test.js"],"names":[],"mappings":""}
@@ -0,0 +1,21 @@
1
+ export const MAX_RANGE_DAYS: 31;
2
+ export const DATA_DOWNLOAD_ERRORS: Readonly<{
3
+ END_BEFORE_START: "end_before_start";
4
+ RANGE_TOO_LONG: "range_too_long";
5
+ INVALID_EMAIL: "invalid_email";
6
+ INVALID_TIMEZONE: "invalid_timezone";
7
+ }>;
8
+ export function validateDateOrder({ start, end }?: {}): {
9
+ code: "end_before_start";
10
+ message: string;
11
+ }[];
12
+ export function validateMaxDateRange({ start, end }?: {}): {
13
+ code: "range_too_long";
14
+ message: string;
15
+ }[];
16
+ export function normalizeDownloadEmail(value: any): any;
17
+ export function validateDownloadEmail({ email }?: {}): {
18
+ code: "invalid_email";
19
+ message: string;
20
+ }[];
21
+ //# sourceMappingURL=dataDownloadValidation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"AAUA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAMM,wDAC0D;AAI1D;;;IAUN"}
@@ -2,14 +2,12 @@ export const DEFAULT_QUIET_HOURS: Readonly<{
2
2
  start: "22:00";
3
3
  end: "07:00";
4
4
  }>;
5
- export const DEFAULT_TIMEZONE: "America/New_York";
6
5
  export const QUIET_HOURS_ERRORS: Readonly<{
7
6
  HALF_PAIR: "half_pair";
8
7
  FORMAT: "format";
9
8
  EQUAL_PAIR: "equal_pair";
10
9
  }>;
11
10
  export function isValidTimeString(value: any): boolean;
12
- export function isValidIANATimezone(timeZone: any): boolean;
13
11
  export function validateQuietHours({ start, end }?: {}): {
14
12
  code: "half_pair";
15
13
  message: string;
@@ -30,4 +28,7 @@ export function resolveQuietHours(user: any): {
30
28
  timezone: string;
31
29
  warnings: string[];
32
30
  };
31
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
32
+ import { isValidIANATimezone } from "./timezone.js";
33
+ export { DEFAULT_TIMEZONE, isValidIANATimezone };
33
34
  //# sourceMappingURL=quietHours.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":"AA0BA;;;GAGG;AAKH;;;;GAIG;AAII,uDACgD;AAOhD;;;;;;;;;KAsCN;AASM,iEASN;AAKM,uEAaN;AAeM;;;;;;;EA6CN;iCAtKgC,2BAA2B;oCACV,eAAe"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dataDownloadValidation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadValidation.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/dataDownloadValidation.test.js"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=timezone.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timezone.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/timezone.test.js"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ export function getFormatter(timeZone: any): any;
2
+ export function isValidIANATimezone(timeZone: any): boolean;
3
+ //# sourceMappingURL=timezone.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timezone.d.ts","sourceRoot":"","sources":["../../src/utils/timezone.js"],"names":[],"mappings":"AAaO,iDAYN;AAEM,4DAUN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.2.0",
3
+ "version": "7.3.0-alpha.139e195",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,3 +10,4 @@
10
10
  export * from "./pollutants.js";
11
11
  export * from "./alertLevels.js";
12
12
  export * from "./userNotifications.js";
13
+ export * from "./timezones.js";
@@ -6,6 +6,7 @@
6
6
  import * as barrel from '../index.js';
7
7
  import { PARAMETERS, HEAVY_METALS } from '../pollutants.js';
8
8
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from '../alertLevels.js';
9
+ import { DEFAULT_TIMEZONE } from '../timezones.js';
9
10
 
10
11
  test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
11
12
  expect(barrel.PARAMETERS).toBe(PARAMETERS);
@@ -44,3 +45,8 @@ test('unknown and legacy statuses fall back to the Good weight (never alert, nev
44
45
  expect(getAlertWeight(null)).toBe(1);
45
46
  expect(getAlertWeight('')).toBe(1);
46
47
  });
48
+
49
+ test('barrel exports DEFAULT_TIMEZONE (browser-safe, no Mongoose)', () => {
50
+ expect(barrel.DEFAULT_TIMEZONE).toBe(DEFAULT_TIMEZONE);
51
+ expect(DEFAULT_TIMEZONE).toBe('America/New_York');
52
+ });
@@ -0,0 +1,6 @@
1
+ // Mongoose-free timezone defaults, so a browser-safe consumer (e.g.
2
+ // JustAir-Client) can use the same default other domains store server-side.
3
+ // Originally lived in utils/quietHours.js (JA-3054); moved here once a
4
+ // second, unrelated domain (dataDownloads.js, JA-3080) needed it too.
5
+
6
+ export const DEFAULT_TIMEZONE = "America/New_York";
package/src/index.js CHANGED
@@ -67,6 +67,7 @@ import Database from "./config/db.js"; // Import the new Database class
67
67
  import CustomLogger from "./config/logger.js";
68
68
  import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
69
69
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
70
+ import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
70
71
  import {
71
72
  monitorEpisodesSchema,
72
73
  MonitorEpisode,
@@ -85,15 +86,14 @@ import {
85
86
  import { splitName } from "./utils/splitName.js";
86
87
  import {
87
88
  DEFAULT_QUIET_HOURS,
88
- DEFAULT_TIMEZONE,
89
89
  QUIET_HOURS_ERRORS,
90
90
  isValidTimeString,
91
- isValidIANATimezone,
92
91
  validateQuietHours,
93
92
  getZoneMinutes,
94
93
  isInQuietWindow,
95
94
  resolveQuietHours,
96
95
  } from "./utils/quietHours.js";
96
+ import { isValidIANATimezone } from "./utils/timezone.js";
97
97
 
98
98
  // Sites related imports
99
99
  import { sitesSchema, Sites } from "./models/sites.js";
@@ -116,6 +116,28 @@ import {
116
116
  sampleParameterReferenceConcentrations,
117
117
  } from "./models/samples.js";
118
118
  import { passwordResetTokenSchema, PasswordResetToken } from "./models/passwordResetToken.js";
119
+ import {
120
+ dataDownloadSchema,
121
+ DataDownloads,
122
+ AqiDataDownload,
123
+ SampleDataDownload,
124
+ } from "./models/dataDownloads.js";
125
+ import {
126
+ // Renamed at the barrel only: the root export namespace is shared by every
127
+ // consuming service, so a bare MAX_RANGE_DAYS gives no hint it's specific to
128
+ // data downloads. Module-internal name is unchanged.
129
+ MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS,
130
+ DATA_DOWNLOAD_ERRORS,
131
+ // The rule ships with the number (round 6, I1). The enforced bound is
132
+ // MAX_RANGE_DAYS plus a 1-hour DST grace, so a consumer rebuilding the cap
133
+ // as `DATA_DOWNLOAD_MAX_RANGE_DAYS * 86400000` builds a stricter limit than
134
+ // the model and rejects a 745-hour range the schema saves. Same reason
135
+ // validateQuietHours is exported rather than just DEFAULT_QUIET_HOURS.
136
+ validateDateOrder,
137
+ validateMaxDateRange,
138
+ validateDownloadEmail,
139
+ normalizeDownloadEmail,
140
+ } from "./utils/dataDownloadValidation.js";
119
141
 
120
142
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
121
143
  return new CustomLogger({ DATADOG_API_KEY, APPLICATION_NAME });
@@ -210,6 +232,17 @@ export {
210
232
  NOTIFICATION_KINDS,
211
233
  NOTIFICATION_CHANNELS,
212
234
  DELIVERY_STATUSES,
235
+ // Data downloads (JA-3080)
236
+ dataDownloadSchema,
237
+ DataDownloads,
238
+ AqiDataDownload,
239
+ SampleDataDownload,
240
+ DATA_DOWNLOAD_MAX_RANGE_DAYS,
241
+ DATA_DOWNLOAD_ERRORS,
242
+ validateDateOrder,
243
+ validateMaxDateRange,
244
+ validateDownloadEmail,
245
+ normalizeDownloadEmail,
213
246
  // Pollutant constants
214
247
  PARAMETERS,
215
248
  HEAVY_METALS,
@@ -0,0 +1,152 @@
1
+ import mongoose from "mongoose";
2
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
3
+ import { isValidIANATimezone } from "../utils/timezone.js";
4
+ import {
5
+ DATA_DOWNLOAD_ERRORS,
6
+ validateDateOrder,
7
+ validateMaxDateRange,
8
+ validateDownloadEmail,
9
+ normalizeDownloadEmail,
10
+ } from "../utils/dataDownloadValidation.js";
11
+
12
+ // DataDownloads — one doc per data download request (JA-3080). Written by
13
+ // JustAirService on download (JA-3081); read by the org-level admin
14
+ // communication screen. AQI downloads are monitor-scoped, Sample downloads
15
+ // are site-scoped (samples.js keys samples to SampleSites, not Monitors) —
16
+ // modeled as discriminators per CLAUDE.md's multi-kind pattern rather than
17
+ // a flat schema with a monitorId/siteId pair that's nullable either way.
18
+ //
19
+ // The base model permits a kind-less document (create() without `dataType`)
20
+ // — same property `userNotifications.js`'s base model has. Write through
21
+ // AqiDataDownload/SampleDataDownload, not DataDownloads, so every stored
22
+ // document has the ref field the admin screen needs to render it.
23
+ const dataDownloadSchema = mongoose.Schema(
24
+ {
25
+ orgId: {
26
+ type: mongoose.Types.ObjectId,
27
+ ref: "Organizations",
28
+ required: [true, "orgId is required"],
29
+ },
30
+ dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
31
+ dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
32
+ // `default` only applies to `undefined`, not `null`, so an explicit
33
+ // `timezone: null` (the normal shape of "didn't pick one" in a JSON body)
34
+ // is coerced in a setter — setters run on query updates, the pre('validate')
35
+ // hook below does not.
36
+ //
37
+ // This guarantees less than "always valid": `runValidators` defaults to
38
+ // false on query updates, so an updateOne can still persist a bad zone.
39
+ // Readers feeding this to Intl.DateTimeFormat must still defend.
40
+ timezone: {
41
+ type: String,
42
+ default: DEFAULT_TIMEZONE,
43
+ set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
44
+ validate: {
45
+ validator: isValidIANATimezone,
46
+ message: "timezone must be a valid IANA timezone identifier",
47
+ type: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
48
+ },
49
+ },
50
+ downloadedAt: { type: Date, required: true, default: Date.now },
51
+ // Figma's download modal requires checking "I have reviewed and agree to
52
+ // the Data Sharing Agreement" before the download proceeds (JA-3245 round
53
+ // 5, S5). Stored as the acceptance timestamp rather than a boolean so the
54
+ // record answers "when did they agree", which is what an audit trail of a
55
+ // consent decision needs. null means no acceptance was recorded — neither
56
+ // JA-3245 nor JA-3080 lists this field, so it stays optional until the
57
+ // intake endpoint (JA-3081) decides whether to require it.
58
+ agreementAcceptedAt: { type: Date, default: null },
59
+ // Per-field validator rather than a pre('validate') hook, so it still
60
+ // applies on updateOne()/findOneAndUpdate() with { runValidators: true }.
61
+ // `type` surfaces as ValidatorError.kind, keeping the stable-code contract.
62
+ email: {
63
+ type: String,
64
+ trim: true,
65
+ lowercase: true,
66
+ default: null,
67
+ // RFC 5321's maximum forward-path length. EMAIL_PATTERN is unbounded, so
68
+ // without this a multi-kilobyte string of the shape a@b.c validates and
69
+ // stores on a public-facing field (round 6, S3).
70
+ maxlength: [254, "email must be 254 characters or fewer"],
71
+ set: normalizeDownloadEmail,
72
+ validate: {
73
+ validator: (value) => validateDownloadEmail({ email: value }).length === 0,
74
+ message: "email must be a valid email address",
75
+ type: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
76
+ },
77
+ },
78
+ },
79
+ {
80
+ timestamps: true,
81
+ discriminatorKey: "dataType",
82
+ }
83
+ );
84
+
85
+ // Cross-field rule (quiet-hours shape, CLAUDE.md). Applies to every kind; the
86
+ // 31-day cap is AQI-only and lives on AqiDataDownload below. Only genuinely
87
+ // cross-field rules belong here — per-field rules stay on their fields, since
88
+ // pre('validate') hooks don't run on query updates.
89
+ //
90
+ // Passes the error code as invalidate()'s `kind` so a caught ValidationError
91
+ // can be classified via err.errors.<path>.kind, not by message wording.
92
+ dataDownloadSchema.pre("validate", function () {
93
+ const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
94
+ for (const error of rangeErrors) {
95
+ this.invalidate("dateRangeEnd", error.message, this.dateRangeEnd, error.code);
96
+ }
97
+ });
98
+
99
+ // Org-level admin screen lists a given org's downloads, most recent first,
100
+ // by the domain-meaningful "date of the download" (JA-3080), not createdAt.
101
+ dataDownloadSchema.index({ orgId: 1, downloadedAt: -1 });
102
+
103
+ const aqiDataDownloadSchema = new mongoose.Schema({
104
+ // Figma's download modal titles the AQI variant "Download Data for:
105
+ // {Site Name}". Resolve that label via monitorId -> Monitors.monitorCode,
106
+ // not a Monitors -> Sites.name join: Monitors.siteId is only populated by
107
+ // POST /devices's linked-device flow, so a Sites join renders blank for a
108
+ // meaningful share of monitors.
109
+ monitorId: {
110
+ type: mongoose.Types.ObjectId,
111
+ ref: "Monitors",
112
+ required: [true, "monitorId is required"],
113
+ },
114
+ });
115
+ aqiDataDownloadSchema.index({ monitorId: 1, downloadedAt: -1 });
116
+
117
+ // AQI-only: the 31-day cap does not apply to Sample downloads. Mongoose
118
+ // merges base + discriminator schema hooks by default (mergeHooks), so this
119
+ // runs alongside (not instead of) the base's validateDateOrder check above.
120
+ aqiDataDownloadSchema.pre("validate", function () {
121
+ const errors = validateMaxDateRange({ start: this.dateRangeStart, end: this.dateRangeEnd });
122
+ for (const error of errors) {
123
+ this.invalidate("dateRangeEnd", error.message, this.dateRangeEnd, error.code);
124
+ }
125
+ });
126
+
127
+ const sampleDataDownloadSchema = new mongoose.Schema({
128
+ siteId: {
129
+ type: mongoose.Types.ObjectId,
130
+ ref: "SampleSites",
131
+ required: [true, "siteId is required"],
132
+ },
133
+ });
134
+ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
135
+
136
+ const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
137
+
138
+ // Registry names are namespaced (matches userNotifications.js): bare "AQI" /
139
+ // "Sample" model-registry keys would collide with a consuming service's own
140
+ // models of those names. { value } keeps the persisted dataType as JA-3245
141
+ // specifies regardless of the registry name.
142
+ const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
143
+ value: "AQI",
144
+ });
145
+
146
+ const SampleDataDownload = DataDownloads.discriminator(
147
+ "SampleDataDownload",
148
+ sampleDataDownloadSchema,
149
+ { value: "Sample" }
150
+ );
151
+
152
+ export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
@@ -0,0 +1,98 @@
1
+ // Data download validation (JA-3080/JA-3245): the shared field and cross-field
2
+ // rules for a DataDownloads document — date range order, the AQI-only length
3
+ // cap, and the optional email. Pure functions, no DB, no clock reads. Same
4
+ // shape as quietHours.js's cross-field validator.
5
+ //
6
+ // Two separate rules, deliberately not one function: end-must-not-precede-
7
+ // start applies to every download kind, but the 31-day cap is AQI-only.
8
+
9
+ // Messaging only ("range cannot exceed 31 days"). validateMaxDateRange is the
10
+ // sole authority on the enforced bound; don't rebuild the cap from this.
11
+ export const MAX_RANGE_DAYS = 31;
12
+
13
+ // DST slack: a calendar-accurate 31-day span crossing a fall-back transition
14
+ // is 745 (not 744) hours long, so allow the extra hour rather than rejecting
15
+ // a range the product spec promises as valid.
16
+ const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
17
+
18
+ // Stable error codes for every validation failure this model can produce,
19
+ // including the schema-level timezone check. Consumers filter on `code` —
20
+ // never on message wording, which is free to change.
21
+ export const DATA_DOWNLOAD_ERRORS = Object.freeze({
22
+ END_BEFORE_START: "end_before_start",
23
+ RANGE_TOO_LONG: "range_too_long",
24
+ INVALID_EMAIL: "invalid_email",
25
+ INVALID_TIMEZONE: "invalid_timezone",
26
+ });
27
+
28
+ // Coerces both ends through `new Date(...)`: a JSON request body hands these
29
+ // raw strings, and comparing un-coerced strings silently produces NaN, so the
30
+ // caps would never fire. Unparsable input returns null — format validation is
31
+ // the schema layer's job, not a cross-field rule's.
32
+ const toTimes = (start, end) => {
33
+ if (!start || !end) {
34
+ return null;
35
+ }
36
+ const startTime = new Date(start).getTime();
37
+ const endTime = new Date(end).getTime();
38
+ if (Number.isNaN(startTime) || Number.isNaN(endTime)) {
39
+ return null;
40
+ }
41
+ return { startTime, endTime };
42
+ };
43
+
44
+ // Returns { code, message }[]; empty = valid. Applies to every download kind.
45
+ export const validateDateOrder = ({ start, end } = {}) => {
46
+ const times = toTimes(start, end);
47
+ if (!times) {
48
+ return [];
49
+ }
50
+ if (times.endTime < times.startTime) {
51
+ return [
52
+ {
53
+ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START,
54
+ message: "dateRangeEnd must not be before dateRangeStart",
55
+ },
56
+ ];
57
+ }
58
+ return [];
59
+ };
60
+
61
+ // Returns { code, message }[]; empty = valid. AQI only — Sample downloads have
62
+ // no range limit. Assumes order was already checked by validateDateOrder.
63
+ export const validateMaxDateRange = ({ start, end } = {}) => {
64
+ const times = toTimes(start, end);
65
+ if (!times || times.endTime < times.startTime) {
66
+ return [];
67
+ }
68
+ if (times.endTime - times.startTime > MAX_RANGE_MS) {
69
+ return [
70
+ {
71
+ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG,
72
+ message: `date range cannot exceed ${MAX_RANGE_DAYS} days`,
73
+ },
74
+ ];
75
+ }
76
+ return [];
77
+ };
78
+
79
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
80
+
81
+ // Email is optional (JA-3080): a blank form field means "not sharing my email",
82
+ // not "invalid email", so a blank string collapses to null.
83
+ export const normalizeDownloadEmail = (value) =>
84
+ typeof value === "string" && value.trim() === "" ? null : value;
85
+
86
+ // Returns { code, message }[]; empty = valid. null/undefined is valid — call
87
+ // normalizeDownloadEmail first so a blank string arrives as null.
88
+ export const validateDownloadEmail = ({ email } = {}) => {
89
+ if (email === null || email === undefined || EMAIL_PATTERN.test(email)) {
90
+ return [];
91
+ }
92
+ return [
93
+ {
94
+ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
95
+ message: "email must be a valid email address",
96
+ },
97
+ ];
98
+ };
@@ -16,13 +16,19 @@
16
16
  // Boundary rule: start is inclusive, end is exclusive — a 22:00–07:00 user
17
17
  // is suppressed at 22:00 sharp and receives alerts again at 07:00 sharp.
18
18
 
19
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
20
+ import { getFormatter, isValidIANATimezone } from "./timezone.js";
21
+
22
+ // Re-exported for existing backend imports — canonical definitions now live
23
+ // in src/constants/timezones.js (Mongoose-free, browser-safe) and
24
+ // ./timezone.js (generic IANA checks, nothing quiet-hours-specific).
25
+ export { DEFAULT_TIMEZONE, isValidIANATimezone };
26
+
19
27
  export const DEFAULT_QUIET_HOURS = Object.freeze({
20
28
  start: "22:00",
21
29
  end: "07:00",
22
30
  });
23
31
 
24
- export const DEFAULT_TIMEZONE = "America/New_York";
25
-
26
32
  // Stable error codes for validateQuietHours. Consumers filter on `code`
27
33
  // (e.g. the users schema pre-validate hook re-attaches only cross-field
28
34
  // errors) — never on message wording, which is free to change.
@@ -34,40 +40,9 @@ export const QUIET_HOURS_ERRORS = Object.freeze({
34
40
 
35
41
  const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
36
42
 
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
43
  export const isValidTimeString = (value) =>
57
44
  typeof value === "string" && HHMM_PATTERN.test(value);
58
45
 
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
46
  // Returns an array of { code, message } errors; empty array = valid. Codes
72
47
  // come from QUIET_HOURS_ERRORS and are the stable API — messages are
73
48
  // human-facing and may be reworded freely. Valid states are exactly two:
@@ -0,0 +1,133 @@
1
+ import {
2
+ MAX_RANGE_DAYS,
3
+ DATA_DOWNLOAD_ERRORS,
4
+ validateDateOrder,
5
+ validateMaxDateRange,
6
+ validateDownloadEmail,
7
+ normalizeDownloadEmail,
8
+ } from "../dataDownloadValidation.js";
9
+
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+ const HOUR_MS = 60 * 60 * 1000;
12
+ const start = new Date("2026-01-01T00:00:00.000Z");
13
+
14
+ describe("validateDateOrder (applies to every download kind)", () => {
15
+ test("returns no errors when start/end are missing", () => {
16
+ expect(validateDateOrder({})).toEqual([]);
17
+ expect(validateDateOrder({ start })).toEqual([]);
18
+ expect(validateDateOrder({ end: start })).toEqual([]);
19
+ });
20
+
21
+ test("returns no errors for a valid order", () => {
22
+ const end = new Date(start.getTime() + DAY_MS);
23
+ expect(validateDateOrder({ start, end })).toEqual([]);
24
+ });
25
+
26
+ test("flags end before start with END_BEFORE_START", () => {
27
+ const end = new Date(start.getTime() - DAY_MS);
28
+ expect(validateDateOrder({ start, end })).toEqual([
29
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START }),
30
+ ]);
31
+ });
32
+
33
+ test("does not flag a range far longer than MAX_RANGE_DAYS — that's validateMaxDateRange's job", () => {
34
+ const end = new Date(start.getTime() + 365 * DAY_MS);
35
+ expect(validateDateOrder({ start, end })).toEqual([]);
36
+ });
37
+
38
+ // JA-3081's intake endpoint hands this raw JSON request-body strings, not
39
+ // Date instances — must not silently misjudge order via NaN-producing
40
+ // string arithmetic (see dataDownloadValidation.js's toTimes coercion comment).
41
+ test("flags end before start given as raw date strings", () => {
42
+ expect(
43
+ validateDateOrder({ start: "2026-05-01", end: "2026-01-01" })
44
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START })]);
45
+ });
46
+
47
+ test("returns no errors for an unparsable date value", () => {
48
+ expect(validateDateOrder({ start: "not-a-date", end: start })).toEqual([]);
49
+ });
50
+ });
51
+
52
+ describe("validateMaxDateRange (AQI only — Sample downloads have no cap)", () => {
53
+ test("returns no errors when start/end are missing", () => {
54
+ expect(validateMaxDateRange({})).toEqual([]);
55
+ });
56
+
57
+ test("accepts exactly MAX_RANGE_DAYS", () => {
58
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS);
59
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
60
+ });
61
+
62
+ // The +1h grace exists so a calendar-accurate 31-day span crossing a DST
63
+ // fall-back transition (745 real hours, not 744) still validates — see
64
+ // dataDownloadValidation.js's MAX_RANGE_MS comment.
65
+ test("accepts MAX_RANGE_DAYS plus the 1-hour DST grace", () => {
66
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
67
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
68
+ });
69
+
70
+ test("flags a range longer than MAX_RANGE_DAYS plus the DST grace with RANGE_TOO_LONG", () => {
71
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS + 1);
72
+ expect(validateMaxDateRange({ start, end })).toEqual([
73
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG }),
74
+ ]);
75
+ });
76
+
77
+ test("flags a too-long range given as raw date strings (JSON request-body shape)", () => {
78
+ expect(
79
+ validateMaxDateRange({ start: "2026-01-01", end: "2027-06-01" })
80
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG })]);
81
+ });
82
+
83
+ test("does not re-report a reversed range as RANGE_TOO_LONG — validateDateOrder owns that", () => {
84
+ const end = new Date(start.getTime() - DAY_MS);
85
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
86
+ });
87
+
88
+ test("returns no errors for an unparsable date value", () => {
89
+ expect(validateMaxDateRange({ start: "not-a-date", end: start })).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () => {
94
+ test("collapses an empty or whitespace-only string to null", () => {
95
+ expect(normalizeDownloadEmail("")).toBeNull();
96
+ expect(normalizeDownloadEmail(" ")).toBeNull();
97
+ });
98
+
99
+ test("passes through a non-blank string unchanged", () => {
100
+ expect(normalizeDownloadEmail("someone@example.com")).toBe("someone@example.com");
101
+ });
102
+
103
+ test("passes through non-string values unchanged — not this function's job to type-check", () => {
104
+ expect(normalizeDownloadEmail(null)).toBeNull();
105
+ expect(normalizeDownloadEmail(undefined)).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe("validateDownloadEmail (email is optional — JA-3080)", () => {
110
+ test("returns no errors when email is null or undefined", () => {
111
+ expect(validateDownloadEmail({})).toEqual([]);
112
+ expect(validateDownloadEmail({ email: null })).toEqual([]);
113
+ expect(validateDownloadEmail({ email: undefined })).toEqual([]);
114
+ });
115
+
116
+ test("returns no errors for a well-formed email", () => {
117
+ expect(validateDownloadEmail({ email: "someone@example.com" })).toEqual([]);
118
+ });
119
+
120
+ test("flags a malformed email with INVALID_EMAIL", () => {
121
+ expect(validateDownloadEmail({ email: "not an email" })).toEqual([
122
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL }),
123
+ ]);
124
+ });
125
+
126
+ // Caller is expected to run normalizeDownloadEmail first — an un-normalized
127
+ // blank string is not the same as null and should still fail the pattern.
128
+ test("flags a blank string that hasn't been normalized to null yet", () => {
129
+ expect(validateDownloadEmail({ email: " " })).toEqual([
130
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL }),
131
+ ]);
132
+ });
133
+ });
@@ -0,0 +1,45 @@
1
+ import { getFormatter, isValidIANATimezone } from '../timezone.js';
2
+
3
+ describe('isValidIANATimezone', () => {
4
+ test.each([
5
+ 'America/New_York',
6
+ 'America/Detroit',
7
+ 'America/Los_Angeles',
8
+ 'UTC',
9
+ 'Asia/Tokyo',
10
+ ])('accepts %s', (tz) => {
11
+ expect(isValidIANATimezone(tz)).toBe(true);
12
+ });
13
+
14
+ test.each([
15
+ 'America/Fake_City',
16
+ 'Not/A_Zone',
17
+ 'EST GMT',
18
+ '',
19
+ ' ',
20
+ ])('rejects %p', (tz) => {
21
+ expect(isValidIANATimezone(tz)).toBe(false);
22
+ });
23
+
24
+ test.each([null, undefined, 5, {}])('rejects non-string %p', (tz) => {
25
+ expect(isValidIANATimezone(tz)).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('getFormatter', () => {
30
+ test('returns a formatter that renders wall-clock time in the given zone', () => {
31
+ const formatter = getFormatter('UTC');
32
+ const parts = formatter.formatToParts(new Date('2026-01-01T13:45:00.000Z'));
33
+ const hour = parts.find((p) => p.type === 'hour').value;
34
+ const minute = parts.find((p) => p.type === 'minute').value;
35
+ expect(`${hour}:${minute}`).toBe('13:45');
36
+ });
37
+
38
+ test('caches the formatter instance per timezone', () => {
39
+ expect(getFormatter('America/New_York')).toBe(getFormatter('America/New_York'));
40
+ });
41
+
42
+ test('throws for an invalid timezone — isValidIANATimezone exists to guard this', () => {
43
+ expect(() => getFormatter('Not/A_Zone')).toThrow();
44
+ });
45
+ });
@@ -0,0 +1,38 @@
1
+ // Generic IANA timezone utilities. No quiet-hours domain logic here (unlike
2
+ // quietHours.js, which imports isValidIANATimezone from this file) so that
3
+ // other consumers of the timezone vocabulary — e.g. dataDownloads.js's
4
+ // `timezone` field (JA-3245) — don't have to reach into an unrelated
5
+ // domain's module to validate one. Re-exported from quietHours.js so
6
+ // existing imports (including the library barrel) keep working.
7
+
8
+ // Formatter construction is the expensive part of Intl (~ms); formatToParts
9
+ // on a cached formatter is cheap. The cache also makes validation and
10
+ // zone-minute lookups (quietHours.js's getZoneMinutes) share one authority:
11
+ // a timezone is valid iff Intl accepts it.
12
+ const formatterCache = new Map();
13
+
14
+ export const getFormatter = (timeZone) => {
15
+ let formatter = formatterCache.get(timeZone);
16
+ if (formatter === undefined) {
17
+ formatter = new Intl.DateTimeFormat("en-US", {
18
+ timeZone,
19
+ hourCycle: "h23",
20
+ hour: "2-digit",
21
+ minute: "2-digit",
22
+ });
23
+ formatterCache.set(timeZone, formatter);
24
+ }
25
+ return formatter;
26
+ };
27
+
28
+ export const isValidIANATimezone = (timeZone) => {
29
+ if (typeof timeZone !== "string" || timeZone.trim() === "") {
30
+ return false;
31
+ }
32
+ try {
33
+ getFormatter(timeZone);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ };