Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
defe292
Add UTM stats card to new stats Traffic tab (CMM-1990)
adalpari Apr 7, 2026
bf11874
Update wordpress-rs and implement real UTM API call (CMM-1990)
adalpari Apr 7, 2026
5654768
Always show "Show all" footer on UTM card (CMM-1990)
adalpari Apr 7, 2026
15702a3
Fix duplicate API calls on retry and initial load in stats cards
adalpari Apr 7, 2026
ed673ee
Format UTM names and add expandable rows with top posts (CMM-1990)
adalpari Apr 7, 2026
9d53097
Update wordpress-rs library version (CMM-1990)
adalpari Apr 7, 2026
c204940
Remove change percentage from UTM card and detail rows (CMM-1990)
adalpari Apr 7, 2026
b5a175d
Add expandable rows with top posts to UTM detail screen (CMM-1990)
adalpari Apr 7, 2026
2edec8a
Clean up dead code, fix per-category loading, dynamic headers, and ad…
adalpari Apr 7, 2026
4822afd
Use ConcurrentHashMap for thread safety and re-fetch data in UTM deta…
adalpari Apr 7, 2026
eca40c0
Revert loadingPeriod lifecycle changes and fix StatsCardsConfiguratio…
adalpari Apr 7, 2026
e61161e
Fix detekt issues in UTM stats code (CMM-1990)
adalpari Apr 7, 2026
2b4a70c
Populate loadingPeriods to fix in-flight dedup guard (CMM-1990)
adalpari Apr 7, 2026
67de676
Fix code quality issues in UTM stats code (CMM-1990)
adalpari Apr 7, 2026
3a631e5
Deduplicate UTM composables, fix detail error state, and fix period-c…
adalpari Apr 7, 2026
cf088fb
Remove period comparison from UTM and add bars to expanded post rows …
adalpari Apr 7, 2026
56c10d3
Merge branch 'trunk' into feat/CMM-1990-stats-add-UTM
adalpari Apr 7, 2026
9082a11
Add UTM to default stats cards list (CMM-1990)
adalpari Apr 8, 2026
40fb450
Replace UTM category button with dropdown arrow selector (CMM-1990)
adalpari Apr 8, 2026
0b92261
Update wordpress-rs library version (CMM-1990)
adalpari Apr 9, 2026
3ffe93c
Fix code quality issues and wordpress-rs API compatibility (CMM-1990)
adalpari Apr 9, 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 @@ -127,6 +127,11 @@
android:theme="@style/WordPress.NoActionBar"
android:exported="false" />

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

<activity
android:name=".ui.newstats.yearinreview.YearInReviewDetailActivity"
android:theme="@style/WordPress.NoActionBar"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider
import org.wordpress.android.fluxc.utils.AppLogWrapper
import org.wordpress.android.util.AppLog
import rs.wordpress.api.kotlin.WpRequestResult
import uniffi.wp_api.UserCapability
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -47,9 +48,8 @@ class SiteCapabilityChecker @Inject constructor(
}
when (response) {
is WpRequestResult.Success -> {
response.response.data.capabilities.entries.any { (key, value) ->
key.toString().equals("edit_theme_options", ignoreCase = true) && value
}
response.response.data.capabilities
.hasCap(UserCapability.EditThemeOptions)
}
else -> false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ import org.wordpress.android.ui.newstats.clicks.ClicksViewModel
import org.wordpress.android.ui.newstats.devices.DevicesCard
import org.wordpress.android.ui.newstats.devices.DevicesCardUiState
import org.wordpress.android.ui.newstats.devices.DevicesViewModel
import org.wordpress.android.ui.newstats.utm.UtmCard
import org.wordpress.android.ui.newstats.utm.UtmCardUiState
import org.wordpress.android.ui.newstats.utm.UtmDetailActivity
import org.wordpress.android.ui.newstats.utm.UtmViewModel
import org.wordpress.android.ui.newstats.filedownloads.FileDownloadsViewModel
import org.wordpress.android.ui.newstats.locations.LocationsCardUiState
import org.wordpress.android.ui.newstats.searchterms.SearchTermsViewModel
Expand Down Expand Up @@ -396,6 +400,7 @@ private fun TrafficTabContent(
videoPlaysViewModel: VideoPlaysViewModel = viewModel(),
fileDownloadsViewModel: FileDownloadsViewModel = viewModel(),
devicesViewModel: DevicesViewModel = viewModel(),
utmViewModel: UtmViewModel = viewModel(),
newStatsViewModel: NewStatsViewModel = viewModel()
) {
val context = LocalContext.current
Expand All @@ -412,6 +417,8 @@ private fun TrafficTabContent(
val fileDownloadsUiState by fileDownloadsViewModel.uiState.collectAsState()
val devicesUiState by devicesViewModel.uiState.collectAsState()
val selectedDeviceType by devicesViewModel.selectedDeviceType.collectAsState()
val utmUiState by utmViewModel.uiState.collectAsState()
val selectedUtmCategory by utmViewModel.selectedCategory.collectAsState()
val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState()
val isTodaysStatsRefreshing by todaysStatsViewModel.isRefreshing.collectAsState()
val isViewsStatsRefreshing by viewsStatsViewModel.isRefreshing.collectAsState()
Expand All @@ -429,13 +436,14 @@ private fun TrafficTabContent(
val isFileDownloadsRefreshing by fileDownloadsViewModel
.isRefreshing.collectAsState()
val isDevicesRefreshing by devicesViewModel.isRefreshing.collectAsState()
val isUtmRefreshing by utmViewModel.isRefreshing.collectAsState()
val isRefreshing = listOf(
isTodaysStatsRefreshing, isViewsStatsRefreshing,
isMostViewedPostsRefreshing, isMostViewedReferrersRefreshing,
isLocationsRefreshing, isAuthorsRefreshing,
isClicksRefreshing, isSearchTermsRefreshing,
isVideoPlaysRefreshing, isFileDownloadsRefreshing,
isDevicesRefreshing
isDevicesRefreshing, isUtmRefreshing
).any { it }
val pullToRefreshState = rememberPullToRefreshState()

Expand Down Expand Up @@ -486,6 +494,9 @@ private fun TrafficTabContent(
},
onDevices = {
devicesViewModel.onPeriodChanged(selectedPeriod)
},
onUtm = {
utmViewModel.onPeriodChanged(selectedPeriod)
}
)
}
Expand Down Expand Up @@ -519,7 +530,8 @@ private fun TrafficTabContent(
onSearchTerms = { searchTermsViewModel.loadData() },
onVideoPlays = { videoPlaysViewModel.loadData() },
onFileDownloads = { fileDownloadsViewModel.loadData() },
onDevices = { devicesViewModel.loadData() }
onDevices = { devicesViewModel.loadData() },
onUtm = { utmViewModel.loadData() }
)
}

Expand Down Expand Up @@ -570,7 +582,8 @@ private fun TrafficTabContent(
onFileDownloads = {
fileDownloadsViewModel.refresh()
},
onDevices = { devicesViewModel.refresh() }
onDevices = { devicesViewModel.refresh() },
onUtm = { utmViewModel.refresh() }
)
},
indicator = {
Expand Down Expand Up @@ -739,6 +752,43 @@ private fun TrafficTabContent(
context = context
)
)
StatsCardType.UTM -> UtmCard(
uiState = utmUiState,
selectedCategory = selectedUtmCategory,
onCategoryChanged = utmViewModel::onCategoryChanged,
onShowAllClick = {
UtmDetailActivity.start(
context = context,
category =
selectedUtmCategory,
period = selectedPeriod
)
},
onRetry = utmViewModel::onRetry,
onRemoveCard = {
newStatsViewModel.removeCard(cardType)
},
cardPosition = cardPosition,
onMoveUp = {
newStatsViewModel.moveCardUp(cardType)
},
onMoveToTop = {
newStatsViewModel.moveCardToTop(cardType)
},
onMoveDown = {
newStatsViewModel.moveCardDown(cardType)
},
onMoveToBottom = {
newStatsViewModel.moveCardToBottom(cardType)
},
onOpenWpAdmin = buildOpenWpAdminAction(
isAuthError = (utmUiState as?
UtmCardUiState.Error)
?.isAuthError == true,
getAdminUrl = utmViewModel::getAdminUrl,
context = context
)
)
StatsCardType.AUTHORS -> AuthorsCard(
uiState = authorsUiState,
onShowAllClick = {
Expand Down Expand Up @@ -975,7 +1025,8 @@ private fun List<StatsCardType>.dispatchToVisibleCards(
onSearchTerms: () -> Unit,
onVideoPlays: () -> Unit,
onFileDownloads: () -> Unit,
onDevices: () -> Unit
onDevices: () -> Unit,
onUtm: () -> Unit
) {
if (StatsCardType.TODAYS_STATS in this) onTodaysStats()
if (StatsCardType.VIEWS_STATS in this) onViewsStats()
Expand All @@ -992,6 +1043,7 @@ private fun List<StatsCardType>.dispatchToVisibleCards(
if (StatsCardType.VIDEO_PLAYS in this) onVideoPlays()
if (StatsCardType.FILE_DOWNLOADS in this) onFileDownloads()
if (StatsCardType.DEVICES in this) onDevices()
if (StatsCardType.UTM in this) onUtm()
}

@OptIn(ExperimentalMaterial3Api::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ enum class StatsCardType(
SEARCH_TERMS(R.string.stats_search_terms),
VIDEO_PLAYS(R.string.stats_videos),
FILE_DOWNLOADS(R.string.stats_file_downloads),
DEVICES(R.string.stats_devices_title);
DEVICES(R.string.stats_devices_title),
UTM(R.string.stats_utm_title);

companion object {
/**
Expand All @@ -33,7 +34,8 @@ enum class StatsCardType(
MOST_VIEWED_REFERRERS,
LOCATIONS,
AUTHORS,
DEVICES
DEVICES,
UTM
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,23 @@ sealed class StatsPeriod(@StringRes val labelResId: Int) {
data class Custom(val startDate: LocalDate, val endDate: LocalDate) :
StatsPeriod(R.string.stats_period_custom)

fun toTypeString(): String = when (this) {
is Today -> PERIOD_TODAY
is Last7Days -> PERIOD_LAST_7_DAYS
is Last30Days -> PERIOD_LAST_30_DAYS
is Last6Months -> PERIOD_LAST_6_MONTHS
is Last12Months -> PERIOD_LAST_12_MONTHS
is Custom -> PERIOD_CUSTOM
}

companion object {
private const val PERIOD_TODAY = "today"
private const val PERIOD_LAST_7_DAYS = "last_7_days"
private const val PERIOD_LAST_30_DAYS = "last_30_days"
private const val PERIOD_LAST_6_MONTHS = "last_6_months"
private const val PERIOD_LAST_12_MONTHS = "last_12_months"
private const val PERIOD_CUSTOM = "custom"

/**
* Returns all preset periods (excluding Custom which requires dates).
*/
Expand All @@ -27,5 +43,34 @@ sealed class StatsPeriod(@StringRes val labelResId: Int) {
Last6Months,
Last12Months
)

fun fromTypeString(
type: String,
customStartEpochDay: Long? = null,
customEndEpochDay: Long? = null
): StatsPeriod = when (type) {
PERIOD_TODAY -> Today
PERIOD_LAST_7_DAYS -> Last7Days
PERIOD_LAST_30_DAYS -> Last30Days
PERIOD_LAST_6_MONTHS -> Last6Months
PERIOD_LAST_12_MONTHS -> Last12Months
PERIOD_CUSTOM -> {
if (customStartEpochDay != null &&
customEndEpochDay != null
) {
Custom(
LocalDate.ofEpochDay(
customStartEpochDay
),
LocalDate.ofEpochDay(
customEndEpochDay
)
)
} else {
Last7Days
}
}
else -> Last7Days
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,26 @@ interface StatsDataSource {
siteId: Long,
quantity: Int = 10
): StatsEmailsSummaryDataResult

/**
* Fetches UTM stats for a specific site.
*
* @param siteId The WordPress.com site ID
* @param keys UTM key names to query
* @param date End date for the query (format: yyyy-MM-dd)
* @param days Number of days to include
* @param max Maximum number of items to return (0 = no limit)
* @param queryTopPosts Whether to include top posts
* @return Result containing UTM data or an error
*/
suspend fun fetchUtm(
siteId: Long,
keys: List<String>,
date: String,
days: Int,
max: Int = 0,
queryTopPosts: Boolean = true
): UtmDataResult
}

/**
Expand Down Expand Up @@ -767,3 +787,29 @@ data class EmailSummaryItem(
val opens: Long,
val clicks: Long
)

/**
* Result wrapper for UTM stats fetch operation.
*/
sealed class UtmDataResult {
data class Success(val data: UtmData) : UtmDataResult()
data class Error(
val errorType: StatsErrorType
) : UtmDataResult()
}

/**
* UTM stats data from the API.
*/
data class UtmData(
val topUtmValues: Map<String, Long>,
val topPosts: Map<String, List<UtmPostData>>
)

/**
* A single UTM post item from the API.
*/
data class UtmPostData(
val title: String,
val views: Long
)
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import uniffi.wp_api.SubscribersByUserTypeSortField
import uniffi.wp_api.StatsEmailsSummaryParams
import uniffi.wp_api.StatsEmailsSummaryPeriod
import uniffi.wp_api.StatsEmailsSummarySortField
import uniffi.wp_api.StatsUtmParams
import uniffi.wp_api.WpApiParamOrder
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.AppLog.T
Expand Down Expand Up @@ -1347,6 +1348,71 @@ class StatsDataSourceImpl @Inject constructor(
}
}

override suspend fun fetchUtm(
siteId: Long,
keys: List<String>,
date: String,
days: Int,
max: Int,
queryTopPosts: Boolean
): UtmDataResult {
val key = keys.joinToString(",")
val params = StatsUtmParams(
max = max.toUInt(),
date = date,
days = days.toUInt(),
startDate = null,
queryTopPosts = queryTopPosts
)
AppLog.d(
T.STATS,
"fetchUtm - siteId=$siteId, " +
"key=$key, date=$date, days=$days"
)
val result = getOrCreateClient()
.request { requestBuilder ->
requestBuilder.statsUtm()
.getStatsUtm(
wpComSiteId = siteId.toULong(),
statsUtmKeys = key,
params = params
)
}
logResultType("fetchUtm", result)
return when (result) {
is WpRequestResult.Success -> {
val response = result.response.data
val topValues = response.topUtmValues
.mapValues { it.value.toLong() }
val topPosts = response.topPosts
.mapValues { entry ->
entry.value.map { post ->
UtmPostData(
title = post.title,
views = post.views.toLong()
)
}
}
AppLog.d(
T.STATS,
"fetchUtm success - " +
"${topValues.size} values"
)
UtmDataResult.Success(
UtmData(
topUtmValues = topValues,
topPosts = topPosts
)
)
}
else -> logErrorAndReturn(
"fetchUtm", result
) {
UtmDataResult.Error(it)
}
}
}

companion object {
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_FORBIDDEN = 403
Expand Down
Loading
Loading