ghost 6.0.0-rc.1 → 6.0.0-rc.3

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.
Files changed (24) hide show
  1. package/README.md +3 -3
  2. package/components/tryghost-i18n-6.0.0-rc.3.tgz +0 -0
  3. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
  4. package/core/built/admin/assets/admin-x-activitypub/{index-D0b_o57K.mjs → index-BV-9xDf-.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-activitypub/{index-C3jWt_0g.mjs → index-D1M-ytsZ.mjs} +949 -910
  6. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-63sCJxA8.mjs → CodeEditorView-Dk8bRHNh.mjs} +2 -2
  7. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  8. package/core/built/admin/assets/admin-x-settings/{index-CL8liuyk.mjs → index-B_Owqg5X.mjs} +12 -9
  9. package/core/built/admin/assets/admin-x-settings/{index-DXFGYHE_.mjs → index-Uj24wdZL.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{modals-DJg5yTqf.mjs → modals-B21zmw-Y.mjs} +7697 -7705
  11. package/core/built/admin/assets/{chunk.524.f75344acd3938cf05152.js → chunk.524.124c2d3b27c954a9aaa6.js} +7 -7
  12. package/core/built/admin/assets/{chunk.582.4775488f7f26ad2a7d9c.js → chunk.582.a5dde33c7ca18e9ed7d9.js} +9 -9
  13. package/core/built/admin/assets/{ghost-11843760824cb811ee4a35896c817a34.js → ghost-338989d8cb5fce4a84d53ddd39ac80d4.js} +7 -7
  14. package/core/built/admin/assets/posts/posts.js +971 -964
  15. package/core/built/admin/assets/stats/stats.js +326 -319
  16. package/core/built/admin/index.html +3 -3
  17. package/core/server/api/endpoints/stats.js +1 -6
  18. package/core/server/services/stats/PostsStatsService.js +48 -105
  19. package/core/server/services/stats/ReferrersStatsService.js +122 -78
  20. package/core/server/services/stats/StatsService.js +13 -2
  21. package/core/server/services/stats/utils/date-utils.js +37 -0
  22. package/package.json +3 -3
  23. package/PRIVACY.md +0 -47
  24. package/components/tryghost-i18n-6.0.0-rc.1.tgz +0 -0
@@ -6,7 +6,7 @@
6
6
  <title>Ghost</title>
7
7
 
8
8
 
9
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.0%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%22e84a455171%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%222cc57d68cb%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%2235c19cc8c2%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%22a6f5cdc099%22%2C%22adminXActivitypubCustomUrl%22%3A%22https%3A%2F%2Fcdn.jsdelivr.net%2Fghost%2Fadmin-x-activitypub%400%2Fdist%2Fadmin-x-activitypub.js%22%7D" />
9
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.0%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%223958492b48%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%22a6a89eb153%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%221a3a628146%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%229a36081a2d%22%2C%22adminXActivitypubCustomUrl%22%3A%22https%3A%2F%2Fcdn.jsdelivr.net%2Fghost%2Fadmin-x-activitypub%400%2Fdist%2Fadmin-x-activitypub.js%22%7D" />
10
10
 
11
11
  <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
12
12
  <meta name="pinterest" content="nopin" />
@@ -49,7 +49,7 @@
49
49
 
50
50
  <script src="assets/vendor-aed0068cf9b67d042dd23a6343545b7b.js"></script>
51
51
  <script src="assets/chunk.397.f965bec6bb556d2750de.js"></script>
52
- <script src="assets/chunk.524.f75344acd3938cf05152.js"></script>
53
- <script src="assets/ghost-11843760824cb811ee4a35896c817a34.js"></script>
52
+ <script src="assets/chunk.524.124c2d3b27c954a9aaa6.js"></script>
53
+ <script src="assets/ghost-338989d8cb5fce4a84d53ddd39ac80d4.js"></script>
54
54
  </body>
55
55
  </html>
@@ -533,12 +533,7 @@ const controller = {
533
533
  };
534
534
  },
