@justair/justair-library 7.3.0-alpha.e85bbda → 7.3.0-alpha.f340581

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/README.md CHANGED
@@ -97,6 +97,18 @@ The following parameters were added to `HEAVY_METALS` and are now included in `s
97
97
  | `MN` | Manganese |
98
98
  | `SE` | Selenium |
99
99
 
100
+ ### New met parameters in 7.3.0
101
+
102
+ The following parameters were added to `PARAMETERS` and are now included in `parametersEnum`. Consuming services that display or filter parameters without a `type` filter (admin pickers, notification threshold UIs, reporting) will start showing them on install, even though no service writes them yet.
103
+
104
+ | id | Name | Unit |
105
+ |---|---|---|
106
+ | `T_AMB` | Ambient Temperature | `°F` |
107
+ | `RH_AMB` | Ambient Relative Humidity | `%` |
108
+ | `DP_AMB` | Ambient Dew Point | `°F` |
109
+
110
+ These are **not** interchangeable with the bare `T` / `RH` / `DP` ids, which are enclosure-internal readings used only by correction equations and fog detection. Never fall back from one kind to the other. See "Ambient vs internal met parameters" in `CLAUDE.md` before wiring these up. `INTERNAL_TO_AMBIENT_PARAMETERS` exports the pairing as data so consumers can filter on it instead of hardcoding the six ids.
111
+
100
112
  ### Object shape
101
113
 
102
114
  Each constant is an object with the following fields:
@@ -1,2 +1,2 @@
1
- export const DATA_DOWNLOAD_TYPES: string[];
1
+ export const DATA_DOWNLOAD_TYPES: readonly string[];
2
2
  //# sourceMappingURL=dataDownloads.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAeA,2CAAqD"}
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAkBA,oDAAoE"}
@@ -279,7 +279,7 @@ export namespace PARAMETERS {
279
279
  let impacts_15: any;
280
280
  export { impacts_15 as impacts };
281
281
  }
282
- namespace RH {
282
+ namespace DP_AMB {
283
283
  let id_16: string;
284
284
  export { id_16 as id };
285
285
  let label_16: string;
@@ -297,7 +297,7 @@ export namespace PARAMETERS {
297
297
  let impacts_16: any;
298
298
  export { impacts_16 as impacts };
299
299
  }
300
- namespace T {
300
+ namespace RH {
301
301
  let id_17: string;
302
302
  export { id_17 as id };
303
303
  let label_17: string;
@@ -315,7 +315,7 @@ export namespace PARAMETERS {
315
315
  let impacts_17: any;
316
316
  export { impacts_17 as impacts };
317
317
  }
318
- namespace WD {
318
+ namespace RH_AMB {
319
319
  let id_18: string;
320
320
  export { id_18 as id };
321
321
  let label_18: string;
@@ -333,7 +333,7 @@ export namespace PARAMETERS {
333
333
  let impacts_18: any;
334
334
  export { impacts_18 as impacts };
335
335
  }
336
- namespace WS {
336
+ namespace T {
337
337
  let id_19: string;
338
338
  export { id_19 as id };
339
339
  let label_19: string;
@@ -351,7 +351,7 @@ export namespace PARAMETERS {
351
351
  let impacts_19: any;
352
352
  export { impacts_19 as impacts };
353
353
  }
354
- namespace GS {
354
+ namespace T_AMB {
355
355
  let id_20: string;
356
356
  export { id_20 as id };
357
357
  let label_20: string;
@@ -369,7 +369,7 @@ export namespace PARAMETERS {
369
369
  let impacts_20: any;
370
370
  export { impacts_20 as impacts };
371
371
  }
372
- namespace R {
372
+ namespace WD {
373
373
  let id_21: string;
374
374
  export { id_21 as id };
375
375
  let label_21: string;
@@ -387,7 +387,7 @@ export namespace PARAMETERS {
387
387
  let impacts_21: any;
388
388
  export { impacts_21 as impacts };
389
389
  }
390
- namespace SR {
390
+ namespace WS {
391
391
  let id_22: string;
392
392
  export { id_22 as id };
393
393
  let label_22: string;
@@ -405,7 +405,7 @@ export namespace PARAMETERS {
405
405
  let impacts_22: any;
406
406
  export { impacts_22 as impacts };
407
407
  }
408
- namespace P {
408
+ namespace GS {
409
409
  let id_23: string;
410
410
  export { id_23 as id };
411
411
  let label_23: string;
@@ -423,7 +423,7 @@ export namespace PARAMETERS {
423
423
  let impacts_23: any;
424
424
  export { impacts_23 as impacts };
425
425
  }
426
- namespace PAR {
426
+ namespace R {
427
427
  let id_24: string;
428
428
  export { id_24 as id };
429
429
  let label_24: string;
@@ -441,7 +441,7 @@ export namespace PARAMETERS {
441
441
  let impacts_24: any;
442
442
  export { impacts_24 as impacts };
443
443
  }
444
- namespace ARM {
444
+ namespace SR {
445
445
  let id_25: string;
446
446
  export { id_25 as id };
447
447
  let label_25: string;
@@ -459,7 +459,7 @@ export namespace PARAMETERS {
459
459
  let impacts_25: any;
460
460
  export { impacts_25 as impacts };
461
461
  }
462
- namespace ARW {
462
+ namespace P {
463
463
  let id_26: string;
464
464
  export { id_26 as id };
465
465
  let label_26: string;
@@ -477,7 +477,7 @@ export namespace PARAMETERS {
477
477
  let impacts_26: any;
478
478
  export { impacts_26 as impacts };
479
479
  }
480
- namespace ARD {
480
+ namespace PAR {
481
481
  let id_27: string;
482
482
  export { id_27 as id };
483
483
  let label_27: string;
@@ -495,7 +495,7 @@ export namespace PARAMETERS {
495
495
  let impacts_27: any;
496
496
  export { impacts_27 as impacts };
497
497
  }
498
- namespace ARH {
498
+ namespace ARM {
499
499
  let id_28: string;
500
500
  export { id_28 as id };
501
501
  let label_28: string;
@@ -513,9 +513,7 @@ export namespace PARAMETERS {
513
513
  let impacts_28: any;
514
514
  export { impacts_28 as impacts };
515
515
  }
516
- }
517
- export namespace HEAVY_METALS {
518
- export namespace AS {
516
+ namespace ARW {
519
517
  let id_29: string;
520
518
  export { id_29 as id };
521
519
  let label_29: string;
@@ -526,14 +524,14 @@ export namespace HEAVY_METALS {
526
524
  export { unit_29 as unit };
527
525
  let type_29: string;
528
526
  export { type_29 as type };
529
- let rfc_29: number;
527
+ let rfc_29: any;
530
528
  export { rfc_29 as rfc };
531
- let origin_29: string;
529
+ let origin_29: any;
532
530
  export { origin_29 as origin };
533
- let impacts_29: string;
531
+ let impacts_29: any;
534
532
  export { impacts_29 as impacts };
535
533
  }
536
- export namespace BA {
534
+ namespace ARD {
537
535
  let id_30: string;
538
536
  export { id_30 as id };
539
537
  let label_30: string;
@@ -546,12 +544,12 @@ export namespace HEAVY_METALS {
546
544
  export { type_30 as type };
547
545
  let rfc_30: any;
548
546
  export { rfc_30 as rfc };
549
- let origin_30: string;
547
+ let origin_30: any;
550
548
  export { origin_30 as origin };
551
- let impacts_30: string;
549
+ let impacts_30: any;
552
550
  export { impacts_30 as impacts };
553
551
  }
554
- export namespace C6H6 {
552
+ namespace ARH {
555
553
  let id_31: string;
556
554
  export { id_31 as id };
557
555
  let label_31: string;
@@ -562,14 +560,21 @@ export namespace HEAVY_METALS {
562
560
  export { unit_31 as unit };
563
561
  let type_31: string;
564
562
  export { type_31 as type };
565
- let rfc_31: number;
563
+ let rfc_31: any;
566
564
  export { rfc_31 as rfc };
567
- let origin_31: string;
565
+ let origin_31: any;
568
566
  export { origin_31 as origin };
569
- let impacts_31: string;
567
+ let impacts_31: any;
570
568
  export { impacts_31 as impacts };
571
569
  }
572
- export namespace BE {
570
+ }
571
+ export const INTERNAL_TO_AMBIENT_PARAMETERS: Readonly<{
572
+ T: "T_AMB";
573
+ RH: "RH_AMB";
574
+ DP: "DP_AMB";
575
+ }>;
576
+ export namespace HEAVY_METALS {
577
+ export namespace AS {
573
578
  let id_32: string;
574
579
  export { id_32 as id };
575
580
  let label_32: string;
@@ -587,7 +592,7 @@ export namespace HEAVY_METALS {
587
592
  let impacts_32: string;
588
593
  export { impacts_32 as impacts };
589
594
  }
590
- export namespace CD {
595
+ export namespace BA {
591
596
  let id_33: string;
592
597
  export { id_33 as id };
593
598
  let label_33: string;
@@ -598,14 +603,14 @@ export namespace HEAVY_METALS {
598
603
  export { unit_33 as unit };
599
604
  let type_33: string;
600
605
  export { type_33 as type };
601
- let rfc_33: number;
606
+ let rfc_33: any;
602
607
  export { rfc_33 as rfc };
603
608
  let origin_33: string;
604
609
  export { origin_33 as origin };
605
610
  let impacts_33: string;
606
611
  export { impacts_33 as impacts };
607
612
  }
608
- export namespace CR {
613
+ export namespace C6H6 {
609
614
  let id_34: string;
610
615
  export { id_34 as id };
611
616
  let label_34: string;
@@ -623,7 +628,7 @@ export namespace HEAVY_METALS {
623
628
  let impacts_34: string;
624
629
  export { impacts_34 as impacts };
625
630
  }
626
- export namespace CO_1 {
631
+ export namespace BE {
627
632
  let id_35: string;
628
633
  export { id_35 as id };
629
634
  let label_35: string;
@@ -634,15 +639,14 @@ export namespace HEAVY_METALS {
634
639
  export { unit_35 as unit };
635
640
  let type_35: string;
636
641
  export { type_35 as type };
637
- let rfc_35: any;
642
+ let rfc_35: number;
638
643
  export { rfc_35 as rfc };
639
644
  let origin_35: string;
640
645
  export { origin_35 as origin };
641
646
  let impacts_35: string;
642
647
  export { impacts_35 as impacts };
643
648
  }
644
- export { CO_1 as CO };
645
- export namespace CU {
649
+ export namespace CD {
646
650
  let id_36: string;
647
651
  export { id_36 as id };
648
652
  let label_36: string;
@@ -653,14 +657,14 @@ export namespace HEAVY_METALS {
653
657
  export { unit_36 as unit };
654
658
  let type_36: string;
655
659
  export { type_36 as type };
656
- let rfc_36: any;
660
+ let rfc_36: number;
657
661
  export { rfc_36 as rfc };
658
662
  let origin_36: string;
659
663
  export { origin_36 as origin };
660
664
  let impacts_36: string;
661
665
  export { impacts_36 as impacts };
662
666
  }
663
- export namespace FE {
667
+ export namespace CR {
664
668
  let id_37: string;
665
669
  export { id_37 as id };
666
670
  let label_37: string;
@@ -671,14 +675,14 @@ export namespace HEAVY_METALS {
671
675
  export { unit_37 as unit };
672
676
  let type_37: string;
673
677
  export { type_37 as type };
674
- let rfc_37: any;
678
+ let rfc_37: number;
675
679
  export { rfc_37 as rfc };
676
680
  let origin_37: string;
677
681
  export { origin_37 as origin };
678
682
  let impacts_37: string;
679
683
  export { impacts_37 as impacts };
680
684
  }
681
- export namespace PB {
685
+ export namespace CO_1 {
682
686
  let id_38: string;
683
687
  export { id_38 as id };
684
688
  let label_38: string;
@@ -689,14 +693,15 @@ export namespace HEAVY_METALS {
689
693
  export { unit_38 as unit };
690
694
  let type_38: string;
691
695
  export { type_38 as type };
692
- let rfc_38: number;
696
+ let rfc_38: any;
693
697
  export { rfc_38 as rfc };
694
698
  let origin_38: string;
695
699
  export { origin_38 as origin };
696
700
  let impacts_38: string;
697
701
  export { impacts_38 as impacts };
698
702
  }
699
- export namespace MN {
703
+ export { CO_1 as CO };
704
+ export namespace CU {
700
705
  let id_39: string;
701
706
  export { id_39 as id };
702
707
  let label_39: string;
@@ -707,14 +712,14 @@ export namespace HEAVY_METALS {
707
712
  export { unit_39 as unit };
708
713
  let type_39: string;
709
714
  export { type_39 as type };
710
- let rfc_39: number;
715
+ let rfc_39: any;
711
716
  export { rfc_39 as rfc };
712
717
  let origin_39: string;
713
718
  export { origin_39 as origin };
714
719
  let impacts_39: string;
715
720
  export { impacts_39 as impacts };
716
721
  }
717
- export namespace NI {
722
+ export namespace FE {
718
723
  let id_40: string;
719
724
  export { id_40 as id };
720
725
  let label_40: string;
@@ -732,7 +737,7 @@ export namespace HEAVY_METALS {
732
737
  let impacts_40: string;
733
738
  export { impacts_40 as impacts };
734
739
  }
735
- export namespace SE {
740
+ export namespace PB {
736
741
  let id_41: string;
737
742
  export { id_41 as id };
738
743
  let label_41: string;
@@ -743,14 +748,14 @@ export namespace HEAVY_METALS {
743
748
  export { unit_41 as unit };
744
749
  let type_41: string;
745
750
  export { type_41 as type };
746
- let rfc_41: any;
751
+ let rfc_41: number;
747
752
  export { rfc_41 as rfc };
748
753
  let origin_41: string;
749
754
  export { origin_41 as origin };
750
755
  let impacts_41: string;
751
756
  export { impacts_41 as impacts };
752
757
  }
753
- export namespace ZN {
758
+ export namespace MN {
754
759
  let id_42: string;
755
760
  export { id_42 as id };
756
761
  let label_42: string;
@@ -761,12 +766,66 @@ export namespace HEAVY_METALS {
761
766
  export { unit_42 as unit };
762
767
  let type_42: string;
763
768
  export { type_42 as type };
764
- let rfc_42: any;
769
+ let rfc_42: number;
765
770
  export { rfc_42 as rfc };
766
771
  let origin_42: string;
767
772
  export { origin_42 as origin };
768
773
  let impacts_42: string;
769
774
  export { impacts_42 as impacts };
770
775
  }
776
+ export namespace NI {
777
+ let id_43: string;
778
+ export { id_43 as id };
779
+ let label_43: string;
780
+ export { label_43 as label };
781
+ let name_43: string;
782
+ export { name_43 as name };
783
+ let unit_43: string;
784
+ export { unit_43 as unit };
785
+ let type_43: string;
786
+ export { type_43 as type };
787
+ let rfc_43: any;
788
+ export { rfc_43 as rfc };
789
+ let origin_43: string;
790
+ export { origin_43 as origin };
791
+ let impacts_43: string;
792
+ export { impacts_43 as impacts };
793
+ }
794
+ export namespace SE {
795
+ let id_44: string;
796
+ export { id_44 as id };
797
+ let label_44: string;
798
+ export { label_44 as label };
799
+ let name_44: string;
800
+ export { name_44 as name };
801
+ let unit_44: string;
802
+ export { unit_44 as unit };
803
+ let type_44: string;
804
+ export { type_44 as type };
805
+ let rfc_44: any;
806
+ export { rfc_44 as rfc };
807
+ let origin_44: string;
808
+ export { origin_44 as origin };
809
+ let impacts_44: string;
810
+ export { impacts_44 as impacts };
811
+ }
812
+ export namespace ZN {
813
+ let id_45: string;
814
+ export { id_45 as id };
815
+ let label_45: string;
816
+ export { label_45 as label };
817
+ let name_45: string;
818
+ export { name_45 as name };
819
+ let unit_45: string;
820
+ export { unit_45 as unit };
821
+ let type_45: string;
822
+ export { type_45 as type };
823
+ let rfc_45: any;
824
+ export { rfc_45 as rfc };
825
+ let origin_45: string;
826
+ export { origin_45 as origin };
827
+ let impacts_45: string;
828
+ export { impacts_45 as impacts };
829
+ }
771
830
  }
772
831
  //# sourceMappingURL=pollutants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pollutants.d.ts","sourceRoot":"","sources":["../../src/constants/pollutants.js"],"names":[],"mappings":""}
1
+ {"version":3,"file":"pollutants.d.ts","sourceRoot":"","sources":["../../src/constants/pollutants.js"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+UA;;;;GAIG"}
@@ -1 +1 @@
1
- {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAKA,kDAAmD"}
1
+ {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAWA,kDAAmD"}
package/dist/index.d.ts CHANGED
@@ -97,8 +97,11 @@ import { validateDateOrder as validateDataDownloadDateOrder } from "./utils/data
97
97
  import { validateMaxDateRange as validateDataDownloadMaxDateRange } from "./utils/dataDownloadValidation.js";
98
98
  import { validateDownloadEmail as validateDataDownloadEmail } from "./utils/dataDownloadValidation.js";
99
99
  import { normalizeDownloadEmail as normalizeDataDownloadEmail } from "./utils/dataDownloadValidation.js";
100
+ import { validateDownloadTimezone as validateDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
101
+ import { normalizeDownloadTimezone as normalizeDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
100
102
  import { PARAMETERS } from "./constants/pollutants.js";
101
103
  import { HEAVY_METALS } from "./constants/pollutants.js";
104
+ import { INTERNAL_TO_AMBIENT_PARAMETERS } from "./constants/pollutants.js";
102
105
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
103
106
  import { getAlertWeight } from "./constants/alertLevels.js";
104
107
  import { splitName } from "./utils/splitName.js";
@@ -111,5 +114,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
111
114
  import { getZoneMinutes } from "./utils/quietHours.js";
112
115
  import { isInQuietWindow } from "./utils/quietHours.js";
113
116
  import { resolveQuietHours } from "./utils/quietHours.js";
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, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
117
+ 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, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, INTERNAL_TO_AMBIENT_PARAMETERS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
115
118
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAkJA;;;iBAEC;yBAlFwB,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;4BAmCT,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;oCAfF,8BAA8B;mCAsD3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAoB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;2BA7ED,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAkBtD,sBAAsB;oCASzC,uBAAuB;iCA1BG,0BAA0B;mCA0BpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAoJA;;;iBAEC;yBApFwB,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;4BAmCT,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;oCAfF,8BAA8B;mCAsD3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BA/E+B,2BAA2B;6BAA3B,2BAA2B;+CAA3B,2BAA2B;oCAChD,4BAA4B;+BAA5B,4BAA4B;0BAkBtD,sBAAsB;oCASzC,uBAAuB;iCA1BG,0BAA0B;mCA0BpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AAqDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAY1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBA/JmB,UAAU"}
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AA2DF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAY1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBAtKmB,UAAU"}
@@ -18,4 +18,9 @@ export function validateDownloadEmail({ email }?: {}): {
18
18
  code: "invalid_email";
19
19
  message: string;
20
20
  }[];
21
+ export function normalizeDownloadTimezone(value: any): any;
22
+ export function validateDownloadTimezone({ timezone }?: {}): {
23
+ code: "invalid_timezone";
24
+ message: string;
25
+ }[];
21
26
  //# sourceMappingURL=dataDownloadValidation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"AAUA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAgBM,wDAMN;AAIM;;;IAqBN"}
1
+ {"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"AAYA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAgBM,wDAMN;AAIM;;;IAqBN;AAOM,2DAWN;AAOM;;;IAaN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.3.0-alpha.e85bbda",
3
+ "version": "7.3.0-alpha.f340581",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -13,4 +13,7 @@
13
13
  // as a third type — add it here and register its discriminator in
14
14
  // models/dataDownloads.js, so no consumer has to hardcode the string.
15
15
 
16
- export const DATA_DOWNLOAD_TYPES = ["AQI", "Sample"];
16
+ // Frozen because every importer in a process gets this same instance — one
17
+ // consumer calling .sort() or .push() would silently reorder the picker for
18
+ // all of them. Matches DATA_DOWNLOAD_ERRORS in dataDownloadValidation.js.
19
+ export const DATA_DOWNLOAD_TYPES = Object.freeze(["AQI", "Sample"]);
@@ -151,6 +151,12 @@ export const PARAMETERS = {
151
151
  origin: null,
152
152
  impacts: null,
153
153
  },
154
+ // ---- T/RH/DP and their _AMB pairs (JA-3058). Does not apply to WD/WS below. ----
155
+ // Bare ids (T/RH/DP) are enclosure-internal, for corrections and fog detection only. The _AMB
156
+ // ids are true ambient and the only ones safe for public display or Heat Index. Never substitute
157
+ // one for the other. Caveat: five suppliers still write outdoor dew point into DP, so DP is
158
+ // exempt from the public-display filter until JA-3061 re-points them. See "Ambient vs internal
159
+ // met parameters" in CLAUDE.md before wiring up.
154
160
  DP: {
155
161
  id: "DP",
156
162
  label: "Dew Point",
@@ -161,6 +167,16 @@ export const PARAMETERS = {
161
167
  origin: null,
162
168
  impacts: null,
163
169
  },
170
+ DP_AMB: {
171
+ id: "DP_AMB",
172
+ label: "Ambient Dew Point",
173
+ name: "Ambient Dew Point (DP_AMB)",
174
+ unit: "°F",
175
+ type: "Weather",
176
+ rfc: null,
177
+ origin: null,
178
+ impacts: null,
179
+ },
164
180
  RH: {
165
181
  id: "RH",
166
182
  label: "Humidity",
@@ -171,6 +187,16 @@ export const PARAMETERS = {
171
187
  origin: null,
172
188
  impacts: null,
173
189
  },
190
+ RH_AMB: {
191
+ id: "RH_AMB",
192
+ label: "Ambient Humidity",
193
+ name: "Ambient Relative Humidity (RH_AMB)",
194
+ unit: "%",
195
+ type: "Weather",
196
+ rfc: null,
197
+ origin: null,
198
+ impacts: null,
199
+ },
174
200
  T: {
175
201
  id: "T",
176
202
  label: "Temperature",
@@ -181,6 +207,16 @@ export const PARAMETERS = {
181
207
  origin: null,
182
208
  impacts: null,
183
209
  },
210
+ T_AMB: {
211
+ id: "T_AMB",
212
+ label: "Ambient Temperature",
213
+ name: "Ambient Temperature (T_AMB)",
214
+ unit: "°F",
215
+ type: "Weather",
216
+ rfc: null,
217
+ origin: null,
218
+ impacts: null,
219
+ },
184
220
  WD: {
185
221
  id: "WD",
186
222
  label: "Wind Direction",
@@ -293,6 +329,16 @@ export const PARAMETERS = {
293
329
  },
294
330
  };
295
331
 
332
+ // Internal parameter id -> its ambient counterpart (JA-3058). Lets consumers filter on data
333
+ // instead of hardcoding the six met ids: the keys are the enclosure-internal parameters that must
334
+ // stay off public surfaces, the values are the ambient ones that are safe to show. Kept in sync
335
+ // with PARAMETERS by a test in tests/pollutants.test.js.
336
+ export const INTERNAL_TO_AMBIENT_PARAMETERS = Object.freeze({
337
+ T: "T_AMB",
338
+ RH: "RH_AMB",
339
+ DP: "DP_AMB",
340
+ });
341
+
296
342
  // Parameters analyzed from physical air samples in a laboratory.
297
343
  // These correspond to SampleSites.parameters and are not measured by real-time sensors.
298
344
  export const HEAVY_METALS = {
@@ -4,14 +4,15 @@
4
4
  // importing PARAMETERS / HEAVY_METALS through the barrel must see the exact
5
5
  // same objects as before.
6
6
  import * as barrel from '../index.js';
7
- import { PARAMETERS, HEAVY_METALS } from '../pollutants.js';
7
+ import { PARAMETERS, HEAVY_METALS, INTERNAL_TO_AMBIENT_PARAMETERS } from '../pollutants.js';
8
8
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from '../alertLevels.js';
9
9
  import { DEFAULT_TIMEZONE } from '../timezones.js';
10
10
  import { DATA_DOWNLOAD_TYPES } from '../dataDownloads.js';
11
11
 
12
- test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
12
+ test('barrel re-exports the pollutants constants unchanged (identity, not copies)', () => {
13
13
  expect(barrel.PARAMETERS).toBe(PARAMETERS);
14
14
  expect(barrel.HEAVY_METALS).toBe(HEAVY_METALS);
15
+ expect(barrel.INTERNAL_TO_AMBIENT_PARAMETERS).toBe(INTERNAL_TO_AMBIENT_PARAMETERS);
15
16
  });
16
17
 
17
18
  test('barrel exports the alert-level constants', () => {
@@ -1,4 +1,4 @@
1
- import { PARAMETERS, HEAVY_METALS } from "../pollutants.js";
1
+ import { PARAMETERS, HEAVY_METALS, INTERNAL_TO_AMBIENT_PARAMETERS } from "../pollutants.js";
2
2
 
3
3
  const REQUIRED_FIELDS = ["id", "label", "name", "unit", "type"];
4
4
  const VALID_TYPES = [
@@ -69,6 +69,28 @@ function sharedAssertions(constants) {
69
69
 
70
70
  describe("PARAMETERS", () => {
71
71
  sharedAssertions(PARAMETERS);
72
+
73
+ // JA-3060: Processing-Engine hardcodes these ids, so lock the ambient/internal pairing.
74
+ const ambientKeys = Object.keys(PARAMETERS).filter((key) => key.endsWith("_AMB"));
75
+
76
+ test("the _AMB parameter set is exactly T, RH and DP", () => {
77
+ expect(ambientKeys.sort()).toEqual(["DP_AMB", "RH_AMB", "T_AMB"]);
78
+ });
79
+
80
+ test("INTERNAL_TO_AMBIENT_PARAMETERS matches the _AMB ids actually defined", () => {
81
+ expect(Object.values(INTERNAL_TO_AMBIENT_PARAMETERS).sort()).toEqual(ambientKeys.sort());
82
+ Object.entries(INTERNAL_TO_AMBIENT_PARAMETERS).forEach(([bare, ambient]) => {
83
+ expect(PARAMETERS[bare]).toBeDefined();
84
+ expect(ambient).toBe(`${bare}_AMB`);
85
+ });
86
+ });
87
+
88
+ test.each(ambientKeys)("%s pairs with its bare parameter on unit and type", (key) => {
89
+ const bare = PARAMETERS[key.replace(/_AMB$/, "")];
90
+ expect(bare).toBeDefined();
91
+ expect(PARAMETERS[key].unit).toBe(bare.unit);
92
+ expect(PARAMETERS[key].type).toBe(bare.type);
93
+ });
72
94
  });
73
95
 
74
96
  describe("HEAVY_METALS", () => {
@@ -3,4 +3,10 @@
3
3
  // Originally lived in utils/quietHours.js (JA-3054); moved here once a
4
4
  // second, unrelated domain (dataDownloads.js, JA-3080) needed it too.
5
5
 
6
+ // Deliberately no DOWNLOAD_TIMEZONES / TIMEZONE_OPTIONS list here. The
7
+ // download modal's "Set Timezone" dropdown offers display labels ("Eastern
8
+ // Standard Time (EST)") over IANA values, and labels are a view concern:
9
+ // this library has no i18n and no view layer, so a label map would drift from
10
+ // design the first time the copy changed. The validator accepts any zone Intl
11
+ // accepts (utils/timezone.js), so the consumer owns which subset it offers.
6
12
  export const DEFAULT_TIMEZONE = "America/New_York";
package/src/index.js CHANGED
@@ -65,7 +65,7 @@ import {
65
65
  } from "./models/changeStreamCheckpoints.js";
66
66
  import Database from "./config/db.js"; // Import the new Database class
67
67
  import CustomLogger from "./config/logger.js";
68
- import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
68
+ import { PARAMETERS, HEAVY_METALS, INTERNAL_TO_AMBIENT_PARAMETERS } from "./constants/pollutants.js";
69
69
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
70
70
  import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
71
71
  import { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
@@ -142,6 +142,8 @@ import {
142
142
  validateMaxDateRange as validateDataDownloadMaxDateRange,
143
143
  validateDownloadEmail as validateDataDownloadEmail,
144
144
  normalizeDownloadEmail as normalizeDataDownloadEmail,
145
+ validateDownloadTimezone as validateDataDownloadTimezone,
146
+ normalizeDownloadTimezone as normalizeDataDownloadTimezone,
145
147
  } from "./utils/dataDownloadValidation.js";
146
148
 
147
149
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
@@ -249,9 +251,12 @@ export {
249
251
  validateDataDownloadMaxDateRange,
250
252
  validateDataDownloadEmail,
251
253
  normalizeDataDownloadEmail,
254
+ validateDataDownloadTimezone,
255
+ normalizeDataDownloadTimezone,
252
256
  // Pollutant constants
253
257
  PARAMETERS,
254
258
  HEAVY_METALS,
259
+ INTERNAL_TO_AMBIENT_PARAMETERS,
255
260
  // AQI alert-level hierarchy (single source of truth)
256
261
  ALERT_LEVEL_WEIGHTS,
257
262
  getAlertWeight,
@@ -1,13 +1,14 @@
1
1
  import mongoose from "mongoose";
2
2
  import { DATA_DOWNLOAD_TYPES } from "../constants/dataDownloads.js";
3
3
  import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
4
- import { isValidIANATimezone } from "../utils/timezone.js";
5
4
  import {
6
5
  DATA_DOWNLOAD_ERRORS,
7
6
  validateDateOrder,
8
7
  validateMaxDateRange,
9
8
  validateDownloadEmail,
10
9
  normalizeDownloadEmail,
10
+ validateDownloadTimezone,
11
+ normalizeDownloadTimezone,
11
12
  } from "../utils/dataDownloadValidation.js";
12
13
 
13
14
  // DataDownloads — one doc per data download request (JA-3080). Written by
@@ -30,10 +31,10 @@ const dataDownloadSchema = new mongoose.Schema(
30
31
  },
31
32
  dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
32
33
  dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
33
- // `default` only applies to `undefined`, not `null`, so an explicit
34
- // `timezone: null` (the normal shape of "didn't pick one" in a JSON body)
35
- // is coerced in a setter — setters run on query updates, the pre('validate')
36
- // hook below does not.
34
+ // `default` only applies to `undefined`, not `null` or "", so both of the
35
+ // other "didn't pick one" shapes (a JSON body's null, an untouched HTML
36
+ // <select>'s empty string) are coerced in a setter — setters run on query
37
+ // updates, the pre('validate') hook below does not.
37
38
  //
38
39
  // This guarantees less than "always valid": `runValidators` defaults to
39
40
  // false on query updates, so an updateOne can still persist a bad zone.
@@ -42,9 +43,9 @@ const dataDownloadSchema = new mongoose.Schema(
42
43
  type: String,
43
44
  trim: true,
44
45
  default: DEFAULT_TIMEZONE,
45
- set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
46
+ set: normalizeDownloadTimezone,
46
47
  validate: {
47
- validator: isValidIANATimezone,
48
+ validator: (value) => validateDownloadTimezone({ timezone: value }).length === 0,
48
49
  message: "timezone must be a valid IANA timezone identifier",
49
50
  type: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
50
51
  },
@@ -93,6 +94,12 @@ const dataDownloadSchema = new mongoose.Schema(
93
94
  //
94
95
  // Passes the error code as invalidate()'s `kind` so a caught ValidationError
95
96
  // can be classified via err.errors.<path>.kind, not by message wording.
97
+ //
98
+ // Same caveat the `timezone` field carries: pre('validate') never runs on a
99
+ // query update, so a `$set` of dateRangeStart/dateRangeEnd can persist a
100
+ // reversed or over-length range even with { runValidators: true }. Deliberate
101
+ // — the rules are cross-field, and query updates have no full document to
102
+ // check. Writers should go through create()/save(); readers must still defend.
96
103
  dataDownloadSchema.pre("validate", function () {
97
104
  const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
98
105
  for (const error of rangeErrors) {
@@ -1,10 +1,12 @@
1
1
  // Data download validation (JA-3080/JA-3245): the shared field and cross-field
2
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.
3
+ // cap, the optional email, and the timezone. Pure functions, no DB, no clock
4
+ // reads. Same shape as quietHours.js's cross-field validator.
5
5
  //
6
6
  // Two separate rules, deliberately not one function: end-must-not-precede-
7
7
  // start applies to every download kind, but the 31-day cap is AQI-only.
8
+ import { isValidIANATimezone } from "./timezone.js";
9
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
8
10
 
9
11
  // Messaging only ("range cannot exceed 31 days"). validateMaxDateRange is the
10
12
  // sole authority on the enforced bound; don't rebuild the cap from this.
@@ -122,3 +124,41 @@ export const validateDownloadEmail = ({ email } = {}) => {
122
124
  },
123
125
  ];
124
126
  };
127
+
128
+ // Every shape of "didn't pick one" resolves to the default, mirroring
129
+ // normalizeDownloadEmail's blank-collapses-to-null contract. An untouched HTML
130
+ // <select> posts "" and a JSON body usually carries null, so treating only one
131
+ // of them as unset rejects a download for a field the user never touched.
132
+ // `undefined` passes through so the schema's own `default` still applies.
133
+ export const normalizeDownloadTimezone = (value) => {
134
+ if (value === undefined) {
135
+ return value;
136
+ }
137
+ if (value === null) {
138
+ return DEFAULT_TIMEZONE;
139
+ }
140
+ if (typeof value === "string" && value.trim() === "") {
141
+ return DEFAULT_TIMEZONE;
142
+ }
143
+ return value;
144
+ };
145
+
146
+ // Returns { code, message }[]; empty = valid. Anything normalizeDownloadTimezone
147
+ // resolves to the default is valid here too, so a consumer pre-validating a
148
+ // request body accepts exactly what the model stores. isValidIANATimezone alone
149
+ // rejects null and "", which would fail a request the model saves happily —
150
+ // the write-path/read-path split the email rules exist to avoid.
151
+ export const validateDownloadTimezone = ({ timezone } = {}) => {
152
+ if (normalizeDownloadTimezone(timezone) !== timezone) {
153
+ return [];
154
+ }
155
+ if (timezone === undefined || isValidIANATimezone(timezone)) {
156
+ return [];
157
+ }
158
+ return [
159
+ {
160
+ code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
161
+ message: "timezone must be a valid IANA timezone identifier",
162
+ },
163
+ ];
164
+ };
@@ -4,8 +4,11 @@ import {
4
4
  validateDateOrder,
5
5
  validateMaxDateRange,
6
6
  validateDownloadEmail,
7
+ validateDownloadTimezone,
8
+ normalizeDownloadTimezone,
7
9
  normalizeDownloadEmail,
8
10
  } from "../dataDownloadValidation.js";
11
+ import { DEFAULT_TIMEZONE } from "../../constants/timezones.js";
9
12
 
10
13
  const DAY_MS = 24 * 60 * 60 * 1000;
11
14
  const HOUR_MS = 60 * 60 * 1000;
@@ -155,3 +158,56 @@ describe("validateDownloadEmail (email is optional — JA-3080)", () => {
155
158
  ]);
156
159
  });
157
160
  });
161
+
162
+ describe("validateDownloadTimezone", () => {
163
+ test.each(["America/New_York", "America/Detroit", "UTC", "Asia/Tokyo"])(
164
+ "accepts %s",
165
+ (timezone) => {
166
+ expect(validateDownloadTimezone({ timezone })).toEqual([]);
167
+ }
168
+ );
169
+
170
+ // The schema setter resolves both to DEFAULT_TIMEZONE, so a consumer
171
+ // pre-validating a request body must accept what the model stores.
172
+ // An untouched <select> posts "", a JSON body usually carries null. Both mean
173
+ // "didn't pick one", so both must validate the way the setter resolves them.
174
+ test.each([null, undefined, "", " "])(
175
+ "treats %p as valid (the setter fills the default)",
176
+ (timezone) => {
177
+ expect(validateDownloadTimezone({ timezone })).toEqual([]);
178
+ }
179
+ );
180
+
181
+ test("accepts a call with no argument at all", () => {
182
+ expect(validateDownloadTimezone()).toEqual([]);
183
+ });
184
+
185
+ test.each(["America/Fake_City", "Not/A_Zone", 5, {}])(
186
+ "flags %p with INVALID_TIMEZONE",
187
+ (timezone) => {
188
+ expect(validateDownloadTimezone({ timezone })).toEqual([
189
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE }),
190
+ ]);
191
+ }
192
+ );
193
+ });
194
+
195
+ describe("normalizeDownloadTimezone", () => {
196
+ test.each([null, "", " "])("resolves %p to the default", (value) => {
197
+ expect(normalizeDownloadTimezone(value)).toBe(DEFAULT_TIMEZONE);
198
+ });
199
+
200
+ // Passes through so the schema's own `default` still applies.
201
+ test("leaves undefined alone", () => {
202
+ expect(normalizeDownloadTimezone(undefined)).toBeUndefined();
203
+ });
204
+
205
+ test("leaves a real zone untouched", () => {
206
+ expect(normalizeDownloadTimezone("Asia/Tokyo")).toBe("Asia/Tokyo");
207
+ });
208
+
209
+ // Validation, not normalization, is what rejects these.
210
+ test.each(["Not/A_Zone", 5])("leaves invalid input %p for the validator", (value) => {
211
+ expect(normalizeDownloadTimezone(value)).toBe(value);
212
+ });
213
+ });