Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0591fb4
Add Latest Post Summary card and per-post stats to New Stats
nbradbury Aug 6, 2026
049a8d2
Open Posts & Pages rows in the new post stats screen
nbradbury Aug 6, 2026
197952f
Bring the post stats screen closer to the old one
nbradbury Aug 6, 2026
fe56b09
Remove the now-unused old-stats post detail navigation
nbradbury Aug 6, 2026
77f6838
Clean up the post stats screen
nbradbury Aug 6, 2026
c72c811
Show the featured image on the Latest Post Summary card
nbradbury Aug 6, 2026
c66a94c
Build against the wordpress-rs PR instead of a local checkout
nbradbury Aug 6, 2026
0289d19
Drop the local wordpress-rs composite build hooks
nbradbury Aug 6, 2026
55d2f54
Remove a duplicate formatter and the now-unused postType
nbradbury Aug 6, 2026
e56b77b
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 10, 2026
952209d
Build against wordpress-rs trunk now that the stats PRs have landed
nbradbury Aug 10, 2026
92b4608
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 11, 2026
f0dfa2a
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 17, 2026
5bd0680
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 19, 2026
d88e1d3
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 20, 2026
c90e3ff
Test the post stats day selection and the latest-post use case
nbradbury Aug 20, 2026
c4187e1
Move the repeated stubbing out of the latest-post use case tests
nbradbury Aug 20, 2026
e61c076
Guard the missing post id, and track the Latest Post card tap
nbradbury Aug 20, 2026
a82a71b
Pull the error state out of loadData
nbradbury Aug 20, 2026
4cee06b
Merge remote-tracking branch 'origin/trunk' into newstats/latest-post…
nbradbury Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions WordPress/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@
android:theme="@style/WordPress.NoActionBar"
android:exported="false" />

<activity
android:name=".ui.newstats.poststats.PostStatsDetailActivity"
android:theme="@style/WordPress.NoActionBar"
android:exported="false" />

<activity
android:name=".ui.newstats.subscribers.subscriberslist.SubscribersListDetailActivity"
android:theme="@style/WordPress.NoActionBar"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,12 @@ import org.wordpress.android.ui.domains.management.purchasedomain.PurchaseDomain
import org.wordpress.android.ui.main.WPMainActivity
import org.wordpress.android.ui.media.MediaBrowserActivity
import org.wordpress.android.ui.media.MediaBrowserType
import org.wordpress.android.ui.mysite.SelectedSiteRepository
import org.wordpress.android.ui.mysite.menu.MenuActivity
import org.wordpress.android.ui.mysite.personalization.PersonalizationActivity
import org.wordpress.android.ui.newstats.NewStatsActivity
import org.wordpress.android.ui.newstats.NewStatsRouting
import org.wordpress.android.ui.newstats.toNewStatsTarget
import org.wordpress.android.ui.stats.StatsConstants
import org.wordpress.android.ui.stats.StatsTimeframe
import org.wordpress.android.ui.stats.refresh.lists.detail.StatsDetailActivity
import org.wordpress.android.ui.stats.refresh.utils.StatsLaunchedFrom
import org.wordpress.android.ui.sitemonitor.SiteMonitorParentActivity
import org.wordpress.android.ui.sitemonitor.SiteMonitorType
Expand All @@ -50,7 +47,6 @@ import javax.inject.Singleton

@Singleton
class ActivityNavigator @Inject constructor(
private val selectedSiteRepository: SelectedSiteRepository,
private val newStatsRouting: NewStatsRouting
) {
/**
Expand Down Expand Up @@ -317,26 +313,6 @@ class ActivityNavigator @Inject constructor(
.build()
}

/**
* Opens the per-post stats detail screen for a Posts & Pages item tapped in the new stats.
* Shows an error toast when no site is selected.
*/
fun openPostDetailStats(context: Context, postId: Long, postType: String?, postTitle: String, postUrl: String?) {
val site = selectedSiteRepository.getSelectedSite() ?: run {
ToastUtils.showToast(context, R.string.blog_not_found, ToastUtils.Duration.SHORT)
return
}
AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED)
StatsDetailActivity.start(
context = context,
site = site,
postId = postId,
postType = statsDetailItemType(postType),
postTitle = postTitle,
postUrl = postUrl
)
}

fun navigateToApplicationPasswordReauthentication(activity: Activity, authenticationUrl: String) {
val intent = Intent(activity, ApplicationPasswordReauthenticateDialogActivity::class.java)
intent.putExtra(ApplicationPasswordDialogActivity.EXTRA_SITE_URL, authenticationUrl)
Expand All @@ -349,12 +325,3 @@ class ActivityNavigator @Inject constructor(
)
}
}

/**
* Maps the API post type of a Posts & Pages item to the item type expected by the post detail
* stats screen, mirroring the old stats mapping (pages and the homepage share one detail layout).
*/
internal fun statsDetailItemType(postType: String?): String = when (postType) {
StatsConstants.ITEM_TYPE_POST, StatsConstants.ITEM_TYPE_ATTACHMENT -> postType
else -> StatsConstants.ITEM_TYPE_HOME_PAGE
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ enum class InsightsCardType(
ALL_TIME_STATS(
R.string.stats_insights_all_time_stats_title
),
LATEST_POST(
R.string.stats_insights_latest_post_summary
),
MOST_POPULAR_DAY(
R.string.stats_insights_most_popular_day
),
Expand All @@ -27,6 +30,7 @@ enum class InsightsCardType(
listOf(
YEAR_IN_REVIEW,
ALL_TIME_STATS,
LATEST_POST,
MOST_POPULAR_DAY,
MOST_POPULAR_TIME,
TAGS_AND_CATEGORIES
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,11 @@ class InsightsViewModel @Inject constructor(
}

companion object {
// TAGS_AND_CATEGORIES is intentionally absent
// from both checks: it has its own dedicated
// fetch path via StatsTagsUseCase in
// TagsAndCategoriesViewModel.
// TAGS_AND_CATEGORIES and LATEST_POST are
// intentionally absent from both checks: each
// fetches on its own, from
// TagsAndCategoriesViewModel and
// LatestPostViewModel respectively.
private fun List<InsightsCardType>.needsSummary():
Boolean = any {
it == InsightsCardType.ALL_TIME_STATS ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import org.wordpress.android.R
import org.wordpress.android.WordPress
import org.wordpress.android.fluxc.store.SiteStore
import org.wordpress.android.ui.ActivityLauncher
import org.wordpress.android.ui.PagePostCreationSourcesDetail
import org.wordpress.android.ui.ActivityNavigator
import org.wordpress.android.ui.compose.components.FeedbackDialog
import org.wordpress.android.ui.compose.theme.AppThemeM3
Expand Down Expand Up @@ -114,8 +115,11 @@ import org.wordpress.android.ui.newstats.subscribers.SubscribersTabContent
import android.widget.Toast
import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard
import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel
import org.wordpress.android.ui.newstats.latestpost.LatestPostCard
import org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel
import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard
import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel
import org.wordpress.android.ui.newstats.poststats.PostStatsDetailActivity
import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeCard
import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeViewModel
import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard
Expand Down Expand Up @@ -213,7 +217,9 @@ class NewStatsActivity : BaseAppCompatActivity() {
onStatsUrlClick = { url ->
activityNavigator.openInCustomTab(this, url)
},
onPostItemClick = ::openPostDetailStats
onPostItemClick = ::openPostDetailStats,
onLatestPostClick = ::openLatestPostStats,
onCreatePostClick = ::createNewPost
)
}
}
Expand Down Expand Up @@ -243,7 +249,28 @@ class NewStatsActivity : BaseAppCompatActivity() {
}

private fun openPostDetailStats(item: MostViewedItem) {
activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url)
analyticsTracker.track(Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED)
PostStatsDetailActivity.start(this, item.id, item.title)
}

private fun openLatestPostStats(postId: Long, title: String) {
analyticsTracker.track(
Stat.STATS_LATEST_POST_SUMMARY_VIEW_POST_DETAILS_TAPPED
)
PostStatsDetailActivity.start(this, postId, title)
}

private fun createNewPost() {
selectedSiteRepository.getSelectedSite()?.let { site ->
ActivityLauncher.addNewPostForResult(
this,
site,
false,
PagePostCreationSourcesDetail.POST_FROM_STATS,
-1,
null
)
}
}

/**
Expand Down Expand Up @@ -365,7 +392,9 @@ private fun NewStatsScreen(
showIntroBottomSheet: Boolean = false,
onIntroDismissed: () -> Unit = {},
onStatsUrlClick: (String) -> Unit = {},
onPostItemClick: (MostViewedItem) -> Unit = {}
onPostItemClick: (MostViewedItem) -> Unit = {},
onLatestPostClick: (Long, String) -> Unit = { _, _ -> },
onCreatePostClick: () -> Unit = {}
) {
val viewsStatsViewModel: ViewsStatsViewModel = viewModel()
val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState()
Expand Down Expand Up @@ -547,7 +576,9 @@ private fun NewStatsScreen(
tab = tabs[page],
viewsStatsViewModel = viewsStatsViewModel,
onStatsUrlClick = onStatsUrlClick,
onPostItemClick = onPostItemClick
onPostItemClick = onPostItemClick,
onLatestPostClick = onLatestPostClick,
onCreatePostClick = onCreatePostClick
)
}
}
Expand All @@ -559,7 +590,9 @@ private fun StatsTabContent(
tab: StatsTab,
viewsStatsViewModel: ViewsStatsViewModel,
onStatsUrlClick: (String) -> Unit = {},
onPostItemClick: (MostViewedItem) -> Unit = {}
onPostItemClick: (MostViewedItem) -> Unit = {},
onLatestPostClick: (Long, String) -> Unit = { _, _ -> },
onCreatePostClick: () -> Unit = {}
) {
when (tab) {
StatsTab.TRAFFIC -> TrafficTabContent(
Expand All @@ -568,7 +601,9 @@ private fun StatsTabContent(
onPostItemClick = onPostItemClick
)
StatsTab.INSIGHTS -> InsightsTabContent(
onStatsUrlClick = onStatsUrlClick
onStatsUrlClick = onStatsUrlClick,
onLatestPostClick = onLatestPostClick,
onCreatePostClick = onCreatePostClick
)
StatsTab.SUBSCRIBERS -> SubscribersTabContent()
}
Expand Down Expand Up @@ -1221,15 +1256,19 @@ private fun InsightsTabContent(
mostPopularDayViewModel: MostPopularDayViewModel = viewModel(),
mostPopularTimeViewModel: MostPopularTimeViewModel = viewModel(),
tagsAndCategoriesViewModel: TagsAndCategoriesViewModel = viewModel(),
latestPostViewModel: LatestPostViewModel = viewModel(),
insightsViewModel: InsightsViewModel = viewModel(),
onStatsUrlClick: (String) -> Unit = {}
onStatsUrlClick: (String) -> Unit = {},
onLatestPostClick: (Long, String) -> Unit = { _, _ -> },
onCreatePostClick: () -> Unit = {}
) {
val context = LocalContext.current
val yearInReviewUiState by yearInReviewViewModel.uiState.collectAsState()
val allTimeStatsUiState by allTimeStatsViewModel.uiState.collectAsState()
val mostPopularDayUiState by mostPopularDayViewModel.uiState.collectAsState()
val mostPopularTimeUiState by mostPopularTimeViewModel.uiState.collectAsState()
val tagsAndCategoriesUiState by tagsAndCategoriesViewModel.uiState.collectAsState()
val latestPostUiState by latestPostViewModel.uiState.collectAsState()
val isRefreshing by insightsViewModel.isDataRefreshing.collectAsState()
val pullToRefreshState = rememberPullToRefreshState()

Expand All @@ -1245,6 +1284,9 @@ private fun InsightsTabContent(
if (InsightsCardType.TAGS_AND_CATEGORIES in cardsToLoad) {
tagsAndCategoriesViewModel.loadData()
}
if (InsightsCardType.LATEST_POST in cardsToLoad) {
latestPostViewModel.loadData()
}
}

val onRetryData = remember { { insightsViewModel.fetchData() } }
Expand Down Expand Up @@ -1318,6 +1360,11 @@ private fun InsightsTabContent(
) {
tagsAndCategoriesViewModel.refresh()
}
if (InsightsCardType.LATEST_POST
in visibleCards
) {
latestPostViewModel.refresh()
}
},
indicator = {
PullToRefreshDefaults.Indicator(
Expand Down Expand Up @@ -1369,6 +1416,18 @@ private fun InsightsTabContent(
onMoveDown = { insightsViewModel.moveCardDown(cardType) },
onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) }
)
InsightsCardType.LATEST_POST -> LatestPostCard(
uiState = latestPostUiState,
onRemoveCard = { insightsViewModel.removeCard(cardType) },
onRetry = { latestPostViewModel.refresh() },
onPostClick = onLatestPostClick,
onCreatePostClick = onCreatePostClick,
cardPosition = pos,
onMoveUp = { insightsViewModel.moveCardUp(cardType) },
onMoveToTop = { insightsViewModel.moveCardToTop(cardType) },
onMoveDown = { insightsViewModel.moveCardDown(cardType) },
onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) }
)
InsightsCardType.MOST_POPULAR_DAY -> MostPopularDayCard(
uiState = mostPopularDayUiState,
onRemoveCard = { insightsViewModel.removeCard(cardType) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ object StatsColors {
val ChangeBadgePositive = Color(0xFF2E7D32)
val ChangeBadgeNegative = Color(0xFFE91E63)

/**
* The selected bar in a day-views chart. Shares the negative badge's hue, but it marks the
* user's selection, not a decline -- keep them separate so either can move independently.
*/
val ChartSelectedBar = Color(0xFFE91E63)

// Per-metric chart accent colors, used when a metric is selected as the charted series so the
// chart recolours per selection (matching iOS's SiteMetric.primaryColor). Views intentionally
// has no color here: it falls back to the theme primary for visual continuity with the previous
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.wordpress.android.ui.newstats.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

// Zero-value entries still get a sliver of a bar so the series reads as continuous.
private const val MIN_BAR_FRACTION = 0.02f

/**
* A compact bar chart of a single series, scaled against its own largest value. Renders nothing
* when every value is zero.
*/
@Composable
fun StatsBarChart(
values: List<Long>,
height: Dp,
barSpacing: Dp,
modifier: Modifier = Modifier
) {
val maxValue = values.maxOrNull() ?: 0L
if (maxValue <= 0L) return

val barColor = MaterialTheme.colorScheme.primary

Row(
modifier = modifier
.fillMaxWidth()
.height(height),
horizontalArrangement = Arrangement.spacedBy(barSpacing),
verticalAlignment = Alignment.Bottom
) {
values.forEach { value ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
val fraction = (
value.toFloat() / maxValue.toFloat()
).coerceIn(MIN_BAR_FRACTION, 1f)
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction)
.clip(
RoundedCornerShape(
topStart = 2.dp,
topEnd = 2.dp
)
)
.background(barColor)
)
}
}
}
}
Loading
Loading