Skip to content

CMM-2152: Fix Views card stat accuracy and halve its network calls#23101

Open
adalpari wants to merge 11 commits into
trunkfrom
fix/cmm-2152-polish-views-stats-periods
Open

CMM-2152: Fix Views card stat accuracy and halve its network calls#23101
adalpari wants to merge 11 commits into
trunkfrom
fix/cmm-2152-polish-views-stats-periods

Conversation

@adalpari

@adalpari adalpari commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Description

Polishes the new-stats Views card, fixing a series of period-specific accuracy bugs where the header total, the header % change, the chart, and the bottom-row totals could disagree with each other (and with the web app), and then cutting the card's network calls in half.

What's fixed / changed

1. The bottom row is filled from the chart's own call (fewer network calls)
The chart's stats/visits call already returns visitors/likes/comments/posts per bucket. For every period whose chart is not hourly — the fixed periods and any multi-day Custom range — the card now requests those fields and reuses that same response to populate the bottom row instead of issuing a separate dedicated call: 2 network calls instead of 4.

This also makes the header and the bottom row agree by construction: they are now literally the same response, so no pair of windows or units can drift apart.

Single-day periods (Today, single-day Custom) render an hourly chart whose response only carries views, so they alone keep fetching the bottom row from a dedicated day-level call (matching the web app) — 4 calls.

2. The window's first bucket is no longer dropped
unitAndQuantityFor derived its quantity from ChronoUnit.MONTHS/YEARS.between, which count the whole units elapsed between the endpoints rather than the calendar buckets the window spans. Since the API returns quantity buckets ending at the bucket holding the end date, the window's first bucket was silently dropped whenever the end's day-of-month fell before the start's: Jan 15..Mar 5 elapses one whole month but spans three, so the card fetched Feb+Mar and lost January entirely — from the chart, the header total and the bottom row alike. Roughly half of all custom ranges longer than 31 days were affected. It now counts spanned buckets.

3. Correct hourly window for single-day periods
The Today chart requested the hourly window ending at the next day's 00:00, so the API returned 01:00→00:00 buckets — dropping today's 00:00 hour and under-counting the chart total vs the day-level bottom row. The window now ends at "<day> 23:00:00", covering the calendar day's 00:00–23:00 (and yesterday for the previous window), matching the web app.

The same off-by-one hour lived in fetchHourlyViews, which still applied an "endDate is exclusive for hourly queries" workaround; it now shares the one formatApiEndDate helper, so the Today's Stats card gets the same fix and the file no longer holds two contradictory models of the endpoint.

4. No first-bucket truncation in the dedicated bottom call
The dedicated bottom-stats call passed a mid-bucket startDate, so for month/year units the API truncated the first bucket's views (additive) while leaving visitors (per-bucket unique) at the full value. The call now mirrors the chart (unit + quantity + endDate, with start_date only where a YEAR window needs it — see #6).

5. The Custom chart coarsens to YEAR beyond two years
For a Custom range over two years the chart used MONTH buckets while the bottom-row totals coarsen to YEAR, so the two covered different, misaligned windows. The custom chart window now shares the bottom row's exact rule, so a long Custom range keeps one unit across the whole card — and, since #1 has it fill the row from that same response, its visitor uniques are de-duplicated per year rather than counted once per month.

6. Long Custom ranges send start_date, and each window carries its own quantity
A Custom range over two years rendered every number on the card wrong — chart, header total, % change and bottom row alike. The app already computed unit=year, quantity=3 and date=<end> identically to the web app but omitted start_date; without it the API doesn't anchor its year buckets to the requested window, so it returned the wrong buckets. YEAR windows now send their own real start, matching web's unit=year&date=<end>&start_date=<start>&quantity=<n>. DAY and MONTH windows still send none (they're correct from unit + quantity + endDate, and a mid-bucket start_date there is the truncation described in #4).

Sending start_date forced a second fix: a window's quantity and its start_date must describe the same window, but the previous request reused the current window's quantity. The previous window mirrors the current one's day span, which always resolves to the same unit but not always to the same bucket count — Dec 31 2023..Jan 1 2026 spans four years while its mirror spans three, so the API folded an extra year into the previous total and skewed the % change. PeriodDateRange now carries a quantity per window, each derived from its own span.

7. YEAR chart buckets are labeled with the year, not the month
A Custom range charted in YEAR buckets rendered its axis labels as "Jan, Jan, Jan" and its legend as "Jan - Jun", because the ViewModel re-derived granularity from a day-count that only distinguishes day from month. The requested unit is now carried through PeriodStatsResult.Success, so labels and the legend format from the granularity actually requested rather than guessing it a second time. DAY/MONTH/HOUR output is unchanged.

Known follow-up: drill-down still guesses granularity from a day-count, so tapping a year bar opens that January. Deferred here because it changes an interaction.

Cleanup
Reuse ShimmerBox for the loading placeholders, dedup the previous-window mirror math, collapse the copy-paste bottom-stats aggregate mapping, and satisfy detekt.

All of the above are covered by unit tests in StatsRepositoryTest and ViewsStatsViewModelTest.

Note on the commit history

The branch is best read as its end state rather than commit-by-commit: a few commits supersede earlier ones. In particular, the Custom range's bottom-row routing was first moved to the dedicated call and then returned to the chart response once the custom chart window and the bottom row were made to share one unit, quantity and window; and an earlier "align previous windows" step is moot now that the fixed periods make no dedicated bottom call at all.

Testing instructions

  1. Open the new stats screen
  2. Keep just the "Views" card
  • Test different date ranges and check them vs stats on the webapp

@dangermattic

dangermattic commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator
2 Warnings
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
⚠️ PR is not assigned to a milestone.

Generated by 🚫 Danger

@wpmobilebot

wpmobilebot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in Jetpack Android by scanning the QR code below to install the corresponding build.

App NameJetpack Android
Build TypeDebug
Versionpr23101-e3c78bb
Build Number1498
Application IDcom.jetpack.android.prealpha
Commite3c78bb
Installation URL1btcqbvotjnng
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in WordPress Android by scanning the QR code below to install the corresponding build.

App NameWordPress Android
Build TypeDebug
Versionpr23101-e3c78bb
Build Number1498
Application IDorg.wordpress.android.prealpha
Commite3c78bb
Installation URL3iie3qoija2og
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@adalpari adalpari changed the title CMM-2152: Fix Views card showing two different % change values CMM-2152: Fix Views card stat accuracy and reduce its network calls Jul 14, 2026
@adalpari adalpari changed the title CMM-2152: Fix Views card stat accuracy and reduce its network calls CMM-2152: Fix Views card stat accuracy and halve its network calls Jul 15, 2026
@wpmobilebot

Copy link
Copy Markdown
Contributor

Project dependencies changes

list
! Upgraded Dependencies
rs.wordpress.api:android:1434-93b8ac510865b086e6b3588c3e816b08a65d020c, (changed from 1434-e7e17e7168b28116f3c7975ac8e20757259589cb)
rs.wordpress.api:kotlin:1434-93b8ac510865b086e6b3588c3e816b08a65d020c, (changed from 1434-e7e17e7168b28116f3c7975ac8e20757259589cb)
tree
 +--- project :libs:fluxc
-|    \--- rs.wordpress.api:android:1434-e7e17e7168b28116f3c7975ac8e20757259589cb
-|         +--- com.squareup.okhttp3:okhttp:5.4.0 (*)
-|         +--- com.squareup.okhttp3:okhttp-tls:5.4.0
-|         |    +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
-|         |    +--- com.squareup.okio:okio:3.17.0 (*)
-|         |    \--- com.squareup.okhttp3:okhttp:5.4.0 (*)
-|         +--- net.java.dev.jna:jna:5.19.1
-|         +--- rs.wordpress.api:kotlin:1434-e7e17e7168b28116f3c7975ac8e20757259589cb
-|         |    +--- com.squareup.okhttp3:okhttp:5.4.0 (*)
-|         |    +--- com.squareup.okhttp3:okhttp-tls:5.4.0 (*)
-|         |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 -> 1.11.0 (*)
-|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
-|         \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
+|    \--- rs.wordpress.api:android:1434-93b8ac510865b086e6b3588c3e816b08a65d020c
+|         +--- com.squareup.okhttp3:okhttp:5.4.0 (*)
+|         +--- com.squareup.okhttp3:okhttp-tls:5.4.0
+|         |    +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
+|         |    +--- com.squareup.okio:okio:3.17.0 (*)
+|         |    \--- com.squareup.okhttp3:okhttp:5.4.0 (*)
+|         +--- net.java.dev.jna:jna:5.19.1
+|         +--- rs.wordpress.api:kotlin:1434-93b8ac510865b086e6b3588c3e816b08a65d020c
+|         |    +--- com.squareup.okhttp3:okhttp:5.4.0 (*)
+|         |    +--- com.squareup.okhttp3:okhttp-tls:5.4.0 (*)
+|         |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 -> 1.11.0 (*)
+|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
+|         \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.21 (*)
-\--- rs.wordpress.api:android:1434-e7e17e7168b28116f3c7975ac8e20757259589cb (*)
+\--- rs.wordpress.api:android:1434-93b8ac510865b086e6b3588c3e816b08a65d020c (*)

Base automatically changed from fix/cmm-2152-newstats-visitors-dedicated-call to trunk July 16, 2026 08:13
adalpari and others added 10 commits July 16, 2026 10:17
For Last6Months/Last12Months the chart derived its previous window by
whole-month subtraction while the bottom row used an exact day-span
mirror, so the header and the bottom row showed contradictory Views %
change on the same card. Share the chart's config-based previous-window
rule so both fetches compare against identical windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dedicated bottom-stats call passed a mid-bucket startDate, so for
month/year units the API truncated the first bucket's views (additive)
while leaving visitors (unique) at the full-bucket value. The bottom
row's Views therefore under-counted and disagreed with the chart header.
Mirror the chart's fetch (unit + quantity + endDate, no startDate) so the
API returns full buckets and the totals match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hourly window ended at the next day's 00:00, so the API returned
01:00→00:00 buckets — dropping today's 00:00 hour and under-counting the
chart total versus the day-level bottom row. End the window at
"<day> 23:00:00" instead so its 24 buckets cover 00:00–23:00 of the
actual day (and yesterday for the previous window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chart's stats/visits call already returns visitors/likes/comments/posts
per bucket, so request those fields and reuse the same response to populate
the bottom row instead of a separate dedicated call — halving the card's
network calls (2 instead of 4) for every period except single-day ones.

Single-day periods (Today, single-day Custom) render an hourly chart whose
response only carries views, so they keep fetching the bottom row from a
dedicated day-level call, matching what the web app does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… de-dup

The Views card filled the bottom row from the chart response for every
multi-day period. That over-counts unique Visitors for a Custom range
beyond two years: the chart uses MONTH buckets, so a visitor active
across several months is counted once per month, whereas the bottom row
should de-duplicate per year. Views/Likes/Comments/Posts are additive
and unaffected.

Fill the bottom row from the chart only for the fixed periods
(Last7Days/Last30Days/Last6Months/Last12Months), whose chart bucket
already matches the unit the bottom row needs. Today and all Custom
ranges now fetch the bottom row from the dedicated call, which coarsens
to YEAR beyond two years so visitors are de-duplicated per year.

This also drops the now-unreachable fixed-period branches from
calculateBottomStatsRange (they no longer call it) and replaces
isSingleDayPeriod with an exhaustive fillsBottomFromChart predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… row

For a Custom range over two years the chart used MONTH buckets while the
bottom-row totals coarsen to YEAR, so the header's Views (chart) and the
bottom row's Views (dedicated call) covered different, misaligned windows
and could disagree on the same card. Pass allowYear = true for the custom
chart window too, so the chart and the bottom row share one unit (YEAR):
their Views totals — and their per-year visitor de-duplication — now always
agree. Shorter ranges (day/month) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes from review of this branch.

unitAndQuantityFor derived its quantity from ChronoUnit.MONTHS/YEARS.between,
which count whole units elapsed between the endpoints rather than the calendar
buckets the window spans. The API returns `quantity` buckets ending at the
bucket holding the end date, so whenever the end's day-of-month fell before the
start's, the window's first bucket was silently dropped: Jan 15..Mar 5 elapses
one whole month but spans three, so the chart and the bottom row both fetched
Feb+Mar and lost January. Count spanned buckets instead. Both the chart and the
bottom-row range share this function, so they kept agreeing with each other
while under-counting; they now agree on the right total.

fetchHourlyViews still applied the "endDate is exclusive for hourly queries"
workaround that a9ac20a disproved, leaving the Today's Stats card with the same
off-by-one hour the Views card just fixed: it asked for the next day at 00:00,
dropping the day's own 00:00 bucket in favour of the following day's. Route it
through formatApiEndDate so both callers model the API identically.

Finally, fetchBottomStats's KDoc still described the routing as it stood at
80551d5, before 5bd3e0e sent every Custom range to the dedicated call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nges

fillsBottomFromChart excluded every Custom range on the grounds that a range
beyond two years needs yearly visitor de-duplication the chart's monthly buckets
can't give. a4dc42f invalidated that: the custom chart window now coarsens to
YEAR past two years, so for any multi-day Custom range the chart and the bottom
row derive their unit, quantity and windows from the very same rules and issue
byte-identical requests. Those periods were paying for two calls that duplicated
the chart's own — the exact redundancy this branch set out to remove, on the one
period that never got it.

Route multi-day Custom through the chart response like the fixed periods, and
keep only the single-day periods (Today, a Custom range whose start and end are
the same day) on the dedicated call: their chart is hourly, and an hourly
response populates `views` alone.

This leaves calculateBottomStatsRange's coarser units unreachable from the card,
since only single-day windows now reach it. The span-based rule stays anyway, so
a caller that does need a standalone total for a longer window gets a correct one
rather than a silently wrong bucket; the KDoc records that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Custom range over two years renders every number on the Views card
wrong -- chart, header total, % change and bottom row alike. For the
range 2024-01-01..2026-06-29 the app already computed unit=year,
quantity=3 and date=2026-06-29, identical to the web app, but omitted
`start_date`: without it the API does not anchor its year buckets to the
requested window. YEAR windows now send their own real start, matching
web's unit=year&date=<end>&start_date=<start>&quantity=<n>.

DAY and MONTH windows still send none. They are correct from
unit + quantity + endDate, and a mid-bucket start_date there truncates
the first bucket's views (additive) while leaving visitors (a per-bucket
unique) at the full-bucket value. That asymmetry no longer splits the
header from the bottom row -- both read one response now -- but it is
still a reason not to send a start_date where nothing needs one.

Sending start_date forces a second fix: a window's quantity and its
start_date must describe the same window, and fetchStatsForPeriod sent
the *current* window's quantity on the *previous* request. The previous
window mirrors the current one's day span, which always resolves to the
same unit but not always to the same bucket count -- 2023-12-31..
2026-01-01 spans four years while its mirror spans three, so the API
returned 2020-2023 and folded an extra year into the previous total.
PeriodDateRange now carries a quantity per window, derived from that
window's own span. Only Custom ranges coarsened to MONTH or YEAR change
behaviour; every other period's two windows span equal buckets by
construction, and tests pin start_date to null on the fixed periods so
the YEAR rule cannot leak into paths that already work.

Also drops the now-dead `allowYear` parameter and refreshes the KDocs
the change falsified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Custom range over two years is charted in YEAR buckets, but its axis
labels rendered as "Jan, Jan, Jan" and its legend as "Jan - Jun".

The repository already picks StatsUnit.YEAR correctly and then dropped
the value at buildPeriodStatsSuccess, so the ViewModel re-derived
granularity from a day-count threshold that only distinguishes day from
month: anything past 31 days got "MMM". The period string could not
disambiguate either, since the API returns a full ISO date for DAY,
MONTH and YEAR buckets alike.

Carry the unit through PeriodStatsResult.Success so labels and the
legend format from the granularity actually requested rather than
guessing it a second time. This avoids duplicating the repository's
two-year threshold into the ViewModel, which is how the two sides drifted
apart to begin with. The period string still decides how to parse a
bucket, as the API returns both "yyyy-MM" and "yyyy-MM-dd" shapes.

DAY, MONTH and HOUR output is unchanged; only YEAR differs. Adds the
first coverage for chart labels, including locale-independent locks that
the year pattern cannot leak into the other units.

Drill-down still makes the same day-count guess, so tapping a year bar
opens that January. Left as a follow-up: it changes an interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@adalpari adalpari force-pushed the fix/cmm-2152-polish-views-stats-periods branch from 7a8de96 to d600564 Compare July 16, 2026 08:24
Reuse ShimmerBox for the loading placeholders, dedup the previous-window
mirror math, collapse the copy-paste bottom-stats aggregate mapping, and
drop the redundant type-check in onChartTypeChanged. Convert
formatDataPointLabel to an expression body to stay under the ReturnCount
limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.65789% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.67%. Comparing base (9ec0f3d) to head (e3c78bb).
⚠️ Report is 1 commits behind head on trunk.

Files with missing lines Patch % Lines
...roid/ui/newstats/viewsstats/ViewsStatsViewModel.kt 68.85% 5 Missing and 14 partials ⚠️
...s/android/ui/newstats/viewsstats/ViewsStatsCard.kt 0.00% 13 Missing ⚠️
.../android/ui/newstats/repository/StatsRepository.kt 93.58% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk   #23101      +/-   ##
==========================================
+ Coverage   37.63%   37.67%   +0.04%     
==========================================
  Files        2341     2341              
  Lines      127275   127315      +40     
  Branches    17648    17659      +11     
==========================================
+ Hits        47895    47962      +67     
+ Misses      75461    75432      -29     
- Partials     3919     3921       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@adalpari adalpari marked this pull request as ready for review July 16, 2026 09:01
@adalpari adalpari requested a review from nbradbury July 16, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants