@justair/justair-library 7.1.0 → 7.2.0-alpha.29d5ea3

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":"AAMA,kDAAmD"}
package/dist/index.d.ts CHANGED
@@ -86,19 +86,30 @@ 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 } from "./utils/dataDownloadRange.js";
94
+ import { MAX_RANGE_MS } from "./utils/dataDownloadRange.js";
95
+ import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadRange.js";
96
+ import { validateDateOrder } from "./utils/dataDownloadRange.js";
97
+ import { validateMaxDateRange } from "./utils/dataDownloadRange.js";
98
+ import { validateDownloadEmail } from "./utils/dataDownloadRange.js";
99
+ import { normalizeDownloadEmail } from "./utils/dataDownloadRange.js";
89
100
  import { PARAMETERS } from "./constants/pollutants.js";
90
101
  import { HEAVY_METALS } from "./constants/pollutants.js";
91
102
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
92
103
  import { getAlertWeight } from "./constants/alertLevels.js";
93
104
  import { splitName } from "./utils/splitName.js";
94
105
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
95
- import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
106
+ import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
96
107
  import { QUIET_HOURS_ERRORS } from "./utils/quietHours.js";
97
108
  import { isValidTimeString } from "./utils/quietHours.js";
98
- import { isValidIANATimezone } from "./utils/quietHours.js";
109
+ import { isValidIANATimezone } from "./utils/timezone.js";
99
110
  import { validateQuietHours } from "./utils/quietHours.js";
100
111
  import { getZoneMinutes } from "./utils/quietHours.js";
101
112
  import { isInQuietWindow } from "./utils/quietHours.js";
102
113
  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 };
114
+ 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, MAX_RANGE_DAYS, MAX_RANGE_MS, 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
115
  //# 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":"AAsIA;;;iBAEC;yBAtEwB,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;+BAS3B,8BAA8B;6BAA9B,8BAA8B;qCAA9B,8BAA8B;kCAA9B,8BAA8B;qCAA9B,8BAA8B;sCAA9B,8BAA8B;uCAA9B,8BAA8B;2BAjEI,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":"AAqBA,qCAmCE;AAoEF,gCAA0E;AAO1E,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 MAX_RANGE_MS: number;
3
+ export const DATA_DOWNLOAD_ERRORS: Readonly<{
4
+ END_BEFORE_START: "end_before_start";
5
+ RANGE_TOO_LONG: "range_too_long";
6
+ INVALID_EMAIL: "invalid_email";
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=dataDownloadRange.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"AAmBA,gCAAiC;AAMjC,kCAAkF;AAKlF;;;;GAIG;AA0BI;;;IAcN;AAMM;;;IAcN;AASM,wDAC0D;AAK1D;;;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=dataDownloadRange.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadRange.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/dataDownloadRange.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.1.0",
3
+ "version": "7.2.0-alpha.29d5ea3",
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,7 @@
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 — see
5
+ // CLAUDE.md's "Note on constants placement".
6
+
7
+ 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,21 @@ 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
+ MAX_RANGE_DAYS,
127
+ MAX_RANGE_MS,
128
+ DATA_DOWNLOAD_ERRORS,
129
+ validateDateOrder,
130
+ validateMaxDateRange,
131
+ validateDownloadEmail,
132
+ normalizeDownloadEmail,
133
+ } from "./utils/dataDownloadRange.js";
119
134
 
120
135
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
121
136
  return new CustomLogger({ DATADOG_API_KEY, APPLICATION_NAME });
@@ -210,6 +225,18 @@ export {
210
225
  NOTIFICATION_KINDS,
211
226
  NOTIFICATION_CHANNELS,
212
227
  DELIVERY_STATUSES,
228
+ // Data downloads (JA-3080)
229
+ dataDownloadSchema,
230
+ DataDownloads,
231
+ AqiDataDownload,
232
+ SampleDataDownload,
233
+ MAX_RANGE_DAYS,
234
+ MAX_RANGE_MS,
235
+ DATA_DOWNLOAD_ERRORS,
236
+ validateDateOrder,
237
+ validateMaxDateRange,
238
+ validateDownloadEmail,
239
+ normalizeDownloadEmail,
213
240
  // Pollutant constants
214
241
  PARAMETERS,
215
242
  HEAVY_METALS,
@@ -0,0 +1,142 @@
1
+ import mongoose from "mongoose";
2
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
3
+ import { isValidIANATimezone } from "../utils/timezone.js";
4
+ import {
5
+ validateDateOrder,
6
+ validateMaxDateRange,
7
+ validateDownloadEmail,
8
+ normalizeDownloadEmail,
9
+ } from "../utils/dataDownloadRange.js";
10
+
11
+ // DataDownloads — one doc per data download request (JA-3080). Written by
12
+ // JustAirService on download (JA-3081); read by the org-level admin
13
+ // communication screen. AQI downloads are monitor-scoped, Sample downloads
14
+ // are site-scoped (samples.js keys samples to SampleSites, not Monitors) —
15
+ // modeled as discriminators per CLAUDE.md's multi-kind pattern rather than
16
+ // a flat schema with a monitorId/siteId pair that's nullable either way.
17
+ //
18
+ // The base model permits a kind-less document (create() without `dataType`)
19
+ // — same property `userNotifications.js`'s base model has. Write through
20
+ // AqiDataDownload/SampleDataDownload, not DataDownloads, so every stored
21
+ // document has the ref field the admin screen needs to render it.
22
+ const dataDownloadSchema = mongoose.Schema(
23
+ {
24
+ orgId: {
25
+ type: mongoose.Types.ObjectId,
26
+ ref: "Organizations",
27
+ required: [true, "orgId is required"],
28
+ },
29
+ dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
30
+ dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
31
+ timezone: {
32
+ type: String,
33
+ default: DEFAULT_TIMEZONE,
34
+ validate: {
35
+ validator: isValidIANATimezone,
36
+ message: "timezone must be a valid IANA timezone identifier",
37
+ },
38
+ },
39
+ downloadedAt: { type: Date, required: true, default: Date.now },
40
+ // Format validation is delegated to validateDownloadEmail in the
41
+ // pre('validate') hook below, not an inline schema validator, so this
42
+ // and JA-3081's intake endpoint (validating the request body before a
43
+ // document exists) can never enforce two different email rules. See
44
+ // dataDownloadRange.js.
45
+ email: {
46
+ type: String,
47
+ trim: true,
48
+ lowercase: true,
49
+ default: null,
50
+ set: normalizeDownloadEmail,
51
+ },
52
+ },
53
+ {
54
+ timestamps: true,
55
+ discriminatorKey: "dataType",
56
+ }
57
+ );
58
+
59
+ // Cross-field rule (quiet-hours shape, CLAUDE.md): delegated to
60
+ // validateDateOrder so this schema and JA-3081's write-path validation can
61
+ // never drift. Applies to every kind — the 31-day cap is AQI-only and lives
62
+ // on AqiDataDownload's own schema below, not here. Lives in pre('validate')
63
+ // so create()/save() can't persist an invalid range even if a caller
64
+ // forgets to check.
65
+ //
66
+ // Email is validated here too (not an inline schema validator) for the same
67
+ // shared-module reason. Both hooks pass the DATA_DOWNLOAD_ERRORS code as
68
+ // invalidate()'s `kind` argument, not just the message, so a caught
69
+ // ValidationError can be classified via err.errors.<path>.kind without
70
+ // string-matching wording that's free to change.
71
+ dataDownloadSchema.pre("validate", function () {
72
+ const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
73
+ for (const error of rangeErrors) {
74
+ this.invalidate("dateRangeEnd", error.message, this.dateRangeEnd, error.code);
75
+ }
76
+
77
+ const emailErrors = validateDownloadEmail({ email: this.email });
78
+ for (const error of emailErrors) {
79
+ this.invalidate("email", error.message, this.email, error.code);
80
+ }
81
+ });
82
+
83
+ // Org-level admin screen lists a given org's downloads, most recent first,
84
+ // by the domain-meaningful "date of the download" (JA-3080), not createdAt.
85
+ dataDownloadSchema.index({ orgId: 1, downloadedAt: -1 });
86
+
87
+ const aqiDataDownloadSchema = new mongoose.Schema({
88
+ // Figma's download modal (node 4748:16300) titles the AQI variant
89
+ // "Download Data for: {Site Name}". Resolve that label via
90
+ // monitorId -> Monitors.monitorCode, NOT a Monitors -> Sites.name join:
91
+ // Monitors don't carry their own name and monitorCode is JustAirService's
92
+ // existing human-readable identifier for a monitor (already used in
93
+ // admin views and Slack alerts), whereas Monitors.siteId is not
94
+ // guaranteed set — it's only populated for monitors created through
95
+ // POST /devices's "linked device" flow, not plain POST /monitors — so a
96
+ // Sites join would render blank for a meaningful share of monitors.
97
+ // JA-3246/JA-3081 should populate this via monitorId, not siteId.
98
+ monitorId: {
99
+ type: mongoose.Types.ObjectId,
100
+ ref: "Monitors",
101
+ required: [true, "monitorId is required"],
102
+ },
103
+ });
104
+ aqiDataDownloadSchema.index({ monitorId: 1, downloadedAt: -1 });
105
+
106
+ // AQI-only: the 31-day cap does not apply to Sample downloads. Mongoose
107
+ // merges base + discriminator schema hooks by default (mergeHooks), so this
108
+ // runs alongside (not instead of) the base's validateDateOrder check above.
109
+ aqiDataDownloadSchema.pre("validate", function () {
110
+ const errors = validateMaxDateRange({ start: this.dateRangeStart, end: this.dateRangeEnd });
111
+ for (const error of errors) {
112
+ this.invalidate("dateRangeEnd", error.message, this.dateRangeEnd, error.code);
113
+ }
114
+ });
115
+
116
+ const sampleDataDownloadSchema = new mongoose.Schema({
117
+ siteId: {
118
+ type: mongoose.Types.ObjectId,
119
+ ref: "SampleSites",
120
+ required: [true, "siteId is required"],
121
+ },
122
+ });
123
+ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
124
+
125
+ const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
126
+
127
+ // Discriminator model names are namespaced (matches userNotifications.js's
128
+ // aqi_alert/device_offline/weekly_digest) — "AQI"/"Sample" as bare mongoose
129
+ // model-registry keys would collide with any consuming service's own models
130
+ // of those names. { value } keeps the persisted dataType exactly what
131
+ // JA-3245 specifies regardless of the registry name.
132
+ const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
133
+ value: "AQI",
134
+ });
135
+
136
+ const SampleDataDownload = DataDownloads.discriminator(
137
+ "SampleDataDownload",
138
+ sampleDataDownloadSchema,
139
+ { value: "Sample" }
140
+ );
141
+
142
+ export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
@@ -0,0 +1,120 @@
1
+ // Data download date range validation (JA-3080/JA-3245): shared cross-field
2
+ // rules for the requested range on a DataDownloads document. Single source
3
+ // of truth for every consumer — this library's schema enforces these on
4
+ // write, JustAirService's intake endpoint (JA-3081) validates the same
5
+ // request shape before ever constructing a document. Pure functions, no DB,
6
+ // no clock reads. Same shape as quietHours.js's cross-field validator.
7
+ //
8
+ // Two separate rules, deliberately not one function: end-must-not-precede-
9
+ // start applies to every download kind, but the 31-day cap is AQI-only —
10
+ // Sample downloads have no range limit. Compose the one(s) that apply to
11
+ // the kind being validated; don't call validateMaxDateRange for Sample.
12
+
13
+ // Display/messaging only ("range cannot exceed 31 days"). NOT the enforced
14
+ // bound — a consumer clamping a date picker with
15
+ // `MAX_RANGE_DAYS * 24 * 60 * 60 * 1000` builds a stricter cap than the one
16
+ // actually enforced (see MAX_RANGE_MS below) and would reject the exact
17
+ // DST-crossing range the grace exists to allow. Import MAX_RANGE_MS instead
18
+ // for anything that needs the real bound; validateMaxDateRange is the sole
19
+ // authority either way.
20
+ export const MAX_RANGE_DAYS = 31;
21
+
22
+ // DST slack: a calendar-accurate 31-day span crossing a fall-back transition
23
+ // is 745 (not 744) hours long. Allow it rather than rejecting a range the
24
+ // product spec (Figma) explicitly promises as valid. This, not
25
+ // MAX_RANGE_DAYS, is the number validateMaxDateRange actually enforces.
26
+ export const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
27
+
28
+ // Stable error codes for validateDateOrder/validateMaxDateRange/
29
+ // validateDownloadEmail. Consumers filter on `code` — never on message
30
+ // wording, which is free to change.
31
+ export const DATA_DOWNLOAD_ERRORS = Object.freeze({
32
+ END_BEFORE_START: "end_before_start",
33
+ RANGE_TOO_LONG: "range_too_long",
34
+ INVALID_EMAIL: "invalid_email",
35
+ });
36
+
37
+ // Coerces both ends through `new Date(...)` before comparing/subtracting: a
38
+ // JSON request body (JA-3081's intake endpoint) hands these functions raw
39
+ // strings, not Date instances, and comparing/subtracting un-coerced strings
40
+ // silently produces NaN (`NaN > x` is always false) — the caps would never
41
+ // fire for exactly the caller these functions exist to serve. An unparsable
42
+ // value coerces to an Invalid Date (NaN time); format validation is the
43
+ // schema/type layer's job, not a cross-field rule's, so that's treated the
44
+ // same as "not this validator's concern" and returns null rather than
45
+ // throwing or reporting a spurious range error.
46
+ const toTimes = (start, end) => {
47
+ if (!start || !end) {
48
+ return null;
49
+ }
50
+ const startTime = new Date(start).getTime();
51
+ const endTime = new Date(end).getTime();
52
+ if (Number.isNaN(startTime) || Number.isNaN(endTime)) {
53
+ return null;
54
+ }
55
+ return { startTime, endTime };
56
+ };
57
+
58
+ // Returns an array of { code, message } errors; empty array = valid. Applies
59
+ // to every download kind (AQI and Sample) — the Figma "31 days" cap is
60
+ // AQI-only and is NOT enforced here, see validateMaxDateRange.
61
+ export const validateDateOrder = ({ start, end } = {}) => {
62
+ const times = toTimes(start, end);
63
+ if (!times) {
64
+ return [];
65
+ }
66
+ if (times.endTime < times.startTime) {
67
+ return [
68
+ {
69
+ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START,
70
+ message: "dateRangeEnd must not be before dateRangeStart",
71
+ },
72
+ ];
73
+ }
74
+ return [];
75
+ };
76
+
77
+ // Returns an array of { code, message } errors; empty array = valid. AQI
78
+ // downloads only — Sample downloads have no range limit. Assumes the caller
79
+ // has already confirmed order via validateDateOrder (a reversed range isn't
80
+ // re-reported here as "too long").
81
+ export const validateMaxDateRange = ({ start, end } = {}) => {
82
+ const times = toTimes(start, end);
83
+ if (!times || times.endTime < times.startTime) {
84
+ return [];
85
+ }
86
+ if (times.endTime - times.startTime > MAX_RANGE_MS) {
87
+ return [
88
+ {
89
+ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG,
90
+ message: `date range cannot exceed ${MAX_RANGE_DAYS} days`,
91
+ },
92
+ ];
93
+ }
94
+ return [];
95
+ };
96
+
97
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
98
+
99
+ // Email is optional (JA-3080) — a blank form field means "not sharing my
100
+ // email", not "invalid email". Both the schema (as a Mongoose `set`) and
101
+ // JA-3081's intake endpoint (before ever constructing a document) need this
102
+ // same "blank string collapses to null" rule, so it lives here rather than
103
+ // only on the schema.
104
+ export const normalizeDownloadEmail = (value) =>
105
+ typeof value === "string" && value.trim() === "" ? null : value;
106
+
107
+ // Returns an array of { code, message } errors; empty array = valid. null/
108
+ // undefined is valid — call normalizeDownloadEmail first so a blank string
109
+ // reaches this as null instead of failing the pattern check.
110
+ export const validateDownloadEmail = ({ email } = {}) => {
111
+ if (email === null || email === undefined || EMAIL_PATTERN.test(email)) {
112
+ return [];
113
+ }
114
+ return [
115
+ {
116
+ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
117
+ message: "email must be a valid email address",
118
+ },
119
+ ];
120
+ };
@@ -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,142 @@
1
+ import {
2
+ MAX_RANGE_DAYS,
3
+ MAX_RANGE_MS,
4
+ DATA_DOWNLOAD_ERRORS,
5
+ validateDateOrder,
6
+ validateMaxDateRange,
7
+ validateDownloadEmail,
8
+ normalizeDownloadEmail,
9
+ } from "../dataDownloadRange.js";
10
+
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+ const HOUR_MS = 60 * 60 * 1000;
13
+ const start = new Date("2026-01-01T00:00:00.000Z");
14
+
15
+ // S1: MAX_RANGE_DAYS alone under-describes the enforced bound. A consumer
16
+ // needing the real cap (e.g. to clamp a date picker) should import
17
+ // MAX_RANGE_MS directly rather than recomputing MAX_RANGE_DAYS * 24h and
18
+ // silently building a stricter limit than validateMaxDateRange enforces.
19
+ test("MAX_RANGE_MS is MAX_RANGE_DAYS plus the 1-hour DST grace, not a plain day-to-ms conversion", () => {
20
+ expect(MAX_RANGE_MS).toBe(MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
21
+ });
22
+
23
+ describe("validateDateOrder (applies to every download kind)", () => {
24
+ test("returns no errors when start/end are missing", () => {
25
+ expect(validateDateOrder({})).toEqual([]);
26
+ expect(validateDateOrder({ start })).toEqual([]);
27
+ expect(validateDateOrder({ end: start })).toEqual([]);
28
+ });
29
+
30
+ test("returns no errors for a valid order", () => {
31
+ const end = new Date(start.getTime() + DAY_MS);
32
+ expect(validateDateOrder({ start, end })).toEqual([]);
33
+ });
34
+
35
+ test("flags end before start with END_BEFORE_START", () => {
36
+ const end = new Date(start.getTime() - DAY_MS);
37
+ expect(validateDateOrder({ start, end })).toEqual([
38
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START }),
39
+ ]);
40
+ });
41
+
42
+ test("does not flag a range far longer than MAX_RANGE_DAYS — that's validateMaxDateRange's job", () => {
43
+ const end = new Date(start.getTime() + 365 * DAY_MS);
44
+ expect(validateDateOrder({ start, end })).toEqual([]);
45
+ });
46
+
47
+ // JA-3081's intake endpoint hands this raw JSON request-body strings, not
48
+ // Date instances — must not silently misjudge order via NaN-producing
49
+ // string arithmetic (see dataDownloadRange.js's toTimes coercion comment).
50
+ test("flags end before start given as raw date strings", () => {
51
+ expect(
52
+ validateDateOrder({ start: "2026-05-01", end: "2026-01-01" })
53
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START })]);
54
+ });
55
+
56
+ test("returns no errors for an unparsable date value", () => {
57
+ expect(validateDateOrder({ start: "not-a-date", end: start })).toEqual([]);
58
+ });
59
+ });
60
+
61
+ describe("validateMaxDateRange (AQI only — Sample downloads have no cap)", () => {
62
+ test("returns no errors when start/end are missing", () => {
63
+ expect(validateMaxDateRange({})).toEqual([]);
64
+ });
65
+
66
+ test("accepts exactly MAX_RANGE_DAYS", () => {
67
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS);
68
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
69
+ });
70
+
71
+ // The +1h grace exists so a calendar-accurate 31-day span crossing a DST
72
+ // fall-back transition (745 real hours, not 744) still validates — see
73
+ // dataDownloadRange.js's MAX_RANGE_MS comment.
74
+ test("accepts MAX_RANGE_DAYS plus the 1-hour DST grace", () => {
75
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
76
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
77
+ });
78
+
79
+ test("flags a range longer than MAX_RANGE_DAYS plus the DST grace with RANGE_TOO_LONG", () => {
80
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS + 1);
81
+ expect(validateMaxDateRange({ start, end })).toEqual([
82
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG }),
83
+ ]);
84
+ });
85
+
86
+ test("flags a too-long range given as raw date strings (JSON request-body shape)", () => {
87
+ expect(
88
+ validateMaxDateRange({ start: "2026-01-01", end: "2027-06-01" })
89
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG })]);
90
+ });
91
+
92
+ test("does not re-report a reversed range as RANGE_TOO_LONG — validateDateOrder owns that", () => {
93
+ const end = new Date(start.getTime() - DAY_MS);
94
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
95
+ });
96
+
97
+ test("returns no errors for an unparsable date value", () => {
98
+ expect(validateMaxDateRange({ start: "not-a-date", end: start })).toEqual([]);
99
+ });
100
+ });
101
+
102
+ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () => {
103
+ test("collapses an empty or whitespace-only string to null", () => {
104
+ expect(normalizeDownloadEmail("")).toBeNull();
105
+ expect(normalizeDownloadEmail(" ")).toBeNull();
106
+ });
107
+
108
+ test("passes through a non-blank string unchanged", () => {
109
+ expect(normalizeDownloadEmail("someone@example.com")).toBe("someone@example.com");
110
+ });
111
+
112
+ test("passes through non-string values unchanged — not this function's job to type-check", () => {
113
+ expect(normalizeDownloadEmail(null)).toBeNull();
114
+ expect(normalizeDownloadEmail(undefined)).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe("validateDownloadEmail (email is optional — JA-3080)", () => {
119
+ test("returns no errors when email is null or undefined", () => {
120
+ expect(validateDownloadEmail({})).toEqual([]);
121
+ expect(validateDownloadEmail({ email: null })).toEqual([]);
122
+ expect(validateDownloadEmail({ email: undefined })).toEqual([]);
123
+ });
124
+
125
+ test("returns no errors for a well-formed email", () => {
126
+ expect(validateDownloadEmail({ email: "someone@example.com" })).toEqual([]);
127
+ });
128
+
129
+ test("flags a malformed email with INVALID_EMAIL", () => {
130
+ expect(validateDownloadEmail({ email: "not an email" })).toEqual([
131
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL }),
132
+ ]);
133
+ });
134
+
135
+ // Caller is expected to run normalizeDownloadEmail first — an un-normalized
136
+ // blank string is not the same as null and should still fail the pattern.
137
+ test("flags a blank string that hasn't been normalized to null yet", () => {
138
+ expect(validateDownloadEmail({ email: " " })).toEqual([
139
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL }),
140
+ ]);
141
+ });
142
+ });
@@ -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
+ };