Skip to content

🐛 Fixed Analytics > Growth tab MRR chart lookback#24953

Merged
9larsons merged 5 commits intomainfrom
fix-mrr-chart-lookback
Sep 25, 2025
Merged

🐛 Fixed Analytics > Growth tab MRR chart lookback#24953
9larsons merged 5 commits intomainfrom
fix-mrr-chart-lookback

Conversation

@9larsons
Copy link
Copy Markdown
Contributor

ref https://linear.app/ghost/issue/ONC-1157/

  • wired up lookback parameter to /stats/mrr endpoint
  • refactored mrr endpoint tests to use helpers, be more readable
  • added mrr tests for >90d lookback (default)

When wiring this up, it appears we missed adding the lookback support to this endpoint. It was adapted from the previous Dashboard which always (and could only) look back 90d, so that's all the more we were returning. The endpoint now correctly looks back over the selected timeframe.

@9larsons 9larsons requested a review from cmraible September 22, 2025 19:23
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Sep 22, 2025

Walkthrough

The change threads a date_from parameter from the frontend hook through the API endpoint to the stats services. The React hook now requests MRR history constrained by memberDataStartDate. The mrr API endpoint accepts date_from, includes it in cache key generation, and passes it to statsService.api.getMRRHistory. StatsService forwards options to MrrStatsService.getHistory. MrrStatsService methods now accept options/dateFrom to constrain fetched deltas, defaulting to 90 days otherwise. Unit tests are overhauled to be time-deterministic, add helpers, switch created_at to datetime, and cover multiple scenarios including custom dateFrom filtering.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly identifies the primary change—fixing the MRR chart lookback in Analytics > Growth—and directly matches the code changes that wire the lookback parameter through the /stats/mrr endpoint and update tests. It is concise and specific so a teammate scanning history will understand the main intent. The emoji is stylistic noise but does not make the title misleading.
Description Check ✅ Passed The description directly describes wiring the lookback parameter to /stats/mrr, refactoring MRR endpoint tests, and adding tests for lookbacks greater than the 90-day default, which aligns with the diff and raw_summary. It provides relevant background about the previous 90-day behavior and the issue being fixed, so it is on-topic and useful for reviewers. The level of detail is adequate for this permissive check.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-mrr-chart-lookback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the community [triage] Community features and bugs label Sep 22, 2025
@9larsons
Copy link
Copy Markdown
Contributor Author

When testing this, the data generator works great. Just run yarn reset:data and enable Stripe to see the MRR charts.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ghost/core/core/server/services/stats/MrrStatsService.js (1)

76-82: Use UTC for “today” to avoid TZ drift in future-event filtering.

Local time can shift “today” versus stored UTC dates.

Apply:

-        const today = moment().format('YYYY-MM-DD');
+        const today = moment.utc().format('YYYY-MM-DD');
🧹 Nitpick comments (5)
ghost/core/core/server/services/stats/StatsService.js (1)

26-28: Wiring options through to MRR is correct. Add JSDoc for clarity.

Document the accepted options (dateFrom) like other methods to prevent misuse.

Apply:

     }
 
-    async getMRRHistory(options = {}) {
+    /**
+     * @param {Object} [options]
+     * @param {string} [options.dateFrom] - Start date in YYYY-MM-DD (UTC)
+     */
+    async getMRRHistory(options = {}) {
         return this.mrr.getHistory(options);
     }
ghost/core/core/server/services/stats/MrrStatsService.js (2)

51-53: Filter future rows in SQL to reduce payload and work.

No reason to fetch events beyond end-of-day UTC if we drop them later.

Apply:

-            .where('created_at', '>=', startDate)
+            .whereBetween('created_at', [
+                startDate,
+                moment.utc().endOf('day').format('YYYY-MM-DD HH:mm:ss')
+            ])

35-39: Fix JSDoc inconsistencies and typos.

Doc says “old→new” and “new→old” simultaneously; also typo “respons”.

Apply:

-    /**
-     * Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
-     * @param {string} [dateFrom] - Start date to fetch deltas from
-     * @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
-     */
+    /**
+     * Get MRR deltas since start date, grouped by currency.
+     * @param {string} [dateFrom] - Start date (UTC, YYYY-MM-DD)
+     * @returns {Promise<MrrDelta[]>} Unsorted raw deltas
+     */
...
-     * The respons is in ascending date order, and currencies for the same date are always in ascending order.
+     * The response is in ascending date order, and currencies for the same date are in ascending order.
-     * @param {Object} [options]
-     * @param {string} [options.dateFrom] - Start date to fetch history from
+     * @param {Object} [options]
+     * @param {string} [options.dateFrom] - Start date (UTC, YYYY-MM-DD)

Also applies to: 56-62

ghost/core/test/unit/server/services/stats/mrr.test.js (2)

111-121: Use numeric types for MRR fields in test schema.

Avoids engine-dependent coercion in SUM().

Apply:

-            await db.schema.createTable('members_paid_subscription_events', function (table) {
+            await db.schema.createTable('members_paid_subscription_events', function (table) {
                 table.string('currency');
-                table.string('mrr_delta');
+                table.integer('mrr_delta');
                 table.datetime('created_at');
             });
 
-            await db.schema.createTable('members_stripe_customers_subscriptions', function (table) {
+            await db.schema.createTable('members_stripe_customers_subscriptions', function (table) {
                 table.string('plan_currency');
-                table.string('mrr');
+                table.integer('mrr');
             });

316-339: Great: explicit dateFrom includes earlier events. Add one test for invalid dateFrom.

Cover invalid input to ensure service falls back to default window (after the proposed validation).

Proposed test:

+        it('Falls back when dateFrom is invalid', async function () {
+            await addMrrEvents([
+                {date: yesterday, delta: 5},
+                {date: today, delta: 5}
+            ]);
+            await addCurrentSubscriptions([{mrr: 10}]);
+            const history = await mrrStatsService.getHistory({dateFrom: 'not-a-date'});
+            history.data[history.data.length - 1].should.eql({date: todayDate, mrr: 10, currency: 'usd'});
+        });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 938a4a1 and d8d1e75.

📒 Files selected for processing (5)
  • apps/stats/src/hooks/useGrowthStats.ts (1 hunks)
  • ghost/core/core/server/api/endpoints/stats.js (1 hunks)
  • ghost/core/core/server/services/stats/MrrStatsService.js (1 hunks)
  • ghost/core/core/server/services/stats/StatsService.js (1 hunks)
  • ghost/core/test/unit/server/services/stats/mrr.test.js (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
ghost/core/core/server/services/stats/MrrStatsService.js (1)
ghost/core/core/server/services/stats/utils/date-utils.js (1)
  • dateFrom (13-13)
apps/stats/src/hooks/useGrowthStats.ts (1)
apps/admin-x-framework/src/api/stats.ts (1)
  • useMrrHistory (234-237)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Lint
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Build & Push
🔇 Additional comments (6)
apps/stats/src/hooks/useGrowthStats.ts (1)

207-211: LGTM: MRR history now respects the same lookback as members.

Passing date_from keeps charts aligned.

ghost/core/core/server/api/endpoints/stats.js (3)

38-41: Expose date_from in MRR endpoint options.

Good addition; matches memberCountHistory.


42-46: Include options in the cache key.

Prevents collisions across different lookbacks.


48-51: Forward the mapped option as dateFrom.

Correctly threads to service API.

ghost/core/test/unit/server/services/stats/mrr.test.js (2)

15-23: Deterministic “today” via fake timers is solid.

Keeps tests stable across environments.


290-315: Good coverage for default 90‑day window behavior.

Asserts the synthetic boundary and totals correctly.

Comment on lines +42 to 45
const startDate = dateFrom
? moment.utc(dateFrom).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss')
: moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
const rows = await knex('members_paid_subscription_events')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate and normalize dateFrom; fall back if invalid.

An invalid dateFrom produces 'Invalid date' and an unexpected WHERE bound.

Apply:

-        const startDate = dateFrom
-            ? moment.utc(dateFrom).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss')
-            : moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
+        const parsed = dateFrom ? moment.utc(dateFrom, ['YYYY-MM-DD', moment.ISO_8601], true) : null;
+        const startDate = parsed?.isValid()
+            ? parsed.startOf('day').format('YYYY-MM-DD HH:mm:ss')
+            : moment.utc().subtract(90, 'days').startOf('day').format('YYYY-MM-DD HH:mm:ss');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const startDate = dateFrom
? moment.utc(dateFrom).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss')
: moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
const rows = await knex('members_paid_subscription_events')
const parsed = dateFrom ? moment.utc(dateFrom, ['YYYY-MM-DD', moment.ISO_8601], true) : null;
const startDate = parsed?.isValid()
? parsed.startOf('day').format('YYYY-MM-DD HH:mm:ss')
: moment.utc().subtract(90, 'days').startOf('day').format('YYYY-MM-DD HH:mm:ss');
const rows = await knex('members_paid_subscription_events')
🤖 Prompt for AI Agents
In ghost/core/core/server/services/stats/MrrStatsService.js around lines 42 to
45, the current ternary uses moment.utc(dateFrom) without validating it so an
invalid dateFrom yields "Invalid date" and bad query bindings; change this to
explicitly parse and validate dateFrom (e.g. const parsed =
moment.utc(dateFrom); if (dateFrom && parsed.isValid()) use
parsed.startOf('day').utc().format('YYYY-MM-DD HH:mm:ss') else fall back to
moment.utc().subtract(90, 'days').startOf('day').utc().format(...)); ensure you
handle non-string/undefined inputs, use the validated/normalized string in the
WHERE binding, and remove the original ternary.

@ErisDS ErisDS added core team [triage] Being looked at by the core Ghost team and removed community [triage] Community features and bugs labels Sep 23, 2025
Copy link
Copy Markdown
Collaborator

@cmraible cmraible left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! Works for me locally, looking good 👌

@9larsons 9larsons merged commit e7f194d into main Sep 25, 2025
28 checks passed
@9larsons 9larsons deleted the fix-mrr-chart-lookback branch September 25, 2025 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core team [triage] Being looked at by the core Ghost team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants