@happyvertical/smrt-analytics 0.32.0 → 0.32.1

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.
@@ -3,6 +3,66 @@ import { AnalyticsEvent } from '../models/AnalyticsEvent.js';
3
3
  import { PropertyStatsWithTrend, TrackingEventStatus } from '../types/index.js';
4
4
  export declare class AnalyticsEventCollection extends SmrtCollection<AnalyticsEvent> {
5
5
  static readonly _itemClass: typeof AnalyticsEvent;
6
+ /**
7
+ * Offset of `timeZone` at `instant`, in milliseconds (wall-clock minus UTC).
8
+ *
9
+ * Reads the zone's wall-clock Y/M/D h:m:s for `instant` via
10
+ * `Intl.DateTimeFormat` parts and subtracts the real UTC instant. Positive
11
+ * east of UTC, negative west (e.g. `America/Los_Angeles` returns roughly
12
+ * `-7h`/`-8h` depending on DST).
13
+ *
14
+ * @throws RangeError if `timeZone` is not a valid IANA identifier.
15
+ */
16
+ private zoneOffsetMs;
17
+ /**
18
+ * Resolve the UTC instant marking the start of the calendar day (00:00) that
19
+ * `instant` falls on **within the given IANA time zone**.
20
+ *
21
+ * Day-over-day buckets ("today vs yesterday") must respect the property's
22
+ * configured `timeZone` (defaults to `America/Los_Angeles`), otherwise a
23
+ * pageview at 11:30pm local time — already the next UTC day — is bucketed
24
+ * into the wrong day. We read the wall-clock civil date for the zone, then
25
+ * map that date's local midnight back to a UTC instant, correcting for the
26
+ * zone offset (and re-correcting once across a DST boundary).
27
+ *
28
+ * Invalid/unknown zone identifiers fall back to UTC day boundaries (matching
29
+ * the previous behaviour) rather than throwing.
30
+ *
31
+ * @param instant - Reference instant.
32
+ * @param timeZone - IANA time zone (e.g. `America/Los_Angeles`).
33
+ * @returns UTC `Date` for local midnight of the day `instant` is in.
34
+ */
35
+ protected startOfDayInZone(instant: Date, timeZone: string): Date;
36
+ /**
37
+ * Resolve the UTC instant for the start of the day *before* `todayStart`'s
38
+ * local day, in `timeZone`.
39
+ *
40
+ * Steps back 12h from local midnight (landing safely inside the previous
41
+ * civil day regardless of DST — a naive `- 24h` skips a day across
42
+ * spring-forward), then re-resolves start-of-day.
43
+ *
44
+ * @param todayStart - Local-midnight UTC instant from {@link startOfDayInZone}.
45
+ * @param timeZone - IANA time zone.
46
+ * @returns UTC `Date` for local midnight of the prior calendar day.
47
+ */
48
+ protected startOfYesterdayInZone(todayStart: Date, timeZone: string): Date;
49
+ /**
50
+ * Classify a day-over-day change into a trend direction + percent.
51
+ *
52
+ * - `yesterday > 0`: percent = rounded delta; >5% up, <-5% down, else flat.
53
+ * - `yesterday === 0 && today > 0`: a brand-new surge from a zero baseline —
54
+ * classified `up` with a `null` percent (no finite percentage exists), so
55
+ * the UI renders "new" rather than a misleading flat 0%.
56
+ * - `yesterday === 0 && today === 0`: flat, 0%.
57
+ *
58
+ * @param today - Today's count.
59
+ * @param yesterday - Yesterday's count.
60
+ * @returns Trend direction and percent (null when growing from zero).
61
+ */
62
+ protected classifyTrend(today: number, yesterday: number): {
63
+ trend: 'up' | 'down' | 'flat';
64
+ trendPercent: number | null;
65
+ };
6
66
  /**
7
67
  * Find events by property
8
68
  *
@@ -112,20 +172,33 @@ export declare class AnalyticsEventCollection extends SmrtCollection<AnalyticsEv
112
172
  *
113
173
  * Compares today's pageview count against yesterday's to produce a
114
174
  * trend direction and percentage change. A threshold of 5% is used
115
- * to classify 'up' vs 'down' vs 'flat'.
175
+ * to classify 'up' vs 'down' vs 'flat'; growth from a zero baseline is
176
+ * classified `up` with a `null` percent (see {@link classifyTrend}).
177
+ *
178
+ * Day boundaries are computed in `timeZone` (an IANA identifier such as the
179
+ * property's `AnalyticsProperty.timeZone`, which defaults to
180
+ * `America/Los_Angeles`) so an event near local midnight buckets into the
181
+ * correct calendar day. Defaults to `'UTC'` when omitted.
116
182
  *
117
183
  * @param propertyId - Property ID
118
184
  * @param now - Optional current date (for testing)
185
+ * @param timeZone - IANA time zone for day boundaries (default `'UTC'`)
119
186
  * @returns Stats with trend
120
187
  */
121
- getPropertyStatsWithTrend(propertyId: string, now?: Date): Promise<PropertyStatsWithTrend>;
188
+ getPropertyStatsWithTrend(propertyId: string, now?: Date, timeZone?: string): Promise<PropertyStatsWithTrend>;
122
189
  /**
123
190
  * Get day-over-day stats for multiple properties in batch.
124
191
  *
192
+ * Day boundaries are computed in `timeZone` (default `'UTC'`); see
193
+ * {@link getPropertyStatsWithTrend}. A single zone applies to the whole
194
+ * batch, so callers mixing properties with different `timeZone` values
195
+ * should batch per zone (or fall back to per-property calls).
196
+ *
125
197
  * @param propertyIds - Array of property IDs
126
198
  * @param now - Optional current date (for testing)
199
+ * @param timeZone - IANA time zone for day boundaries (default `'UTC'`)
127
200
  * @returns Map of propertyId to stats
128
201
  */
129
- getBatchPropertyStats(propertyIds: string[], now?: Date): Promise<Map<string, PropertyStatsWithTrend>>;
202
+ getBatchPropertyStats(propertyIds: string[], now?: Date, timeZone?: string): Promise<Map<string, PropertyStatsWithTrend>>;
130
203
  }
131
204
  //# sourceMappingURL=AnalyticsEventCollection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AnalyticsEventCollection.d.ts","sourceRoot":"","sources":["../../src/collections/AnalyticsEventCollection.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EACL,KAAK,sBAAsB,EAC3B,mBAAmB,EACpB,MAAM,mBAAmB,CAAC;AAE3B,qBAAa,wBAAyB,SAAQ,cAAc,CAAC,cAAc,CAAC;IAC1E,MAAM,CAAC,QAAQ,CAAC,UAAU,wBAAkB;IAE5C;;;;;OAKG;IACG,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAOnE;;;;;OAKG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAOnE;;;;;OAKG;IACG,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAOjE;;;;;OAKG;IACG,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAO7D;;;;;OAKG;IACG,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAO1E;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI9C;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI3C;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI7C;;;;;OAKG;IACG,YAAY,CAAC,UAAU,GAAE,MAAU,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAKrE;;;;;OAKG;IACG,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAU1E;;;;;;OAMG;IACG,eAAe,CACnB,SAAS,EAAE,IAAI,EACf,OAAO,EAAE,IAAI,GACZ,OAAO,CAAC,cAAc,EAAE,CAAC;IAU5B;;;;;OAKG;IACG,eAAe,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAcrE;;;;;OAKG;IACG,aAAa,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAWnE;;;;;OAKG;IACG,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAYxE;;;;;OAKG;IACG,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAClD,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IAeF;;;;;;;;;;OAUG;IACG,yBAAyB,CAC7B,UAAU,EAAE,MAAM,EAClB,GAAG,CAAC,EAAE,IAAI,GACT,OAAO,CAAC,sBAAsB,CAAC;IA2DlC;;;;;;OAMG;IACG,qBAAqB,CACzB,WAAW,EAAE,MAAM,EAAE,EACrB,GAAG,CAAC,EAAE,IAAI,GACT,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;CAqEhD"}
1
+ {"version":3,"file":"AnalyticsEventCollection.d.ts","sourceRoot":"","sources":["../../src/collections/AnalyticsEventCollection.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -556,6 +556,134 @@ AnalyticsEvent = __decorateClass$2([
556
556
  ], AnalyticsEvent);
557
557
  class AnalyticsEventCollection extends SmrtCollection {
558
558
  static _itemClass = AnalyticsEvent;
559
+ /**
560
+ * Offset of `timeZone` at `instant`, in milliseconds (wall-clock minus UTC).
561
+ *
562
+ * Reads the zone's wall-clock Y/M/D h:m:s for `instant` via
563
+ * `Intl.DateTimeFormat` parts and subtracts the real UTC instant. Positive
564
+ * east of UTC, negative west (e.g. `America/Los_Angeles` returns roughly
565
+ * `-7h`/`-8h` depending on DST).
566
+ *
567
+ * @throws RangeError if `timeZone` is not a valid IANA identifier.
568
+ */
569
+ zoneOffsetMs(instant, timeZone) {
570
+ const parts = new Intl.DateTimeFormat("en-US", {
571
+ timeZone,
572
+ year: "numeric",
573
+ month: "2-digit",
574
+ day: "2-digit",
575
+ hour: "2-digit",
576
+ minute: "2-digit",
577
+ second: "2-digit",
578
+ hour12: false
579
+ }).formatToParts(instant);
580
+ const lookup = (type) => Number.parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
581
+ let hour = lookup("hour");
582
+ if (hour === 24) hour = 0;
583
+ const asUtc = Date.UTC(
584
+ lookup("year"),
585
+ lookup("month") - 1,
586
+ lookup("day"),
587
+ hour,
588
+ lookup("minute"),
589
+ lookup("second")
590
+ );
591
+ return asUtc - instant.getTime();
592
+ }
593
+ /**
594
+ * Resolve the UTC instant marking the start of the calendar day (00:00) that
595
+ * `instant` falls on **within the given IANA time zone**.
596
+ *
597
+ * Day-over-day buckets ("today vs yesterday") must respect the property's
598
+ * configured `timeZone` (defaults to `America/Los_Angeles`), otherwise a
599
+ * pageview at 11:30pm local time — already the next UTC day — is bucketed
600
+ * into the wrong day. We read the wall-clock civil date for the zone, then
601
+ * map that date's local midnight back to a UTC instant, correcting for the
602
+ * zone offset (and re-correcting once across a DST boundary).
603
+ *
604
+ * Invalid/unknown zone identifiers fall back to UTC day boundaries (matching
605
+ * the previous behaviour) rather than throwing.
606
+ *
607
+ * @param instant - Reference instant.
608
+ * @param timeZone - IANA time zone (e.g. `America/Los_Angeles`).
609
+ * @returns UTC `Date` for local midnight of the day `instant` is in.
610
+ */
611
+ startOfDayInZone(instant, timeZone) {
612
+ let civil;
613
+ try {
614
+ const parts = new Intl.DateTimeFormat("en-US", {
615
+ timeZone,
616
+ year: "numeric",
617
+ month: "2-digit",
618
+ day: "2-digit"
619
+ }).formatToParts(instant);
620
+ const lookup = (type) => Number.parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
621
+ civil = {
622
+ year: lookup("year"),
623
+ month: lookup("month"),
624
+ day: lookup("day")
625
+ };
626
+ } catch {
627
+ return new Date(
628
+ Date.UTC(
629
+ instant.getUTCFullYear(),
630
+ instant.getUTCMonth(),
631
+ instant.getUTCDate()
632
+ )
633
+ );
634
+ }
635
+ const guess = Date.UTC(civil.year, civil.month - 1, civil.day);
636
+ const offset = this.zoneOffsetMs(new Date(guess), timeZone);
637
+ let utc = guess - offset;
638
+ const offset2 = this.zoneOffsetMs(new Date(utc), timeZone);
639
+ if (offset2 !== offset) utc = guess - offset2;
640
+ return new Date(utc);
641
+ }
642
+ /**
643
+ * Resolve the UTC instant for the start of the day *before* `todayStart`'s
644
+ * local day, in `timeZone`.
645
+ *
646
+ * Steps back 12h from local midnight (landing safely inside the previous
647
+ * civil day regardless of DST — a naive `- 24h` skips a day across
648
+ * spring-forward), then re-resolves start-of-day.
649
+ *
650
+ * @param todayStart - Local-midnight UTC instant from {@link startOfDayInZone}.
651
+ * @param timeZone - IANA time zone.
652
+ * @returns UTC `Date` for local midnight of the prior calendar day.
653
+ */
654
+ startOfYesterdayInZone(todayStart, timeZone) {
655
+ return this.startOfDayInZone(
656
+ new Date(todayStart.getTime() - 12 * 36e5),
657
+ timeZone
658
+ );
659
+ }
660
+ /**
661
+ * Classify a day-over-day change into a trend direction + percent.
662
+ *
663
+ * - `yesterday > 0`: percent = rounded delta; >5% up, <-5% down, else flat.
664
+ * - `yesterday === 0 && today > 0`: a brand-new surge from a zero baseline —
665
+ * classified `up` with a `null` percent (no finite percentage exists), so
666
+ * the UI renders "new" rather than a misleading flat 0%.
667
+ * - `yesterday === 0 && today === 0`: flat, 0%.
668
+ *
669
+ * @param today - Today's count.
670
+ * @param yesterday - Yesterday's count.
671
+ * @returns Trend direction and percent (null when growing from zero).
672
+ */
673
+ classifyTrend(today, yesterday) {
674
+ if (yesterday > 0) {
675
+ const change = (today - yesterday) / yesterday * 100;
676
+ const trendPercent = Math.round(change);
677
+ let trend = "flat";
678
+ if (change > 5) trend = "up";
679
+ else if (change < -5) trend = "down";
680
+ return { trend, trendPercent };
681
+ }
682
+ if (today > 0) {
683
+ return { trend: "up", trendPercent: null };
684
+ }
685
+ return { trend: "flat", trendPercent: 0 };
686
+ }
559
687
  /**
560
688
  * Find events by property
561
689
  *
@@ -745,22 +873,23 @@ class AnalyticsEventCollection extends SmrtCollection {
745
873
  *
746
874
  * Compares today's pageview count against yesterday's to produce a
747
875
  * trend direction and percentage change. A threshold of 5% is used
748
- * to classify 'up' vs 'down' vs 'flat'.
876
+ * to classify 'up' vs 'down' vs 'flat'; growth from a zero baseline is
877
+ * classified `up` with a `null` percent (see {@link classifyTrend}).
878
+ *
879
+ * Day boundaries are computed in `timeZone` (an IANA identifier such as the
880
+ * property's `AnalyticsProperty.timeZone`, which defaults to
881
+ * `America/Los_Angeles`) so an event near local midnight buckets into the
882
+ * correct calendar day. Defaults to `'UTC'` when omitted.
749
883
  *
750
884
  * @param propertyId - Property ID
751
885
  * @param now - Optional current date (for testing)
886
+ * @param timeZone - IANA time zone for day boundaries (default `'UTC'`)
752
887
  * @returns Stats with trend
753
888
  */
754
- async getPropertyStatsWithTrend(propertyId, now) {
889
+ async getPropertyStatsWithTrend(propertyId, now, timeZone = "UTC") {
755
890
  const currentTime = now || /* @__PURE__ */ new Date();
756
- const todayStart = new Date(
757
- Date.UTC(
758
- currentTime.getUTCFullYear(),
759
- currentTime.getUTCMonth(),
760
- currentTime.getUTCDate()
761
- )
762
- );
763
- const yesterdayStart = new Date(todayStart.getTime() - 864e5);
891
+ const todayStart = this.startOfDayInZone(currentTime, timeZone);
892
+ const yesterdayStart = this.startOfYesterdayInZone(todayStart, timeZone);
764
893
  const allPageviewEvents = await this.list({
765
894
  where: {
766
895
  propertyId,
@@ -781,14 +910,10 @@ class AnalyticsEventCollection extends SmrtCollection {
781
910
  );
782
911
  const todayPageviews = todayPageviewEvents.length;
783
912
  const yesterdayPageviews = yesterdayPageviewEvents.length;
784
- let trend = "flat";
785
- let trendPercent = 0;
786
- if (yesterdayPageviews > 0) {
787
- const change = (todayPageviews - yesterdayPageviews) / yesterdayPageviews * 100;
788
- trendPercent = Math.round(change);
789
- if (change > 5) trend = "up";
790
- else if (change < -5) trend = "down";
791
- }
913
+ const { trend, trendPercent } = this.classifyTrend(
914
+ todayPageviews,
915
+ yesterdayPageviews
916
+ );
792
917
  return {
793
918
  todayPageviews,
794
919
  todayUsers: todayClients.size,
@@ -801,21 +926,21 @@ class AnalyticsEventCollection extends SmrtCollection {
801
926
  /**
802
927
  * Get day-over-day stats for multiple properties in batch.
803
928
  *
929
+ * Day boundaries are computed in `timeZone` (default `'UTC'`); see
930
+ * {@link getPropertyStatsWithTrend}. A single zone applies to the whole
931
+ * batch, so callers mixing properties with different `timeZone` values
932
+ * should batch per zone (or fall back to per-property calls).
933
+ *
804
934
  * @param propertyIds - Array of property IDs
805
935
  * @param now - Optional current date (for testing)
936
+ * @param timeZone - IANA time zone for day boundaries (default `'UTC'`)
806
937
  * @returns Map of propertyId to stats
807
938
  */
808
- async getBatchPropertyStats(propertyIds, now) {
939
+ async getBatchPropertyStats(propertyIds, now, timeZone = "UTC") {
809
940
  const results = /* @__PURE__ */ new Map();
810
941
  const currentTime = now || /* @__PURE__ */ new Date();
811
- const todayStart = new Date(
812
- Date.UTC(
813
- currentTime.getUTCFullYear(),
814
- currentTime.getUTCMonth(),
815
- currentTime.getUTCDate()
816
- )
817
- );
818
- const yesterdayStart = new Date(todayStart.getTime() - 864e5);
942
+ const todayStart = this.startOfDayInZone(currentTime, timeZone);
943
+ const yesterdayStart = this.startOfYesterdayInZone(todayStart, timeZone);
819
944
  const allEvents = await this.list({
820
945
  where: {
821
946
  eventName: "page_view",
@@ -841,14 +966,10 @@ class AnalyticsEventCollection extends SmrtCollection {
841
966
  );
842
967
  const todayPageviews = todayPageviewEvents.length;
843
968
  const yesterdayPageviews = yesterdayPageviewEvents.length;
844
- let trend = "flat";
845
- let trendPercent = 0;
846
- if (yesterdayPageviews > 0) {
847
- const change = (todayPageviews - yesterdayPageviews) / yesterdayPageviews * 100;
848
- trendPercent = Math.round(change);
849
- if (change > 5) trend = "up";
850
- else if (change < -5) trend = "down";
851
- }
969
+ const { trend, trendPercent } = this.classifyTrend(
970
+ todayPageviews,
971
+ yesterdayPageviews
972
+ );
852
973
  results.set(propertyId, {
853
974
  todayPageviews,
854
975
  todayUsers: todayClients.size,