Skip to content

🎨 Improved analytics chart bucketing for Year to date and All time ranges#29237

Merged
9larsons merged 2 commits into
mainfrom
slars/mystifying-lewin-9e761e
Jul 10, 2026
Merged

🎨 Improved analytics chart bucketing for Year to date and All time ranges#29237
9larsons merged 2 commits into
mainfrom
slars/mystifying-lewin-9e761e

Conversation

@9larsons

@9larsons 9larsons commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

fixes https://linear.app/ghost/issue/PLA-236/
fixes https://linear.app/ghost/issue/PLA-237/
fixes https://linear.app/ghost/issue/PLA-238/

Chart bucketing/aggregation in Admin existed as three divergent copies: a ~344-line re-implementation in src/analytics/utils/chart-helpers.ts, a getPeriodText copy in src/posts/analytics/utils/, and Shade's plainer sanitizeChartData. Which copy a chart happened to import determined how it bucketed. This PR collapses them into one module, apps/admin/src/shared/analytics/chart-helpers.ts — the rest is cleanup:

  • Deleted the two local copies and removed sanitizeChartData from @tryghost/shade/app (bucketing depends on Ghost's range sentinels, which the design system shouldn't know about)
  • Removed dead code: bulk-import outlier detection (_isOutlier had no consumers) and the unreachable monthly-exact path
  • Unified the duplicated KpiMetric type (chartColor vs color) into src/shared/analytics/kpi.ts
  • Net ~−450 lines

The new logic

Every range resolves to an effective span in days — fixed ranges as-is, Year to date → days since Jan 1, All time → the span of the chart's own data — then one threshold set applies: <91 days daily, 91–270 weekly, >270 monthly. Buckets are labeled with their period start for all aggregation types, except the partial first bucket which clamps to the range start. Tick/tooltip formats follow the bucketing via getEffectiveChartRange().

Before → after

Before After
Year to date Post analytics: ~190 raw daily points (choppy). Site analytics: weekly after 60 days elapsed, monthly after 150 Same thresholds as every range: daily until ~April, weekly to ~October (~28 points mid-summer), then monthly
All time Site analytics: always monthly (1000-day sentinel). Post analytics: weekly or monthly depending on post age, by accident Span of the chart's actual data decides: <91 days daily, 91–270 weekly, >270 monthly — same rule for site and posts
Leading empty rows (All time) api_kpis pads the 1000-day window with zeros; trimmed in one view only, after aggregation Trimmed centrally before the span is measured and aggregated
Weekly→monthly boundary 356 days: an ~11-month-old post's All time rendered ~48 weekly points 270 days (~9 months): the same chart renders ~12 monthly points. No range dropdown option sits between 91 and 372 days, so fixed options keep their bands
Monthly bucket labels Sum/avg charts (web visits): 1st of month. exact charts (member totals): last day of month — cross-tab points off by a month's width Period start for every aggregation type — buckets align across all tabs
First bucket label Calendar period start, dating the chart before its own range: YTD opened at "Week of 28 Dec" of the previous year Clamped to the range start: YTD opens at "Week of 1 Jan" (All time: the first data point). Later buckets keep calendar period starts, so alignment is preserved
Tooltip/tick format Derived from the raw range number: YTD (-1) showed raw dates like "31 Mar" for aggregated buckets; some views force-faked ranges (366/91/30) to work around it getEffectiveChartRange() maps the actual bucketing to the display band: monthly → "Mar 2026", weekly → "Week of 22 Mar" — the hacks are gone

Verification

  • apps/admin: typecheck, lint, 848 unit tests (incl. a new suite covering thresholds, YTD/All time resolution, label conventions, and first-bucket clamping); apps/shade: build, lint, tests.
  • Manually via pnpm dev/pnpm dev:analytics across site Analytics (Overview/Growth/Newsletters/Web) and Post Analytics (Web traffic): YTD renders ~28 aligned weekly buckets on every tab, All time on an ~11-month-old post renders ~12 monthly buckets.

9larsons added 2 commits July 10, 2026 10:34
…nges

fixes https://linear.app/ghost/issue/PLA-236/ytd-range-renders-unaggregated-daily-points-choppy-analytics-charts
fixes https://linear.app/ghost/issue/PLA-237/ytd-month-buckets-labeled-inconsistently-web-uses-1st-of-month
fixes https://linear.app/ghost/issue/PLA-238/verify-all-time-bucketing-in-post-analytics-web-currently-summarizes

Chart bucketing was split across three implementations (a 344-line local
copy in src/analytics, a getPeriodText copy in src/posts, and Shade's
plainer sanitizeChartData) that disagreed about Year to date and about
where bucket labels land. Year to date rendered raw daily points in some
views, and monthly buckets were labeled with the 1st of the month on web
charts but the last day on newsletters/growth charts.

There is now one implementation in apps/admin/src/shared/analytics:

- Every range resolves to an effective span in days (Year to date → days
  since Jan 1, All time → the span of the chart's own data after trimming
  the API's leading empty padding) and one set of thresholds applies to
  all of them: under 91 days daily, 91-356 weekly, over 356 monthly.
- Buckets are always labeled with their period start regardless of
  aggregation type, so sum charts (visits) and exact charts (member
  totals) place points on the same dates, and getEffectiveChartRange maps
  the chosen bucketing into the matching display band ("Week of ...",
  "MMM YYYY") for GhAreaChart ticks and tooltips.
- Shade's sanitizeChartData is removed: the bucketing rules depend on
  Ghost's range sentinels, which the design system shouldn't know about.
  Also removes dead code (bulk-import outlier detection, monthly-exact)
  and unifies the duplicated KpiMetric type.
ref https://linear.app/ghost/issue/PLA-236/ytd-range-renders-unaggregated-daily-points-choppy-analytics-charts
ref https://linear.app/ghost/issue/PLA-238/verify-all-time-bucketing-in-post-analytics-web-currently-summarizes

Review feedback on the bucketing unification:

- The weekly band ran to 356 days, so an ~11-month "All time" chart rendered
  ~48 weekly points — dense and jagged next to the ~12 monthly points that
  "Last 12 months" gets. The weekly band now caps at 270 days (~9 months);
  Year to date keeps its comfortable ~28 weekly points in mid-summer and
  flips to monthly from October. Only span-resolved ranges (Year to date,
  All time, post age) can land between the fixed dropdown options of 91 and
  372 days, so no dropdown option changes bands.
- Buckets are labeled with their calendar period start, which dated the
  partial first bucket before the chart's own range — a Year to date chart
  opened at "Week of 28 Dec" of the previous year. The first bucket's label
  is now clamped to the range start ("Week of 1 Jan"), or to the first data
  point for All time. Later buckets keep calendar period starts, and the
  zero-filling in the paid subscriptions chart clamps its period keys the
  same way, so cross-chart bucket alignment is unchanged.
@nx-cloud

nx-cloud Bot commented Jul 10, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit d98b5a5

Command Status Duration Result
nx run @tryghost/admin-x-settings:test:acceptance ✅ Succeeded 10m 8s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗
nx run-many -t test:unit -p @tryghost/admin,@tr... ✅ Succeeded 5m 40s View ↗
nx run @tryghost/admin:build ✅ Succeeded 2m 56s View ↗
nx run ghost-admin:test ✅ Succeeded 2m 57s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded <1s View ↗
nx run-many -t lint -p @tryghost/admin,@tryghos... ✅ Succeeded 1m 25s View ↗
nx run @tryghost/activitypub:test:acceptance ✅ Succeeded 43s View ↗
Additional runs (2) ✅ Succeeded ... View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-10 16:43:56 UTC

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: analytics chart bucketing improvements for Year to date and All time ranges.
Description check ✅ Passed The description accurately matches the changeset and explains the shared chart-helper consolidation and bucketing updates.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slars/mystifying-lewin-9e761e

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.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/admin/src/posts/analytics/Overview/overview.tsx (1)

92-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Wrap displayChartRange in useMemo for consistency and performance.

Unlike all other files in this PR (overview.tsx, newsletters-kpis.tsx, web-kpis.tsx), displayChartRange is computed as a bare const without useMemo. Since this component holds state (isGiftLinkOpen) that triggers re-renders, getEffectiveChartRange (which runs trimForRange + getAggregationStrategy) will recompute on every render even when chartData and chartRange are unchanged.

♻️ Proposed fix
-const displayChartRange = getEffectiveChartRange(chartRange, chartData as KpiDataItem[] || [], {fieldName: currentMetric.dataKey as keyof KpiDataItem});
+const displayChartRange = useMemo(() => {
+    return getEffectiveChartRange(chartRange, chartData as KpiDataItem[] || [], {fieldName: currentMetric.dataKey as keyof KpiDataItem});
+}, [chartRange, chartData, currentMetric]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin/src/posts/analytics/Overview/overview.tsx` at line 92, Wrap the
displayChartRange calculation in the Overview component with useMemo, using
chartRange, chartData, and currentMetric.dataKey as dependencies. Preserve the
existing getEffectiveChartRange arguments and ensure useMemo is imported from
React if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/admin/src/shared/analytics/chart-helpers.test.ts`:
- Line 275: Update the test description in the monthly aggregation test to
reference ranges over 270 days, matching the current monthly threshold and the
range used by the test.

---

Nitpick comments:
In `@apps/admin/src/posts/analytics/Overview/overview.tsx`:
- Line 92: Wrap the displayChartRange calculation in the Overview component with
useMemo, using chartRange, chartData, and currentMetric.dataKey as dependencies.
Preserve the existing getEffectiveChartRange arguments and ensure useMemo is
imported from React if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f2990daf-2631-4abb-b31d-f20abeb21dee

📥 Commits

Reviewing files that changed from the base of the PR and between b97f3ea and d98b5a5.

📒 Files selected for processing (30)
  • apps/admin/src/analytics/utils/chart-helpers.test.ts
  • apps/admin/src/analytics/utils/chart-helpers.ts
  • apps/admin/src/analytics/views/Stats/Growth/components/growth-kpis.tsx
  • apps/admin/src/analytics/views/Stats/Growth/components/growth-sources.tsx
  • apps/admin/src/analytics/views/Stats/Growth/components/new-subscribers-cadence.tsx
  • apps/admin/src/analytics/views/Stats/Growth/components/paid-subscription-change-chart.test.tsx
  • apps/admin/src/analytics/views/Stats/Growth/components/paid-subscription-change-chart.tsx
  • apps/admin/src/analytics/views/Stats/Growth/growth.tsx
  • apps/admin/src/analytics/views/Stats/Locations/components/locations-card.tsx
  • apps/admin/src/analytics/views/Stats/Newsletters/components/newsletters-kpis.tsx
  • apps/admin/src/analytics/views/Stats/Newsletters/newsletters.tsx
  • apps/admin/src/analytics/views/Stats/Overview/components/overview-kpis.tsx
  • apps/admin/src/analytics/views/Stats/Overview/components/top-posts.tsx
  • apps/admin/src/analytics/views/Stats/Overview/overview.tsx
  • apps/admin/src/analytics/views/Stats/Web/components/sources-card.tsx
  • apps/admin/src/analytics/views/Stats/Web/components/top-content.tsx
  • apps/admin/src/analytics/views/Stats/Web/components/web-kpis.tsx
  • apps/admin/src/analytics/views/Stats/Web/web-kpi-metrics.ts
  • apps/admin/src/posts/analytics/Overview/overview.tsx
  • apps/admin/src/posts/analytics/Web/components/kpi-metrics.ts
  • apps/admin/src/posts/analytics/Web/components/kpis.tsx
  • apps/admin/src/posts/analytics/Web/components/sources.tsx
  • apps/admin/src/posts/analytics/Web/web.tsx
  • apps/admin/src/posts/analytics/utils/chart-helpers.test.tsx
  • apps/admin/src/posts/analytics/utils/chart-helpers.ts
  • apps/admin/src/shared/analytics/chart-helpers.test.ts
  • apps/admin/src/shared/analytics/chart-helpers.ts
  • apps/admin/src/shared/analytics/kpi.ts
  • apps/shade/src/app.ts
  • apps/shade/src/lib/app-utils.ts
💤 Files with no reviewable changes (7)
  • apps/admin/src/posts/analytics/utils/chart-helpers.ts
  • apps/admin/src/posts/analytics/utils/chart-helpers.test.tsx
  • apps/admin/src/analytics/views/Stats/Growth/components/paid-subscription-change-chart.test.tsx
  • apps/admin/src/analytics/utils/chart-helpers.test.ts
  • apps/shade/src/lib/app-utils.ts
  • apps/shade/src/app.ts
  • apps/admin/src/analytics/utils/chart-helpers.ts

});

describe('monthly aggregation', () => {
it('aggregates data monthly for ranges over 356 days', () => {

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale threshold in test description ("over 356 days").

The weekly band now caps at 270 days (monthly is > 270), per this PR's threshold change. The test uses range 372, so it still passes, but the "356" wording no longer reflects the boundary and is misleading.

📝 Suggested wording fix
-            it('aggregates data monthly for ranges over 356 days', () => {
+            it('aggregates data monthly for ranges over 270 days', () => {
📝 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
it('aggregates data monthly for ranges over 356 days', () => {
it('aggregates data monthly for ranges over 270 days', () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin/src/shared/analytics/chart-helpers.test.ts` at line 275, Update
the test description in the monthly aggregation test to reference ranges over
270 days, matching the current monthly threshold and the range used by the test.

@9larsons
9larsons enabled auto-merge (squash) July 10, 2026 16:47
@9larsons
9larsons merged commit fea5fad into main Jul 10, 2026
43 checks passed
@9larsons
9larsons deleted the slars/mystifying-lewin-9e761e branch July 10, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant