Skip to content

CMM-1289: Add Subscribers tab cards for new stats screen#22634

Open
adalpari wants to merge 23 commits intotrunkfrom
feat/CMM-1289-new-stats-subscribers-tab
Open

CMM-1289: Add Subscribers tab cards for new stats screen#22634
adalpari wants to merge 23 commits intotrunkfrom
feat/CMM-1289-new-stats-subscribers-tab

Conversation

@adalpari
Copy link
Contributor

@adalpari adalpari commented Feb 26, 2026

Description

Populates the Subscribers tab in the new stats screen with 4 configurable cards. Each card supports move, remove, and add operations with per-site persistence, matching the Traffic tab pattern. Uses 3 new wordpress-rs API endpoints (stats subscribers, subscribers by user type, stats emails summary).

Cards:

  • All-time Subscribers Stats: Shows current subscriber count and counts from 30, 60, 90 days ago using 4 parallel API calls
  • Subscribers Graph: Placeholder card for future chart implementation
  • Subscribers List: Displays subscribers with subscription dates, includes detail screen via Show All
  • Emails: Shows latest emails with opens/clicks counts, includes detail screen via Show All

Testing instructions

Subscribers tab cards:

  1. Open the new stats screen on a site with subscriber data
  2. Navigate to the Subscribers tab
  • Verify all 4 cards render with data
  • Verify pull-to-refresh works

Card management:

  1. Tap the 3-dot menu on any card
  2. Use Move Up / Move Down / Move to Top / Move to Bottom
  • Verify card reordering works correctly
  1. Tap Remove Card
  • Verify card is removed from the list
  1. Tap the Add Card button at the bottom
  • Verify removed card appears in the bottom sheet
  1. Select a card from the bottom sheet
  • Verify card reappears in the list
  1. Close and reopen the stats screen
  • Verify card configuration persists

Show All detail screens:

  1. On the Subscribers List card, tap Show All
  • Verify full list of subscribers renders with names and dates
  1. On the Emails card, tap Show All
  • Verify full list of emails renders with title, opens, and clicks columns

Error and empty states:

  1. Turn on airplane mode and open the Subscribers tab
  • Verify the no-connection screen displays with a retry button
  1. Open the Subscribers tab on a site with no subscriber data
  • Verify empty states display correctly
Screen_recording_20260303_124916.mp4

adalpari and others added 4 commits February 26, 2026 16:25
Introduces the shared infrastructure for the Subscribers tab including
the wordpress-rs dependency update, data layer (3 new API endpoints),
repository methods, card configuration with persistence, and the
All-time Subscribers card showing current, 30-day, 60-day, and 90-day
subscriber counts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds an empty placeholder card for the Subscribers Graph that will be
populated with chart data in a future iteration.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Displays a list of subscribers with their subscription dates. Includes
a Show All CTA that opens a detail screen with the full subscriber list.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds the Emails card showing latest emails with opens and clicks counts,
including a detail screen. Wires all 4 cards into the Subscribers tab
with the tab ViewModel, content composable, add-card bottom sheet,
and manifest registrations.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@adalpari adalpari changed the title Add Subscribers tab cards for new stats screen CMM-1289: Add Subscribers tab cards for new stats screen Feb 26, 2026
@dangermattic
Copy link
Collaborator

dangermattic commented Feb 26, 2026

1 Error
🚫 This PR includes a PR version of wordpress-rs (1200-3a23a7389b3931e6241986a168b73a95d99151e0). Please merge the corresponding wordpress-rs PR and update to a trunk version before merging.
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
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

Project manifest changes for WordPress

The following changes in the WordPress's merged AndroidManifest.xml file were detected (build variant: wordpressVanillaRelease):

--- ./build/reports/diff_manifest/WordPress/wordpressVanillaRelease/base_manifest.txt	2026-03-05 15:05:59.513513060 +0000
+++ ./build/reports/diff_manifest/WordPress/wordpressVanillaRelease/head_manifest.txt	2026-03-05 15:06:08.103623463 +0000
@@ -203,6 +203,14 @@
         <activity
             android:name="org.wordpress.android.ui.newstats.authors.AuthorsDetailActivity"
             android:exported="false"
+            android:theme="@style/WordPress.NoActionBar" />
+        <activity
+            android:name="org.wordpress.android.ui.newstats.subscribers.subscriberslist.SubscribersListDetailActivity"
+            android:exported="false"
+            android:theme="@style/WordPress.NoActionBar" />
+        <activity
+            android:name="org.wordpress.android.ui.newstats.subscribers.emails.EmailsDetailActivity"
+            android:exported="false"
             android:theme="@style/WordPress.NoActionBar" /> <!-- Account activities -->
         <activity
             android:name="org.wordpress.android.ui.main.MeActivity"

Go to https://buildkite.com/automattic/wordpress-android/builds/25318/canvas?sid=019cbe83-e30f-49c1-9026-4dc204d013f6, click on the Artifacts tab and audit the files.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

Project manifest changes for WordPress

The following changes in the WordPress's merged AndroidManifest.xml file were detected (build variant: jetpackVanillaRelease):

--- ./build/reports/diff_manifest/WordPress/jetpackVanillaRelease/base_manifest.txt	2026-03-05 15:06:36.759565352 +0000
+++ ./build/reports/diff_manifest/WordPress/jetpackVanillaRelease/head_manifest.txt	2026-03-05 15:06:47.649638969 +0000
@@ -397,6 +397,14 @@
         <activity
             android:name="org.wordpress.android.ui.newstats.authors.AuthorsDetailActivity"
             android:exported="false"
+            android:theme="@style/WordPress.NoActionBar" />
+        <activity
+            android:name="org.wordpress.android.ui.newstats.subscribers.subscriberslist.SubscribersListDetailActivity"
+            android:exported="false"
+            android:theme="@style/WordPress.NoActionBar" />
+        <activity
+            android:name="org.wordpress.android.ui.newstats.subscribers.emails.EmailsDetailActivity"
+            android:exported="false"
             android:theme="@style/WordPress.NoActionBar" /> <!-- Account activities -->
         <activity
             android:name="org.wordpress.android.ui.main.MeActivity"

Go to https://buildkite.com/automattic/wordpress-android/builds/25318/canvas?sid=019cbe83-e310-4cdb-ba7a-1cffbe78c2b7, click on the Artifacts tab and audit the files.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

Project dependencies changes

list
! Upgraded Dependencies
rs.wordpress.api:android:1200-3a23a7389b3931e6241986a168b73a95d99151e0, (changed from trunk-a557060af03d5ed2eb41a21afd51f434fe864e20)
rs.wordpress.api:kotlin:1200-3a23a7389b3931e6241986a168b73a95d99151e0, (changed from trunk-a557060af03d5ed2eb41a21afd51f434fe864e20)
tree
 +--- project :libs:fluxc
-|    \--- rs.wordpress.api:android:trunk-a557060af03d5ed2eb41a21afd51f434fe864e20
-|         +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
-|         +--- com.squareup.okhttp3:okhttp-tls:5.3.2
-|         |    +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
-|         |    +--- com.squareup.okio:okio:3.16.4 (*)
-|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.2.21 -> 2.3.10 (*)
-|         +--- net.java.dev.jna:jna:5.18.1
-|         +--- rs.wordpress.api:kotlin:trunk-a557060af03d5ed2eb41a21afd51f434fe864e20
-|         |    +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
-|         |    +--- com.squareup.okhttp3:okhttp-tls:5.3.2 (*)
-|         |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 (*)
-|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.10 (*)
-|         \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.10 (*)
+|    \--- rs.wordpress.api:android:1200-3a23a7389b3931e6241986a168b73a95d99151e0
+|         +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
+|         +--- com.squareup.okhttp3:okhttp-tls:5.3.2
+|         |    +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
+|         |    +--- com.squareup.okio:okio:3.16.4 (*)
+|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.2.21 -> 2.3.10 (*)
+|         +--- net.java.dev.jna:jna:5.18.1
+|         +--- rs.wordpress.api:kotlin:1200-3a23a7389b3931e6241986a168b73a95d99151e0
+|         |    +--- com.squareup.okhttp3:okhttp:5.3.2 (*)
+|         |    +--- com.squareup.okhttp3:okhttp-tls:5.3.2 (*)
+|         |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 (*)
+|         |    \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.10 (*)
+|         \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.21 -> 2.3.10 (*)
-\--- rs.wordpress.api:android:trunk-a557060af03d5ed2eb41a21afd51f434fe864e20 (*)
+\--- rs.wordpress.api:android:1200-3a23a7389b3931e6241986a168b73a95d99151e0 (*)

Adds 5 test files covering the new Subscribers tab functionality:
- AllTimeSubscribersViewModelTest (12 tests)
- SubscribersListViewModelTest (13 tests)
- EmailsCardViewModelTest (13 tests)
- StatsRepositorySubscribersTest (12 tests)
- SubscribersCardsConfigurationRepositoryTest (14 tests)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@wpmobilebot
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

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
FlavorJalapeno
Build TypeDebug
Versionpr22634-9e19b42
Build Number1485
Application IDorg.wordpress.android.prealpha
Commit9e19b42
Installation URL7287jvm19u850
Note: Google Login is not supported on these builds.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

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
FlavorJalapeno
Build TypeDebug
Versionpr22634-9e19b42
Build Number1485
Application IDcom.jetpack.android.prealpha
Commit9e19b42
Installation URL785ble9rpe3ro
Note: Google Login is not supported on these builds.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Feb 26, 2026

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

adalpari and others added 9 commits February 26, 2026 20:58
- SubscribersTabViewModelTest (21 tests): config loading, card
  management (add/remove/move), flow observation, network status
- SubscribersGraphViewModelTest (6 tests): placeholder state behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Redesign card with highlighted Current sub-card and 3 smaller
  sub-cards for 30/60/90 days ago
- Fix subscribers API: use DAY unit instead of MONTH
- Pass correct date parameter (today, -30d, -60d, -90d) for each call
- Hide period selector when Subscribers tab is selected
- Update tests for new date parameter

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Center-align opens/clicks columns in headers and rows
- Add divider between column headers and items
- Show "-" instead of "0" for zero opens/clicks values
- Use lighter color for zero values to de-emphasize
- Add proper spacing between title and numeric columns
- Remove "Top N" truncation label from detail screen

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace the placeholder graph card with a Vico line chart showing
subscriber counts over time. Add segmented button tabs for switching
between Days (30d), Weeks (12w), Months (6m), and Years (3y) periods.
Data is sorted chronologically (older to newer).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add 10 new ViewModel tests covering tab switching params, chronological
sorting, empty data, auth errors, and edge cases. Add 4 repository tests
for fetchSubscribersGraph covering success, empty, error, and auth error.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update wordpress-rs to b17d6e02dde5ea55773d527c1cb6ad2f889fc90e, handle
nullable dateSubscribed, format subscriber dates as relative time
(e.g. "30 days", "1 year, 45 days") instead of raw date strings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Extract BaseSubscribersCardViewModel to eliminate ViewModel duplication
- Replace hardcoded error strings with string resources
- Use Android plural resources for formatSubscriberDate localization
- Fix refresh() to call statsRepository.init() before fetching
- Use AtomicBoolean for thread-safe isLoading/isLoadedSuccessfully flags
- Lazy load detail items (fetch 5 for card, 100 on demand)
- Prevent cardsToLoad from re-triggering on every config change
- Extract shared formatEmailStat to StatsFormatter utility
- Add FormatSubscriberDateTest with 12 tests covering all branches
- Update existing ViewModel tests for base class refactor

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Detail pages now fetch data via their own ViewModels with infinite scroll
instead of receiving all items through Intent extras. Subscribers uses
page-based pagination; Emails uses increasing quantity. Both use mutex-
guarded loading with scroll-to-end detection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Bug #1: Add error state with retry to detail Activities (blank screen on error)
- Bug #2: Cancel previous load job on tab switch to prevent race conditions
- Bug #3: Reset isLoadedSuccessfully in refresh() to allow data reload
- Bug #4: Add duplicate guard in addCard() to prevent duplicate cards
- Bug #9: Use resource string instead of raw e.message for error display
- Thread safety #7: Use AtomicBoolean for isInitialLoad in SubscribersTabViewModel
- Performance #11: Pre-compute formatted dates in ViewModel, memoize in card composable
- Update all tests to match new error handling and API signatures

Co-Authored-By: Claude Opus 4.6 <[email protected]>
adalpari and others added 2 commits March 3, 2026 12:37
- Replace Resources with ContextProvider in SubscribersListDetailViewModel
  to avoid passing Activity resources into ViewModel (MVVM violation)
- Fix refresh() race condition by canceling loadJob in BaseSubscribersCardViewModel
- Add Mutex synchronization to SubscribersCardsConfigurationRepository
- Fix isValidConfiguration no-op (filterIsInstance on typed list)
- Expose PAGE_SIZE constants for test use instead of duplicating
- Replace SimpleDateFormat with DateTimeFormatter in StatsDataSourceImpl
- Log exceptions in fetchSubscribersGraph instead of swallowing silently
- Use Period.between() for leap-year-accurate date formatting
- Remove duplicate selectedTab from SubscribersGraphUiState.Loaded
- Auto-load newly added cards in SubscribersTabContent
- Add statsRepository.init() verification tests to all card ViewModels

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@adalpari
Copy link
Contributor Author

adalpari commented Mar 3, 2026

@claude

@claude
Copy link
Contributor

claude bot commented Mar 3, 2026

Claude finished @adalpari's task in 3m 13s —— View job


Code Review

The PR implements a well-structured Subscribers tab with 4 configurable cards, closely following the existing Traffic tab pattern. The architecture is clean, the test coverage is solid, and card management (add/remove/reorder/persist) is correctly implemented. Here are the issues I found, from most to least critical:


🔴 Critical Issues

1. Race condition in StatsDataSourceImpl.getOrCreateClient()

StatsDataSourceImpl.kt:67-73

private fun getOrCreateClient(): WpComApiClient {
    val token = accessToken
    check(token != null) { "DataSource not initialized" }
    return wpComApiClient
        ?: wpComApiClientProvider.getWpComApiClient(token)
            .also { wpComApiClient = it }
}

The fields are @Volatile (guaranteeing visibility) but getOrCreateClient() is not synchronized. Two coroutines can simultaneously observe wpComApiClient == null and each create a new client — a classic check-then-act race. The AllTimeSubscribersViewModel makes 4 parallel API calls, making this very likely to trigger. Consider using a @Synchronized annotation, a Mutex, or the getOrSet/compareAndSet pattern:

@Synchronized
private fun getOrCreateClient(): WpComApiClient { ... }

Fix this →


2. Security alert: LocalContext.current.resources captured inside remember block

SubscribersListCard.kt:233-240

val resources = LocalContext.current.resources
val formattedDate = remember(item.subscribedSince) {
    formatSubscriberDate(item.subscribedSince, resources)
}

This is flagged by GitHub Advanced Security. resources is captured by value inside remember, so locale changes won't trigger recomposition — the formatted date becomes stale after a language change. The recommended pattern is to move formatting to the ViewModel (which already happens in SubscribersListDetailViewModel using ContextProvider). In the card's case, the SubscribersListViewModel should pre-format the date and expose a formattedDate field on the SubscriberListItem, matching the detail screen's approach.

Fix this →


🟡 Medium Issues

3. Missing unit tests for BaseSubscribersCardViewModel

Danger bot flagged this explicitly. The base class contains significant shared logic — isLoading race prevention via AtomicBoolean, the loadDataIfNeeded() guard, the refresh() flow with isRefreshing, and the exception handling in fetchData(). While subclass tests exercise these paths indirectly, they don't test the critical interaction where loadDataIfNeeded() is called concurrently or where loadData() is called directly while a loadDataIfNeeded() load is in flight.

A concrete gap: there's no test verifying that calling loadData() directly (bypassing compareAndSet) doesn't break the loadDataIfNeeded() idempotency guard on the next call.

4. Emails pagination re-fetches all data on each "load more"

EmailsDetailViewModel.kt:65-79

fun loadMore() {
    currentQuantity += EMAILS_DETAIL_PAGE_SIZE
    fetchEmails(currentQuantity, isInitial = false)  // fetches ALL items from page 1
}

Unlike SubscribersListDetailViewModel which uses page-based pagination (appending new items), EmailsDetailViewModel re-fetches all emails with an increasing quantity parameter, then replaces the entire list. This is O(n) network traffic for each page load. If the emails API only supports a quantity parameter (not page/offset), this should at least be documented as a known limitation. If it supports cursor/page params, migrate to true pagination.

5. saveConfiguration is public but lacks mutex protection

SubscribersCardsConfigurationRepository.kt:48-58

All write operations (removeCard, addCard, moveCard*) correctly acquire the mutex before calling saveConfiguration, but saveConfiguration itself is public. A caller that bypasses the mutex could corrupt the configuration. Making it private would enforce the invariant without any API break (it's not used externally).

Fix this →


🟢 Observations / Minor

6. SubscribersListDetailViewModel uses ContextProvider for resource access

SubscribersListDetailViewModel.kt:92-93

val resources = contextProvider.getContext().resources

Using ContextProvider in a ViewModel creates an implicit Android context dependency that can complicate testing. The existing ResourceProvider (already used by BaseSubscribersCardViewModel) is the established project pattern for this. Consider switching to ResourceProvider for consistency, though this is a style preference.

7. loadVisibleCards lambda recreated on each recomposition

SubscribersTabContent.kt:173-184

val loadVisibleCards = {
    visibleCards.dispatchToVisibleCards(...)
}

This lambda captures visibleCards and ViewModel references and is recreated on every recomposition. Using rememberUpdatedState + a remember'd lambda would be more efficient, though given the low frequency of recompositions in this content, the practical impact is minimal.

8. SubscribersTabViewModel.siteId computed in flow collection

SubscribersTabViewModel.kt:78

val currentSiteId = siteId  // called inside flow collect

The siteId getter reads from the repository inside the configuration flow collector. In theory, the site could change between when the configuration update was emitted and when the check executes. This is an unlikely edge case but could result in a missed configuration update. Using loadConfiguration() to capture siteId once at init and comparing against that would be safer.


Summary

The implementation is solid and follows established patterns well. The three items that need attention before merging are: (1) the getOrCreateClient() race condition which is very likely to manifest given the parallel API calls, (2) the security-flagged LocalContext.current.resources in the remember block, and (3) the missing BaseSubscribersCardViewModel tests (required by Danger). The emails pagination re-fetching is a performance concern worth addressing or documenting.

adalpari and others added 2 commits March 3, 2026 13:25
- Add @synchronized to StatsDataSourceImpl.getOrCreateClient()
  to prevent race condition during parallel API calls
- Move date formatting from SubscribersListCard composable to
  SubscribersListViewModel to avoid stale resources capture
- Make saveConfiguration private in ConfigurationRepository
- Add comment documenting emails pagination API limitation
- Add BaseSubscribersCardViewModelTest with 12 tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Rethrow CancellationException in all catch blocks to preserve
  structured concurrency (BaseSubscribersCardViewModel, detail VMs,
  StatsRepository)
- Use safe cast in extractSubscriberCount to prevent runtime crash
- Add try-catch to fetchSubscribersList and fetchEmailsSummary
- Add mutex to getConfiguration() to fix TOCTOU race
- Fix refresh() bypassing isLoading guard
- Remove emails pagination (API limitation) and use static list
- Fix premature loadMore trigger by initializing canLoadMore to false
- Add !isLoading guard to shouldLoadMore derived state

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@adalpari
Copy link
Contributor Author

adalpari commented Mar 3, 2026

Achieved some Claude findungs and other improvements here and here

@adalpari adalpari marked this pull request as ready for review March 3, 2026 13:23
@adalpari adalpari requested a review from a team as a code owner March 3, 2026 13:23
@adalpari adalpari requested review from nbradbury and removed request for a team March 3, 2026 13:23
@nbradbury
Copy link
Contributor

@adalpari This is looking good and tested well. I did ask Claude to look for simplifications and I've attached its output.

stats-subscribers-simplification-plan.md

- Extract NoConnectionContent into shared composable used by both NewStatsActivity and SubscribersTabContent
- Consolidate 4 card movement methods in SubscribersCardsConfigurationRepository via moveCard() helper
- Consolidate 6 card action methods in SubscribersTabViewModel via cardAction() helper
- Extract CardActions data class in SubscribersTabContent to reduce callback repetition

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@adalpari
Copy link
Contributor Author

adalpari commented Mar 3, 2026

@adalpari This is looking good and tested well. I did ask Claude to look for simplifications and I've attached its output.

stats-subscribers-simplification-plan.md

Thank you for the suggestion. I've applied it here

Copy link
Contributor

@nbradbury nbradbury left a comment

Choose a reason for hiding this comment

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

This looks good to me! Feel free to merge once you can switch to a trunk version of wp-rs :shipit:

adalpari and others added 2 commits March 5, 2026 15:54
…ats-subscribers-tab

# Conflicts:
#	gradle/libs.versions.toml
- Update wordpress-rs library to commit 3a23a7389b3931e6241986a168b73a95d99151e0
- Adapt StatsEmailsSummarySortOrder -> WpApiParamOrder rename

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 5, 2026

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.

4 participants