535
535
  async query(frame) {
536
- return await statsService.api.getTopSourcesWithRange(
537
- frame.options.date_from,
538
- frame.options.date_to,
539
- frame.options.order || 'free_members desc',
540
- frame.options.limit || 50
541
- );
536
+ return await statsService.api.getTopSourcesWithRange(frame.options);
542
537
  }
543
538
  }
544
539
 
@@ -4,6 +4,8 @@ const urlUtils = require('../../../shared/url-utils');
4
4
 
5
5
  // Import source normalization from ReferrersStatsService
6
6
  const {normalizeSource} = require('./ReferrersStatsService');
7
+ // Import centralized date utilities
8
+ const {getDateBoundaries, applyDateFilter} = require('./utils/date-utils');
7
9
 
8
10
  /**
9
11
  * @typedef {Object} StatsServiceOptions
@@ -21,6 +23,7 @@ const {normalizeSource} = require('./ReferrersStatsService');
21
23
  * @property {number} [limit=20] - Maximum number of results to return
22
24
  * @property {string} [date_from] - Start date filter in YYYY-MM-DD format
23
25
  * @property {string} [date_to] - End date filter in YYYY-MM-DD format
26
+ * @property {string} [timezone='UTC'] - optional timezone for date interpretation
24
27
  * @property {string} [post_type] - Filter by post type ('post', 'page')
25
28
  */
26
29
 
@@ -99,6 +102,7 @@ class PostsStatsService {
99
102
  const limitRaw = Number.parseInt(String(options.limit ?? 20), 10);
100
103
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 20;
101
104
  const [orderField, orderDirection = 'desc'] = order.split(' ');
105
+ const {dateFrom, dateTo} = getDateBoundaries(options);
102
106
 
103
107
  if (!['free_members', 'paid_members', 'mrr'].includes(orderField)) {
104
108
  throw new errors.BadRequestError({
@@ -142,27 +146,13 @@ class PostsStatsService {
142
146
  const subquery1 = this.select('attribution_url', 'attribution_type', 'attribution_id')
143
147
  .from('members_created_events')
144
148
  .whereNotNull('attribution_url');
145
- if (options.date_from || options.date_to) {
146
- if (options.date_from) {
147
- subquery1.where('created_at', '>=', options.date_from);
148
- }
149
- if (options.date_to) {
150
- subquery1.where('created_at', '<=', options.date_to + ' 23:59:59');
151
- }
152
- }
149
+ applyDateFilter(subquery1, dateFrom, dateTo, 'created_at');
153
150
 
154
151
  subquery1.union(function () {
155
152
  const subquery2 = this.select('attribution_url', 'attribution_type', 'attribution_id')
156
153
  .from('members_subscription_created_events')
157
154
  .whereNotNull('attribution_url');
158
- if (options.date_from || options.date_to) {
159
- if (options.date_from) {
160
- subquery2.where('created_at', '>=', options.date_from);
161
- }
162
- if (options.date_to) {
163
- subquery2.where('created_at', '<=', options.date_to + ' 23:59:59');
164
- }
165
- }
155
+ applyDateFilter(subquery2, dateFrom, dateTo, 'created_at');
166
156
  })
167
157
  .as('combined');
168
158
  })
@@ -462,6 +452,7 @@ class PostsStatsService {
462
452
  const selectField = groupByUrl ? 'mce.attribution_url' : 'mce.attribution_id as post_id';
463
453
  const groupByField = groupByUrl ? 'mce.attribution_url' : 'mce.attribution_id';
464
454
  const joinCondition = groupByUrl ? 'mce.attribution_url' : 'mce.attribution_id';
455
+ const {dateFrom, dateTo} = getDateBoundaries(options);
465
456
 
466
457
  let subquery = knex('members_created_events as mce')
467
458
  .select(selectField)
@@ -507,7 +498,7 @@ class PostsStatsService {
507
498
  }
508
499
  }
509
500
 
510
- this._applyDateFilter(subquery, options, 'mce.created_at');
501
+ applyDateFilter(subquery, dateFrom, dateTo, 'mce.created_at');
511
502
  return subquery;
512
503
  }
513
504
 
@@ -523,6 +514,7 @@ class PostsStatsService {
523
514
  const knex = this.knex;
524
515
  const selectField = groupByUrl ? 'msce.attribution_url' : 'msce.attribution_id as post_id';
525
516
  const groupByField = groupByUrl ? 'msce.attribution_url' : 'msce.attribution_id';
517
+ const {dateFrom, dateTo} = getDateBoundaries(options);
526
518
 
527
519
  let subquery = knex('members_subscription_created_events as msce')
528
520
  .select(selectField)
@@ -551,7 +543,7 @@ class PostsStatsService {
551
543
  }
552
544
  }
553
545
 
554
- this._applyDateFilter(subquery, options, 'msce.created_at');
546
+ applyDateFilter(subquery, dateFrom, dateTo, 'msce.created_at');
555
547
  return subquery;
556
548
  }
557
549
 
@@ -566,6 +558,7 @@ class PostsStatsService {
566
558
  _buildMrrSubquery(options, groupByUrl = false) {
567
559
  const selectField = groupByUrl ? 'msce.attribution_url' : 'msce.attribution_id as post_id';
568
560
  const groupByField = groupByUrl ? 'msce.attribution_url' : 'msce.attribution_id';
561
+ const {dateFrom, dateTo} = getDateBoundaries(options);
569
562
 
570
563
  let subquery = this.knex('members_subscription_created_events as msce')
571
564
  .select(selectField)
@@ -599,7 +592,7 @@ class PostsStatsService {
599
592
  }
600
593
  }
601
594
 
602
- this._applyDateFilter(subquery, options, 'msce.created_at');
595
+ applyDateFilter(subquery, dateFrom, dateTo, 'msce.created_at');
603
596
  return subquery;
604
597
  }
605
598
 
@@ -615,6 +608,7 @@ class PostsStatsService {
615
608
  */
616
609
  _buildFreeReferrersSubquery(postId, options) {
617
610
  const knex = this.knex;
611
+ const {dateFrom, dateTo} = getDateBoundaries(options);
618
612
 
619
613
  // Simpler approach mirroring _buildFreeMembersSubquery
620
614
  let subquery = knex('members_created_events as mce')
@@ -631,7 +625,7 @@ class PostsStatsService {
631
625
  .whereNull('msce.id') // Keep only signups where no matching paid conversion (same post/referrer) exists
632
626
  .groupBy('mce.referrer_source');
633
627
 
634
- this._applyDateFilter(subquery, options, 'mce.created_at'); // Filter based on signup time
628
+ applyDateFilter(subquery, dateFrom, dateTo, 'mce.created_at');
635
629
  return subquery;
636
630
  }
637
631
 
@@ -645,6 +639,7 @@ class PostsStatsService {
645
639
  */
646
640
  _buildPaidReferrersSubquery(postId, options) {
647
641
  const knex = this.knex;
642
+ const {dateFrom, dateTo} = getDateBoundaries(options);
648
643
  let subquery = knex('members_subscription_created_events as msce')
649
644
  .select('msce.referrer_source as source')
650
645
  .countDistinct('msce.member_id as paid_members')
@@ -652,8 +647,7 @@ class PostsStatsService {
652
647
  .where('msce.attribution_type', 'post')
653
648
  .groupBy('msce.referrer_source');
654
649
 
655
- // Apply date filter to the paid conversion event timestamp
656
- this._applyDateFilter(subquery, options, 'msce.created_at');
650
+ applyDateFilter(subquery, dateFrom, dateTo, 'msce.created_at');
657
651
  return subquery;
658
652
  }
659
653
 
@@ -667,6 +661,7 @@ class PostsStatsService {
667
661
  */
668
662
  _buildMrrReferrersSubquery(postId, options) {
669
663
  const knex = this.knex;
664
+ const {dateFrom, dateTo} = getDateBoundaries(options);
670
665
  let subquery = knex('members_subscription_created_events as msce')
671
666
  .select('msce.referrer_source as source')
672
667
  .sum('mpse.mrr_delta as mrr')
@@ -679,51 +674,10 @@ class PostsStatsService {
679
674
  .where('msce.attribution_type', 'post')
680
675
  .groupBy('msce.referrer_source');
681
676
 
682
- // Apply date filter to the paid conversion event timestamp
683
- this._applyDateFilter(subquery, options, 'msce.created_at');
677
+ applyDateFilter(subquery, dateFrom, dateTo, 'msce.created_at');
684
678
  return subquery;
685
679
  }
686
680
 
687
- /**
688
- * Apply date filters to a query builder instance
689
- * @private
690
- * @param {import('knex').Knex.QueryBuilder} query
691
- * @param {StatsServiceOptions} options
692
- * @param {string} dateColumn - The date column to filter on
693
- */
694
- _applyDateFilter(query, options, dateColumn) {
695
- // Note: Timezone handling might require converting dates before querying,
696
- // depending on how created_at is stored (UTC assumed here).
697
- if (options.date_from) {
698
- try {
699
- // Attempt to parse and validate the date
700
- const fromDate = new Date(options.date_from);
701
- if (!isNaN(fromDate.getTime())) {
702
- query.where(dateColumn, '>=', fromDate);
703
- } else {
704
- logging.warn(`Invalid date_from format: ${options.date_from}. Skipping filter.`);
705
- }
706
- } catch (e) {
707
- logging.warn(`Error parsing date_from: ${options.date_from}. Skipping filter.`);
708
- }
709
- }
710
- if (options.date_to) {
711
- try {
712
- const toDate = new Date(options.date_to);
713
- if (!isNaN(toDate.getTime())) {
714
- // Include the whole day for the 'to' date by setting to end of day
715
- const endOfDay = new Date(toDate);
716
- endOfDay.setHours(23, 59, 59, 999);
717
- query.where(dateColumn, '<=', endOfDay);
718
- } else {
719
- logging.warn(`Invalid date_to format: ${options.date_to}. Skipping filter.`);
720
- }
721
- } catch (e) {
722
- logging.warn(`Error parsing date_to: ${options.date_to}. Skipping filter.`);
723
- }
724
- }
725
- }
726
-
727
681
  /**
728
682
  * Get newsletter stats for sent or published posts with a specific newsletter_id
729
683
  *
@@ -733,6 +687,7 @@ class PostsStatsService {
733
687
  * @param {number|string} [options.limit=20] - Maximum number of results to return
734
688
  * @param {string} [options.date_from] - Optional start date filter (YYYY-MM-DD)
735
689
  * @param {string} [options.date_to] - Optional end date filter (YYYY-MM-DD)
690
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
736
691
  * @returns {Promise<{data: NewsletterStatResult[]}>} The newsletter stats for sent/published posts with the specified newsletter_id
737
692
  */
738
693
  async getNewsletterStats(newsletterId, options = {}) {
@@ -740,6 +695,7 @@ class PostsStatsService {
740
695
  const order = options.order || 'date desc';
741
696
  const limitRaw = Number.parseInt(String(options.limit ?? 20), 10);
742
697
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 20;
698
+ const {dateFrom, dateTo} = getDateBoundaries(options);
743
699
 
744
700
  // Parse order field and direction
745
701
  let [orderField, orderDirection = 'desc'] = order.split(' ');
@@ -766,19 +722,6 @@ class PostsStatsService {
766
722
  });
767
723
  }
768
724
 
769
- // Build date filters if provided
770
- let dateFilter = this.knex.raw('1=1');
771
- if (options.date_from) {
772
- dateFilter = this.knex.raw(`p.published_at >= ?`, [options.date_from]);
773
- }
774
- if (options.date_to) {
775
- // Make date_to inclusive of the entire day by adding 23:59:59
776
- const endOfDay = options.date_to + ' 23:59:59';
777
- dateFilter = options.date_from
778
- ? this.knex.raw(`p.published_at >= ? AND p.published_at <= ?`, [options.date_from, endOfDay])
779
- : this.knex.raw(`p.published_at <= ?`, [endOfDay]);
780
- }
781
-
782
725
  // Subquery to count clicks from members_click_events
783
726
  const clicksSubquery = this.knex
784
727
  .select('r.post_id')
@@ -806,11 +749,12 @@ class PostsStatsService {
806
749
  .leftJoin(clicksSubquery, 'p.id', 'clicks.post_id')
807
750
  .where('p.newsletter_id', newsletterId)
808
751
  .whereIn('p.status', ['sent', 'published'])
809
- // Show all newsletters that were sent, even if no email record exists or has 0 engagement
810
- .whereRaw(dateFilter)
811
752
  .orderBy(orderFieldMap[orderField], orderDirection)
812
753
  .limit(limit);
813
754
 
755
+ // Apply centralized date filtering
756
+ applyDateFilter(query, dateFrom, dateTo, 'p.published_at');
757
+
814
758
  const results = await query;
815
759
 
816
760
  return {data: results};
@@ -829,6 +773,7 @@ class PostsStatsService {
829
773
  * @param {number} [options.limit] - Number of results to return (default: 20)
830
774
  * @param {string} [options.date_from] - Optional start date filter (YYYY-MM-DD)
831
775
  * @param {string} [options.date_to] - Optional end date filter (YYYY-MM-DD)
776
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
832
777
  * @returns {Promise<{data: Array}>} The newsletter basic stats (with click data when ordering by click_rate)
833
778
  */
834
779
  async getNewsletterBasicStats(newsletterId, options = {}) {
@@ -836,6 +781,7 @@ class PostsStatsService {
836
781
  const order = options.order || 'date desc';
837
782
  const limitRaw = Number.parseInt(String(options.limit ?? 20), 10);
838
783
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 20;
784
+ const {dateFrom, dateTo} = getDateBoundaries(options);
839
785
 
840
786
  // Parse order field and direction
841
787
  let [orderField, orderDirection = 'desc'] = order.split(' ');
@@ -862,19 +808,6 @@ class PostsStatsService {
862
808
  });
863
809
  }
864
810
 
865
- // Build date filters if provided
866
- let dateFilter = this.knex.raw('1=1');
867
- if (options.date_from) {
868
- dateFilter = this.knex.raw(`p.published_at >= ?`, [options.date_from]);
869
- }
870
- if (options.date_to) {
871
- // Make date_to inclusive of the entire day by adding 23:59:59
872
- const endOfDay = options.date_to + ' 23:59:59';
873
- dateFilter = options.date_from
874
- ? this.knex.raw(`p.published_at >= ? AND p.published_at <= ?`, [options.date_from, endOfDay])
875
- : this.knex.raw(`p.published_at <= ?`, [endOfDay]);
876
- }
877
-
878
811
  let query;
879
812
 
880
813
  // If ordering by click_rate, we need to include click data
@@ -906,10 +839,11 @@ class PostsStatsService {
906
839
  .leftJoin(clicksSubquery, 'p.id', 'clicks.post_id')
907
840
  .where('p.newsletter_id', newsletterId)
908
841
  .whereIn('p.status', ['sent', 'published'])
909
- // Show all newsletters that were sent, even if no email record exists or has 0 engagement
910
- .whereRaw(dateFilter)
911
842
  .orderBy(orderFieldMap[orderField], orderDirection)
912
843
  .limit(limit);
844
+
845
+ // Apply centralized date filtering
846
+ applyDateFilter(query, dateFrom, dateTo, 'p.published_at');
913
847
  } else {
914
848
  // Build the query without click data for better performance
915
849
  query = this.knex
@@ -925,10 +859,11 @@ class PostsStatsService {
925
859
  .leftJoin('emails as e', 'p.id', 'e.post_id')
926
860
  .where('p.newsletter_id', newsletterId)
927
861
  .whereIn('p.status', ['sent', 'published'])
928
- // Show all newsletters that were sent, even if no email record exists or has 0 engagement
929
- .whereRaw(dateFilter)
930
862
  .orderBy(orderFieldMap[orderField], orderDirection)
931
863
  .limit(limit);
864
+
865
+ // Apply centralized date filtering
866
+ applyDateFilter(query, dateFrom, dateTo, 'p.published_at');
932
867
  }
933
868
 
934
869
  const results = await query;
@@ -996,6 +931,7 @@ class PostsStatsService {
996
931
  * @param {Object} options - Query options
997
932
  * @param {string} [options.date_from] - Optional start date filter (YYYY-MM-DD)
998
933
  * @param {string} [options.date_to] - Optional end date filter (YYYY-MM-DD)
934
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
999
935
  * @returns {Promise<{data: Array<{total: number, deltas: Array<{date: string, value: number}>}>}>} The newsletter subscriber stats
1000
936
  */
1001
937
  async getNewsletterSubscriberStats(newsletterId, options = {}) {
@@ -1055,8 +991,15 @@ class PostsStatsService {
1055
991
  /**
1056
992
  * Optimized query to get newsletter subscriber deltas
1057
993
  * @private
994
+ * @param {string} newsletterId - ID of the newsletter to get subscriber deltas for
995
+ * @param {Object} options - Query options
996
+ * @param {string} [options.date_from] - Optional start date filter (YYYY-MM-DD)
997
+ * @param {string} [options.date_to] - Optional end date filter (YYYY-MM-DD)
998
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
1058
999
  */
1059
1000
  async _getNewsletterSubscriberDeltas(newsletterId, options = {}) {
1001
+ const {dateFrom, dateTo} = getDateBoundaries(options);
1002
+
1060
1003
  // Build optimized deltas query - avoid expensive JOIN
1061
1004
  let deltasQuery = this.knex('members_subscribe_events as mse')
1062
1005
  .select(
@@ -1073,13 +1016,8 @@ class PostsStatsService {
1073
1016
  .groupByRaw('DATE(mse.created_at)')
1074
1017
  .orderBy('date', 'asc');
1075
1018
 
1076
- // Apply date filters early to reduce dataset
1077
- if (options.date_from) {
1078
- deltasQuery.where('mse.created_at', '>=', options.date_from);
1079
- }
1080
- if (options.date_to) {
1081
- deltasQuery.where('mse.created_at', '<=', `${options.date_to} 23:59:59`);
1082
- }
1019
+ // Apply timezone-aware date filters
1020
+ applyDateFilter(deltasQuery, dateFrom, dateTo, 'mse.created_at');
1083
1021
 
1084
1022
  return await deltasQuery;
1085
1023
  }
@@ -1369,6 +1307,9 @@ class PostsStatsService {
1369
1307
  * @private
1370
1308
  * @param {string[]} postIds - Array of post IDs to get attribution counts for
1371
1309
  * @param {Object} options - Date filter options
1310
+ * @param {string} [options.date_from] - Start date in YYYY-MM-DD format
1311
+ * @param {string} [options.date_to] - End date in YYYY-MM-DD format
1312
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
1372
1313
  * @returns {Promise<Array<{post_id: string, free_members: number, paid_members: number}>>}
1373
1314
  */
1374
1315
  async _getMemberAttributionCounts(postIds, options = {}) {
@@ -1376,6 +1317,8 @@ class PostsStatsService {
1376
1317
  return [];
1377
1318
  }
1378
1319
 
1320
+ const {dateFrom, dateTo} = getDateBoundaries(options);
1321
+
1379
1322
  try {
1380
1323
  // Build free members query (modeled after _buildFreeMembersSubquery)
1381
1324
  // Members who signed up on post but paid elsewhere/never
@@ -1393,7 +1336,7 @@ class PostsStatsService {
1393
1336
  .groupBy('mce.attribution_id');
1394
1337
 
1395
1338
  // Apply date filter to free members query
1396
- this._applyDateFilter(freeMembersQuery, options, 'mce.created_at');
1339
+ applyDateFilter(freeMembersQuery, dateFrom, dateTo, 'mce.created_at');
1397
1340
 
1398
1341
  // Build paid members query (modeled after _buildPaidMembersSubquery)
1399
1342
  // Members whose paid conversion was attributed to this post
@@ -1405,7 +1348,7 @@ class PostsStatsService {
1405
1348
  .groupBy('msce.attribution_id');
1406
1349
 
1407
1350
  // Apply date filter to paid members query
1408
- this._applyDateFilter(paidMembersQuery, options, 'msce.created_at');
1351
+ applyDateFilter(paidMembersQuery, dateFrom, dateTo, 'msce.created_at');
1409
1352
 
1410
1353
  // Execute both queries
1411
1354
  const [freeResults, paidResults] = await Promise.all([