From 880f5ebf38306dbde35102ce3325dca375491353 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 10:44:39 +0100 Subject: [PATCH 01/23] Add StatsDataSource and refactor StatsRepository - Create StatsDataSource interface and implementation to abstract data fetching - Add StatsModule for dependency injection - Refactor StatsRepository to use the new data source - Rename todaysstat -> todaystats package in newstats - Add StatsRepositoryTest with comprehensive test coverage --- .../wordpress/android/modules/StatsModule.kt | 15 + .../android/ui/newstats/NewStatsActivity.kt | 4 +- .../ui/newstats/datasource/StatsDataSource.kt | 96 ++++ .../datasource/StatsDataSourceImpl.kt | 114 +++++ .../ui/newstats/repository/StatsRepository.kt | 255 +++-------- .../TodaysStatsCard.kt | 2 +- .../TodaysStatsCardUiState.kt | 2 +- .../TodaysStatsViewModel.kt | 2 +- .../repository/StatsRepositoryTest.kt | 425 ++++++++++++++++++ .../TodaysStatsViewModelTest.kt | 2 +- 10 files changed, 727 insertions(+), 190 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsCard.kt (99%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsCardUiState.kt (94%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsViewModel.kt (99%) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt rename WordPress/src/test/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsViewModelTest.kt (99%) diff --git a/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt new file mode 100644 index 000000000000..8216a860e935 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.modules + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsDataSourceImpl + +@InstallIn(SingletonComponent::class) +@Module +abstract class StatsModule { + @Binds + abstract fun bindStatsDataSource(impl: StatsDataSourceImpl): StatsDataSource +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 1b7c45f12c3b..eb87e12e0ca5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity -import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsCard -import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsViewModel +import org.wordpress.android.ui.newstats.todaystats.TodaysStatsCard +import org.wordpress.android.ui.newstats.todaystats.TodaysStatsViewModel import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt new file mode 100644 index 000000000000..d8c0b0edf81f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.ui.newstats.datasource + +/** + * Data source interface for fetching stats data. + * This abstraction allows mocking the data layer in tests without needing access to uniffi objects. + */ +interface StatsDataSource { + /** + * Initializes the data source with the access token. + */ + fun init(accessToken: String) + + /** + * Fetches stats data for a specific site. + * + * @param siteId The WordPress.com site ID + * @param unit The time unit for the stats (HOUR, DAY, etc.) + * @param quantity The number of data points to fetch + * @param endDate The end date for the stats period (format: yyyy-MM-dd) + * @return Result containing the stats data or an error + */ + suspend fun fetchStatsVisits( + siteId: Long, + unit: StatsUnit, + quantity: Int, + endDate: String + ): StatsVisitsDataResult +} + +/** + * Time unit for stats data. + */ +enum class StatsUnit { + HOUR, + DAY +} + +/** + * Result wrapper for stats visits fetch operation. + */ +sealed class StatsVisitsDataResult { + data class Success(val data: StatsVisitsData) : StatsVisitsDataResult() + data class Error(val message: String) : StatsVisitsDataResult() +} + +/** + * Stats visits data from the API. + * Contains all the data points for views, visitors, likes, comments, and posts. + */ +data class StatsVisitsData( + val visits: List, + val visitors: List, + val likes: List, + val comments: List, + val posts: List +) + +/** + * Data point for visits/views. + */ +data class VisitsDataPoint( + val period: String, + val visits: Long +) + +/** + * Data point for visitors. + */ +data class VisitorsDataPoint( + val period: String, + val visitors: Long +) + +/** + * Data point for likes. + */ +data class LikesDataPoint( + val period: String, + val likes: Long +) + +/** + * Data point for comments. + */ +data class CommentsDataPoint( + val period: String, + val comments: Long +) + +/** + * Data point for posts. + */ +data class PostsDataPoint( + val period: String, + val posts: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt new file mode 100644 index 000000000000..a24c07edadcc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.ui.newstats.datasource + +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.ui.newstats.extension.statsCommentsData +import org.wordpress.android.ui.newstats.extension.statsLikesData +import org.wordpress.android.ui.newstats.extension.statsPostsData +import org.wordpress.android.ui.newstats.extension.statsVisitorsData +import org.wordpress.android.ui.newstats.extension.statsVisitsData +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsParams +import uniffi.wp_api.StatsVisitsUnit +import javax.inject.Inject + +/** + * Implementation of [StatsDataSource] that fetches stats data from the WordPress.com API + * using the wordpress-rs library. + */ +class StatsDataSourceImpl @Inject constructor( + private val wpComApiClientProvider: WpComApiClientProvider +) : StatsDataSource { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this data source is accessed + * from multiple coroutine contexts. + */ + @Volatile + private var accessToken: String? = null + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null) { "DataSource not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + override fun init(accessToken: String) { + this.accessToken = accessToken + } + + override suspend fun fetchStatsVisits( + siteId: Long, + unit: StatsUnit, + quantity: Int, + endDate: String + ): StatsVisitsDataResult { + if (accessToken == null) { + return StatsVisitsDataResult.Error("DataSource not initialized") + } + + val params = StatsVisitsParams( + unit = unit.toApiUnit(), + quantity = quantity.toUInt(), + endDate = endDate + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + return when (result) { + is WpRequestResult.Success -> { + val response = result.response.data + val statsData = StatsVisitsData( + visits = response.statsVisitsData().map { dataPoint -> + VisitsDataPoint( + period = dataPoint.period, + visits = dataPoint.visits.toLong() + ) + }, + visitors = response.statsVisitorsData().map { dataPoint -> + VisitorsDataPoint( + period = dataPoint.period, + visitors = dataPoint.visitors.toLong() + ) + }, + likes = response.statsLikesData().map { dataPoint -> + LikesDataPoint( + period = dataPoint.period, + likes = dataPoint.likes.toLong() + ) + }, + comments = response.statsCommentsData().map { dataPoint -> + CommentsDataPoint( + period = dataPoint.period, + comments = dataPoint.comments.toLong() + ) + }, + posts = response.statsPostsData().map { dataPoint -> + PostsDataPoint( + period = dataPoint.period, + posts = dataPoint.posts.toLong() + ) + } + ) + StatsVisitsDataResult.Success(statsData) + } + + is WpRequestResult.WpError -> { + StatsVisitsDataResult.Error(result.errorMessage) + } + + else -> { + StatsVisitsDataResult.Error("Unknown error") + } + } + } + + private fun StatsUnit.toApiUnit(): StatsVisitsUnit = when (this) { + StatsUnit.HOUR -> StatsVisitsUnit.HOUR + StatsUnit.DAY -> StatsVisitsUnit.DAY + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 24da50d9278b..dfe74fcc85ea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -1,29 +1,22 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsUnit +import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD -import org.wordpress.android.networking.restapi.WpComApiClientProvider import org.wordpress.android.util.AppLog -import org.wordpress.android.ui.newstats.extension.statsCommentsData -import org.wordpress.android.ui.newstats.extension.statsLikesData -import org.wordpress.android.ui.newstats.extension.statsPostsData -import org.wordpress.android.ui.newstats.extension.statsVisitorsData -import org.wordpress.android.ui.newstats.extension.statsVisitsData -import rs.wordpress.api.kotlin.WpComApiClient -import rs.wordpress.api.kotlin.WpRequestResult -import uniffi.wp_api.StatsVisitsParams -import uniffi.wp_api.StatsVisitsUnit import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import javax.inject.Inject import javax.inject.Named -private const val HOURLY_QUANTITY = 24u -private const val DAILY_QUANTITY = 1u -private const val WEEKLY_QUANTITY = 7u +private const val HOURLY_QUANTITY = 24 +private const val DAILY_QUANTITY = 1 +private const val WEEKLY_QUANTITY = 7 private const val DAYS_BEFORE_END_DATE = -6 /** @@ -31,23 +24,10 @@ private const val DAYS_BEFORE_END_DATE = -6 * Handles hourly visits/views data for the Today's Stats card chart. */ class StatsRepository @Inject constructor( - private val wpComApiClientProvider: WpComApiClientProvider, + private val statsDataSource: StatsDataSource, private val appLogWrapper: AppLogWrapper, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { - /** - * Access token for API authentication. - * Marked as @Volatile to ensure visibility across threads since this repository is accessed - * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). - */ - @Volatile - private var accessToken: String? = null - - private val wpComApiClient: WpComApiClient by lazy { - check(accessToken != null) { "Repository not initialized" } - wpComApiClientProvider.getWpComApiClient(accessToken!!) - } - /** * Thread-local date formatter for thread-safe date formatting. * SimpleDateFormat is NOT thread-safe, so we use ThreadLocal to provide each thread @@ -60,7 +40,7 @@ class StatsRepository @Inject constructor( private fun getDateFormat(): SimpleDateFormat = dateFormat.get()!! fun init(accessToken: String) { - this.accessToken = accessToken + statsDataSource.init(accessToken) } /** @@ -70,34 +50,23 @@ class StatsRepository @Inject constructor( * @return Today's aggregated stats or error */ suspend fun fetchTodayAggregates(siteId: Long): TodayAggregatesResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext TodayAggregatesResult.Error("Repository not initialized") - } - val calendar = Calendar.getInstance() val dateString = getDateFormat().format(calendar.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = DAILY_QUANTITY, - endDate = dateString, + endDate = dateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val views = response.statsVisitsData().firstOrNull()?.visits?.toLong() ?: 0L - val visitors = response.statsVisitorsData().firstOrNull()?.visitors?.toLong() ?: 0L - val likes = response.statsLikesData().firstOrNull()?.likes?.toLong() ?: 0L - val comments = response.statsCommentsData().firstOrNull()?.comments?.toLong() ?: 0L + is StatsVisitsDataResult.Success -> { + val data = result.data + val views = data.visits.firstOrNull()?.visits ?: 0L + val visitors = data.visitors.firstOrNull()?.visitors ?: 0L + val likes = data.likes.firstOrNull()?.likes ?: 0L + val comments = data.comments.firstOrNull()?.comments ?: 0L val aggregates = TodayAggregates( views = views, @@ -108,14 +77,9 @@ class StatsRepository @Inject constructor( TodayAggregatesResult.Success(aggregates) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.errorMessage}") - TodayAggregatesResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching today aggregates") - TodayAggregatesResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.message}") + TodayAggregatesResult.Error(result.message) } } } @@ -131,11 +95,6 @@ class StatsRepository @Inject constructor( siteId: Long, offsetDays: Int = 0 ): HourlyViewsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext HourlyViewsResult.Error("Repository not initialized") - } - val calendar = Calendar.getInstance() // The API's endDate is exclusive for hourly queries, so we need to add 1 day to get // the target day's hours. Formula: 1 (for exclusive end) - offsetDays (0=today, 1=yesterday) @@ -144,36 +103,24 @@ class StatsRepository @Inject constructor( calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) val dateString = getDateFormat().format(calendar.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.HOUR, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.HOUR, quantity = HOURLY_QUANTITY, - endDate = dateString, + endDate = dateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val dataPoints = response.statsVisitsData().map { dataPoint -> - HourlyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + is StatsVisitsDataResult.Success -> { + val dataPoints = result.data.visits.map { dataPoint -> + HourlyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } HourlyViewsResult.Success(dataPoints) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.errorMessage}") - HourlyViewsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching hourly views") - HourlyViewsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.message}") + HourlyViewsResult.Error(result.message) } } } @@ -187,41 +134,24 @@ class StatsRepository @Inject constructor( */ suspend fun fetchWeeklyStats(siteId: Long, weeksAgo: Int = 0): WeeklyStatsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext WeeklyStatsResult.Error("Repository not initialized") - } - val (startDate, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val visitsData = response.statsVisitsData() - val visitorsData = response.statsVisitorsData() - val likesData = response.statsLikesData() - val commentsData = response.statsCommentsData() - val postsData = response.statsPostsData() - - val totalViews = visitsData.sumOf { it.visits.toLong() } - val totalVisitors = visitorsData.sumOf { it.visitors.toLong() } - val totalLikes = likesData.sumOf { it.likes.toLong() } - val totalComments = commentsData.sumOf { it.comments.toLong() } - val totalPosts = postsData.sumOf { it.posts.toLong() } + is StatsVisitsDataResult.Success -> { + val data = result.data + val totalViews = data.visits.sumOf { it.visits } + val totalVisitors = data.visitors.sumOf { it.visitors } + val totalLikes = data.likes.sumOf { it.likes } + val totalComments = data.comments.sumOf { it.comments } + val totalPosts = data.posts.sumOf { it.posts } val startDateFormatted = getDateFormat().format(startDate.time) @@ -237,14 +167,9 @@ class StatsRepository @Inject constructor( WeeklyStatsResult.Success(aggregates) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching weekly stats: ${result.errorMessage}") - WeeklyStatsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching weekly stats") - WeeklyStatsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching weekly stats: ${result.message}") + WeeklyStatsResult.Error(result.message) } } } @@ -258,44 +183,27 @@ class StatsRepository @Inject constructor( */ suspend fun fetchDailyViewsForWeek(siteId: Long, weeksAgo: Int = 0): DailyViewsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext DailyViewsResult.Error("Repository not initialized") - } - val (_, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val dataPoints = response.statsVisitsData().map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + is StatsVisitsDataResult.Success -> { + val dataPoints = result.data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } DailyViewsResult.Success(dataPoints) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching daily views: ${result.errorMessage}") - DailyViewsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching daily views") - DailyViewsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching daily views: ${result.message}") + DailyViewsResult.Error(result.message) } } } @@ -312,42 +220,26 @@ class StatsRepository @Inject constructor( siteId: Long, weeksAgo: Int = 0 ): WeeklyStatsWithDailyDataResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext WeeklyStatsWithDailyDataResult.Error("Repository not initialized") - } - val (startDate, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val visitsData = response.statsVisitsData() - val visitorsData = response.statsVisitorsData() - val likesData = response.statsLikesData() - val commentsData = response.statsCommentsData() - val postsData = response.statsPostsData() + is StatsVisitsDataResult.Success -> { + val data = result.data // Build aggregates - val totalViews = visitsData.sumOf { it.visits.toLong() } - val totalVisitors = visitorsData.sumOf { it.visitors.toLong() } - val totalLikes = likesData.sumOf { it.likes.toLong() } - val totalComments = commentsData.sumOf { it.comments.toLong() } - val totalPosts = postsData.sumOf { it.posts.toLong() } + val totalViews = data.visits.sumOf { it.visits } + val totalVisitors = data.visitors.sumOf { it.visitors } + val totalLikes = data.likes.sumOf { it.likes } + val totalComments = data.comments.sumOf { it.comments } + val totalPosts = data.posts.sumOf { it.posts } val startDateFormatted = getDateFormat().format(startDate.time) val aggregates = WeeklyAggregates( @@ -361,24 +253,19 @@ class StatsRepository @Inject constructor( ) // Build daily data points - val dailyDataPoints = visitsData.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + val dailyDataPoints = data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } WeeklyStatsWithDailyDataResult.Success(aggregates, dailyDataPoints) } - is WpRequestResult.WpError -> { + is StatsVisitsDataResult.Error -> { appLogWrapper.e( AppLog.T.STATS, - "API Error fetching weekly stats with daily data: ${result.errorMessage}" + "API Error fetching weekly stats with daily data: ${result.message}" ) - WeeklyStatsWithDailyDataResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching weekly stats with daily data") - WeeklyStatsWithDailyDataResult.Error("Unknown error") + WeeklyStatsWithDailyDataResult.Error(result.message) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt index 9875394edb0b..17963b45328f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt similarity index 94% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt index 06fb7d1dd293..725e70968dcc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats /** * UI State for the Today's Stats card in the new stats screen. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt index 55739425a0f5..a18cf844d2ef 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt new file mode 100644 index 000000000000..3076798dc9f6 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt @@ -0,0 +1,425 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.wordpress.android.ui.newstats.datasource.CommentsDataPoint +import org.wordpress.android.ui.newstats.datasource.LikesDataPoint +import org.wordpress.android.ui.newstats.datasource.PostsDataPoint +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsUnit +import org.wordpress.android.ui.newstats.datasource.StatsVisitsData +import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult +import org.wordpress.android.ui.newstats.datasource.VisitorsDataPoint +import org.wordpress.android.ui.newstats.datasource.VisitsDataPoint +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper + +@ExperimentalCoroutinesApi +class StatsRepositoryTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + // region init + @Test + fun `when init is called, then data source is initialized with access token`() { + repository.init(TEST_ACCESS_TOKEN) + + verify(statsDataSource).init(eq(TEST_ACCESS_TOKEN)) + } + // endregion + + // region fetchTodayAggregates + @Test + fun `given successful response, when fetchTodayAggregates is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createStatsVisitsData())) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS) + } + + @Test + fun `given successful response, when fetchTodayAggregates is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createStatsVisitsData())) + + repository.fetchTodayAggregates(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(1), + endDate = any() + ) + } + + @Test + fun `given empty data, when fetchTodayAggregates is called, then zeros are returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createEmptyStatsVisitsData())) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(0L) + assertThat(success.aggregates.visitors).isEqualTo(0L) + assertThat(success.aggregates.likes).isEqualTo(0L) + assertThat(success.aggregates.comments).isEqualTo(0L) + } + + @Test + fun `given error response, when fetchTodayAggregates is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + // endregion + + // region fetchHourlyViews + @Test + fun `given successful response, when fetchHourlyViews is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_VIEWS_2) + } + + @Test + fun `given successful response, when fetchHourlyViews is called, then data source is called with HOUR unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + repository.fetchHourlyViews(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.HOUR), + quantity = eq(24), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchHourlyViews is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given offset days, when fetchHourlyViews is called, then data source is called`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + repository.fetchHourlyViews(TEST_SITE_ID, offsetDays = 1) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.HOUR), + quantity = eq(24), + endDate = any() + ) + } + // endregion + + // region fetchWeeklyStats + @Test + fun `given successful response, when fetchWeeklyStats is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchWeeklyStats(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsResult.Success::class.java) + val success = result as WeeklyStatsResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS_1 + TEST_VISITORS_2) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES_1 + TEST_LIKES_2) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS_1 + TEST_COMMENTS_2) + assertThat(success.aggregates.posts).isEqualTo(TEST_POSTS_1 + TEST_POSTS_2) + } + + @Test + fun `given successful response, when fetchWeeklyStats is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStats(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchWeeklyStats is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchWeeklyStats(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsResult.Error::class.java) + assertThat((result as WeeklyStatsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given weeks ago parameter, when fetchWeeklyStats is called, then data source is called`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStats(TEST_SITE_ID, weeksAgo = 1) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + // endregion + + // region fetchDailyViewsForWeek + @Test + fun `given successful response, when fetchDailyViewsForWeek is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + assertThat(result).isInstanceOf(DailyViewsResult.Success::class.java) + val success = result as DailyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_VIEWS_2) + } + + @Test + fun `given successful response, when fetchDailyViewsForWeek is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchDailyViewsForWeek is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + assertThat(result).isInstanceOf(DailyViewsResult.Error::class.java) + assertThat((result as DailyViewsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + // endregion + + // region fetchWeeklyStatsWithDailyData + @Test + fun `given successful response, when fetchWeeklyStatsWithDailyData is called, then success result is returned`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsWithDailyDataResult.Success::class.java) + val success = result as WeeklyStatsWithDailyDataResult.Success + + // Verify aggregates + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS_1 + TEST_VISITORS_2) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES_1 + TEST_LIKES_2) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS_1 + TEST_COMMENTS_2) + assertThat(success.aggregates.posts).isEqualTo(TEST_POSTS_1 + TEST_POSTS_2) + + // Verify daily data points + assertThat(success.dailyDataPoints).hasSize(2) + assertThat(success.dailyDataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dailyDataPoints[0].views).isEqualTo(TEST_VIEWS_1) + } + + @Test + fun `given successful response, when fetchWeeklyStatsWithDailyData is called, data source is called correctly`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchWeeklyStatsWithDailyData is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsWithDailyDataResult.Error::class.java) + assertThat((result as WeeklyStatsWithDailyDataResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given weeks ago parameter, when fetchWeeklyStatsWithDailyData is called, then data source is called`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID, weeksAgo = 2) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + // endregion + + // region Helper functions + private fun createStatsVisitsData() = StatsVisitsData( + visits = listOf(VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS)), + visitors = listOf(VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS)), + likes = listOf(LikesDataPoint(TEST_PERIOD_1, TEST_LIKES)), + comments = listOf(CommentsDataPoint(TEST_PERIOD_1, TEST_COMMENTS)), + posts = listOf(PostsDataPoint(TEST_PERIOD_1, TEST_POSTS)) + ) + + private fun createEmptyStatsVisitsData() = StatsVisitsData( + visits = emptyList(), + visitors = emptyList(), + likes = emptyList(), + comments = emptyList(), + posts = emptyList() + ) + + private fun createHourlyStatsVisitsData() = StatsVisitsData( + visits = listOf( + VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS_1), + VisitsDataPoint(TEST_PERIOD_2, TEST_VIEWS_2) + ), + visitors = listOf( + VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS_1), + VisitorsDataPoint(TEST_PERIOD_2, TEST_VISITORS_2) + ), + likes = emptyList(), + comments = emptyList(), + posts = emptyList() + ) + + private fun createWeeklyStatsVisitsData() = StatsVisitsData( + visits = listOf( + VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS_1), + VisitsDataPoint(TEST_PERIOD_2, TEST_VIEWS_2) + ), + visitors = listOf( + VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS_1), + VisitorsDataPoint(TEST_PERIOD_2, TEST_VISITORS_2) + ), + likes = listOf( + LikesDataPoint(TEST_PERIOD_1, TEST_LIKES_1), + LikesDataPoint(TEST_PERIOD_2, TEST_LIKES_2) + ), + comments = listOf( + CommentsDataPoint(TEST_PERIOD_1, TEST_COMMENTS_1), + CommentsDataPoint(TEST_PERIOD_2, TEST_COMMENTS_2) + ), + posts = listOf( + PostsDataPoint(TEST_PERIOD_1, TEST_POSTS_1), + PostsDataPoint(TEST_PERIOD_2, TEST_POSTS_2) + ) + ) + // endregion + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val ERROR_MESSAGE = "Test error message" + + private const val TEST_PERIOD_1 = "2024-01-15" + private const val TEST_PERIOD_2 = "2024-01-16" + + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L + private const val TEST_POSTS = 5L + + private const val TEST_VIEWS_1 = 100L + private const val TEST_VIEWS_2 = 150L + private const val TEST_VISITORS_1 = 50L + private const val TEST_VISITORS_2 = 75L + private const val TEST_LIKES_1 = 10L + private const val TEST_LIKES_2 = 15L + private const val TEST_COMMENTS_1 = 5L + private const val TEST_COMMENTS_2 = 8L + private const val TEST_POSTS_1 = 2L + private const val TEST_POSTS_2 = 3L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt similarity index 99% rename from WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt index 93254e765c0d..68cd6d3c11a8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat From b3693223f2ab4305201500645c592654ec0f7e36 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 10:44:39 +0100 Subject: [PATCH 02/23] Add StatsDataSource and refactor StatsRepository - Create StatsDataSource interface and implementation to abstract data fetching - Add StatsModule for dependency injection - Refactor StatsRepository to use the new data source - Rename todaysstat -> todaystats package in newstats - Add StatsRepositoryTest with comprehensive test coverage --- .../wordpress/android/modules/StatsModule.kt | 15 + .../android/ui/newstats/NewStatsActivity.kt | 4 +- .../ui/newstats/datasource/StatsDataSource.kt | 96 ++++ .../datasource/StatsDataSourceImpl.kt | 85 ++++ .../ui/newstats/repository/StatsRepository.kt | 255 +++-------- .../TodaysStatsCard.kt | 2 +- .../TodaysStatsCardUiState.kt | 2 +- .../TodaysStatsViewModel.kt | 2 +- .../repository/StatsRepositoryTest.kt | 425 ++++++++++++++++++ .../TodaysStatsViewModelTest.kt | 2 +- 10 files changed, 698 insertions(+), 190 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsCard.kt (99%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsCardUiState.kt (94%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsViewModel.kt (99%) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt rename WordPress/src/test/java/org/wordpress/android/ui/newstats/{todaysstat => todaystats}/TodaysStatsViewModelTest.kt (99%) diff --git a/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt new file mode 100644 index 000000000000..8216a860e935 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/StatsModule.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.modules + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsDataSourceImpl + +@InstallIn(SingletonComponent::class) +@Module +abstract class StatsModule { + @Binds + abstract fun bindStatsDataSource(impl: StatsDataSourceImpl): StatsDataSource +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 1b7c45f12c3b..eb87e12e0ca5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity -import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsCard -import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsViewModel +import org.wordpress.android.ui.newstats.todaystats.TodaysStatsCard +import org.wordpress.android.ui.newstats.todaystats.TodaysStatsViewModel import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt new file mode 100644 index 000000000000..d8c0b0edf81f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.ui.newstats.datasource + +/** + * Data source interface for fetching stats data. + * This abstraction allows mocking the data layer in tests without needing access to uniffi objects. + */ +interface StatsDataSource { + /** + * Initializes the data source with the access token. + */ + fun init(accessToken: String) + + /** + * Fetches stats data for a specific site. + * + * @param siteId The WordPress.com site ID + * @param unit The time unit for the stats (HOUR, DAY, etc.) + * @param quantity The number of data points to fetch + * @param endDate The end date for the stats period (format: yyyy-MM-dd) + * @return Result containing the stats data or an error + */ + suspend fun fetchStatsVisits( + siteId: Long, + unit: StatsUnit, + quantity: Int, + endDate: String + ): StatsVisitsDataResult +} + +/** + * Time unit for stats data. + */ +enum class StatsUnit { + HOUR, + DAY +} + +/** + * Result wrapper for stats visits fetch operation. + */ +sealed class StatsVisitsDataResult { + data class Success(val data: StatsVisitsData) : StatsVisitsDataResult() + data class Error(val message: String) : StatsVisitsDataResult() +} + +/** + * Stats visits data from the API. + * Contains all the data points for views, visitors, likes, comments, and posts. + */ +data class StatsVisitsData( + val visits: List, + val visitors: List, + val likes: List, + val comments: List, + val posts: List +) + +/** + * Data point for visits/views. + */ +data class VisitsDataPoint( + val period: String, + val visits: Long +) + +/** + * Data point for visitors. + */ +data class VisitorsDataPoint( + val period: String, + val visitors: Long +) + +/** + * Data point for likes. + */ +data class LikesDataPoint( + val period: String, + val likes: Long +) + +/** + * Data point for comments. + */ +data class CommentsDataPoint( + val period: String, + val comments: Long +) + +/** + * Data point for posts. + */ +data class PostsDataPoint( + val period: String, + val posts: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt new file mode 100644 index 000000000000..d93eda1e5847 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -0,0 +1,85 @@ +package org.wordpress.android.ui.newstats.datasource + +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.ui.newstats.extension.statsCommentsData +import org.wordpress.android.ui.newstats.extension.statsLikesData +import org.wordpress.android.ui.newstats.extension.statsPostsData +import org.wordpress.android.ui.newstats.extension.statsVisitorsData +import org.wordpress.android.ui.newstats.extension.statsVisitsData +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsParams +import uniffi.wp_api.StatsVisitsUnit +import javax.inject.Inject + +/** + * Implementation of [StatsDataSource] that fetches stats data from the WordPress.com API + * using the wordpress-rs library. + */ +class StatsDataSourceImpl @Inject constructor( + private val wpComApiClientProvider: WpComApiClientProvider +) : StatsDataSource { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this data source is accessed + * from multiple coroutine contexts. + */ + @Volatile + private var accessToken: String? = null + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null) { "DataSource not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + override fun init(accessToken: String) { + this.accessToken = accessToken + } + + override suspend fun fetchStatsVisits( + siteId: Long, + unit: StatsUnit, + quantity: Int, + endDate: String + ): StatsVisitsDataResult { + if (accessToken == null) { + return StatsVisitsDataResult.Error("DataSource not initialized") + } + + val params = StatsVisitsParams( + unit = unit.toApiUnit(), + quantity = quantity.toUInt(), + endDate = endDate + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + return when (result) { + is WpRequestResult.Success -> { + StatsVisitsDataResult.Success(mapResponseToStatsVisitsData(result.response.data)) + } + is WpRequestResult.WpError -> StatsVisitsDataResult.Error(result.errorMessage) + else -> StatsVisitsDataResult.Error("Unknown error") + } + } + + private fun mapResponseToStatsVisitsData( + response: uniffi.wp_api.StatsVisitsResponse + ): StatsVisitsData = StatsVisitsData( + visits = response.statsVisitsData().map { VisitsDataPoint(it.period, it.visits.toLong()) }, + visitors = response.statsVisitorsData().map { VisitorsDataPoint(it.period, it.visitors.toLong()) }, + likes = response.statsLikesData().map { LikesDataPoint(it.period, it.likes.toLong()) }, + comments = response.statsCommentsData().map { CommentsDataPoint(it.period, it.comments.toLong()) }, + posts = response.statsPostsData().map { PostsDataPoint(it.period, it.posts.toLong()) } + ) + + private fun StatsUnit.toApiUnit(): StatsVisitsUnit = when (this) { + StatsUnit.HOUR -> StatsVisitsUnit.HOUR + StatsUnit.DAY -> StatsVisitsUnit.DAY + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 24da50d9278b..dfe74fcc85ea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -1,29 +1,22 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsUnit +import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD -import org.wordpress.android.networking.restapi.WpComApiClientProvider import org.wordpress.android.util.AppLog -import org.wordpress.android.ui.newstats.extension.statsCommentsData -import org.wordpress.android.ui.newstats.extension.statsLikesData -import org.wordpress.android.ui.newstats.extension.statsPostsData -import org.wordpress.android.ui.newstats.extension.statsVisitorsData -import org.wordpress.android.ui.newstats.extension.statsVisitsData -import rs.wordpress.api.kotlin.WpComApiClient -import rs.wordpress.api.kotlin.WpRequestResult -import uniffi.wp_api.StatsVisitsParams -import uniffi.wp_api.StatsVisitsUnit import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import javax.inject.Inject import javax.inject.Named -private const val HOURLY_QUANTITY = 24u -private const val DAILY_QUANTITY = 1u -private const val WEEKLY_QUANTITY = 7u +private const val HOURLY_QUANTITY = 24 +private const val DAILY_QUANTITY = 1 +private const val WEEKLY_QUANTITY = 7 private const val DAYS_BEFORE_END_DATE = -6 /** @@ -31,23 +24,10 @@ private const val DAYS_BEFORE_END_DATE = -6 * Handles hourly visits/views data for the Today's Stats card chart. */ class StatsRepository @Inject constructor( - private val wpComApiClientProvider: WpComApiClientProvider, + private val statsDataSource: StatsDataSource, private val appLogWrapper: AppLogWrapper, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { - /** - * Access token for API authentication. - * Marked as @Volatile to ensure visibility across threads since this repository is accessed - * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). - */ - @Volatile - private var accessToken: String? = null - - private val wpComApiClient: WpComApiClient by lazy { - check(accessToken != null) { "Repository not initialized" } - wpComApiClientProvider.getWpComApiClient(accessToken!!) - } - /** * Thread-local date formatter for thread-safe date formatting. * SimpleDateFormat is NOT thread-safe, so we use ThreadLocal to provide each thread @@ -60,7 +40,7 @@ class StatsRepository @Inject constructor( private fun getDateFormat(): SimpleDateFormat = dateFormat.get()!! fun init(accessToken: String) { - this.accessToken = accessToken + statsDataSource.init(accessToken) } /** @@ -70,34 +50,23 @@ class StatsRepository @Inject constructor( * @return Today's aggregated stats or error */ suspend fun fetchTodayAggregates(siteId: Long): TodayAggregatesResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext TodayAggregatesResult.Error("Repository not initialized") - } - val calendar = Calendar.getInstance() val dateString = getDateFormat().format(calendar.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = DAILY_QUANTITY, - endDate = dateString, + endDate = dateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val views = response.statsVisitsData().firstOrNull()?.visits?.toLong() ?: 0L - val visitors = response.statsVisitorsData().firstOrNull()?.visitors?.toLong() ?: 0L - val likes = response.statsLikesData().firstOrNull()?.likes?.toLong() ?: 0L - val comments = response.statsCommentsData().firstOrNull()?.comments?.toLong() ?: 0L + is StatsVisitsDataResult.Success -> { + val data = result.data + val views = data.visits.firstOrNull()?.visits ?: 0L + val visitors = data.visitors.firstOrNull()?.visitors ?: 0L + val likes = data.likes.firstOrNull()?.likes ?: 0L + val comments = data.comments.firstOrNull()?.comments ?: 0L val aggregates = TodayAggregates( views = views, @@ -108,14 +77,9 @@ class StatsRepository @Inject constructor( TodayAggregatesResult.Success(aggregates) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.errorMessage}") - TodayAggregatesResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching today aggregates") - TodayAggregatesResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.message}") + TodayAggregatesResult.Error(result.message) } } } @@ -131,11 +95,6 @@ class StatsRepository @Inject constructor( siteId: Long, offsetDays: Int = 0 ): HourlyViewsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext HourlyViewsResult.Error("Repository not initialized") - } - val calendar = Calendar.getInstance() // The API's endDate is exclusive for hourly queries, so we need to add 1 day to get // the target day's hours. Formula: 1 (for exclusive end) - offsetDays (0=today, 1=yesterday) @@ -144,36 +103,24 @@ class StatsRepository @Inject constructor( calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) val dateString = getDateFormat().format(calendar.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.HOUR, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.HOUR, quantity = HOURLY_QUANTITY, - endDate = dateString, + endDate = dateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val dataPoints = response.statsVisitsData().map { dataPoint -> - HourlyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + is StatsVisitsDataResult.Success -> { + val dataPoints = result.data.visits.map { dataPoint -> + HourlyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } HourlyViewsResult.Success(dataPoints) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.errorMessage}") - HourlyViewsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching hourly views") - HourlyViewsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.message}") + HourlyViewsResult.Error(result.message) } } } @@ -187,41 +134,24 @@ class StatsRepository @Inject constructor( */ suspend fun fetchWeeklyStats(siteId: Long, weeksAgo: Int = 0): WeeklyStatsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext WeeklyStatsResult.Error("Repository not initialized") - } - val (startDate, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val visitsData = response.statsVisitsData() - val visitorsData = response.statsVisitorsData() - val likesData = response.statsLikesData() - val commentsData = response.statsCommentsData() - val postsData = response.statsPostsData() - - val totalViews = visitsData.sumOf { it.visits.toLong() } - val totalVisitors = visitorsData.sumOf { it.visitors.toLong() } - val totalLikes = likesData.sumOf { it.likes.toLong() } - val totalComments = commentsData.sumOf { it.comments.toLong() } - val totalPosts = postsData.sumOf { it.posts.toLong() } + is StatsVisitsDataResult.Success -> { + val data = result.data + val totalViews = data.visits.sumOf { it.visits } + val totalVisitors = data.visitors.sumOf { it.visitors } + val totalLikes = data.likes.sumOf { it.likes } + val totalComments = data.comments.sumOf { it.comments } + val totalPosts = data.posts.sumOf { it.posts } val startDateFormatted = getDateFormat().format(startDate.time) @@ -237,14 +167,9 @@ class StatsRepository @Inject constructor( WeeklyStatsResult.Success(aggregates) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching weekly stats: ${result.errorMessage}") - WeeklyStatsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching weekly stats") - WeeklyStatsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching weekly stats: ${result.message}") + WeeklyStatsResult.Error(result.message) } } } @@ -258,44 +183,27 @@ class StatsRepository @Inject constructor( */ suspend fun fetchDailyViewsForWeek(siteId: Long, weeksAgo: Int = 0): DailyViewsResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext DailyViewsResult.Error("Repository not initialized") - } - val (_, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val dataPoints = response.statsVisitsData().map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + is StatsVisitsDataResult.Success -> { + val dataPoints = result.data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } DailyViewsResult.Success(dataPoints) } - is WpRequestResult.WpError -> { - appLogWrapper.e(AppLog.T.STATS, "API Error fetching daily views: ${result.errorMessage}") - DailyViewsResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching daily views") - DailyViewsResult.Error("Unknown error") + is StatsVisitsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching daily views: ${result.message}") + DailyViewsResult.Error(result.message) } } } @@ -312,42 +220,26 @@ class StatsRepository @Inject constructor( siteId: Long, weeksAgo: Int = 0 ): WeeklyStatsWithDailyDataResult = withContext(ioDispatcher) { - if (accessToken == null) { - appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") - return@withContext WeeklyStatsWithDailyDataResult.Error("Repository not initialized") - } - val (startDate, endDate) = calculateWeekDateRange(weeksAgo) val endDateString = getDateFormat().format(endDate.time) - val params = StatsVisitsParams( - unit = StatsVisitsUnit.DAY, + val result = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = StatsUnit.DAY, quantity = WEEKLY_QUANTITY, - endDate = endDateString, + endDate = endDateString ) - val result = wpComApiClient.request { requestBuilder -> - requestBuilder.statsVisits().getStatsVisits( - wpComSiteId = siteId.toULong(), - params = params - ) - } - when (result) { - is WpRequestResult.Success -> { - val response = result.response.data - val visitsData = response.statsVisitsData() - val visitorsData = response.statsVisitorsData() - val likesData = response.statsLikesData() - val commentsData = response.statsCommentsData() - val postsData = response.statsPostsData() + is StatsVisitsDataResult.Success -> { + val data = result.data // Build aggregates - val totalViews = visitsData.sumOf { it.visits.toLong() } - val totalVisitors = visitorsData.sumOf { it.visitors.toLong() } - val totalLikes = likesData.sumOf { it.likes.toLong() } - val totalComments = commentsData.sumOf { it.comments.toLong() } - val totalPosts = postsData.sumOf { it.posts.toLong() } + val totalViews = data.visits.sumOf { it.visits } + val totalVisitors = data.visitors.sumOf { it.visitors } + val totalLikes = data.likes.sumOf { it.likes } + val totalComments = data.comments.sumOf { it.comments } + val totalPosts = data.posts.sumOf { it.posts } val startDateFormatted = getDateFormat().format(startDate.time) val aggregates = WeeklyAggregates( @@ -361,24 +253,19 @@ class StatsRepository @Inject constructor( ) // Build daily data points - val dailyDataPoints = visitsData.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits.toLong()) + val dailyDataPoints = data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } WeeklyStatsWithDailyDataResult.Success(aggregates, dailyDataPoints) } - is WpRequestResult.WpError -> { + is StatsVisitsDataResult.Error -> { appLogWrapper.e( AppLog.T.STATS, - "API Error fetching weekly stats with daily data: ${result.errorMessage}" + "API Error fetching weekly stats with daily data: ${result.message}" ) - WeeklyStatsWithDailyDataResult.Error(result.errorMessage) - } - - else -> { - appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching weekly stats with daily data") - WeeklyStatsWithDailyDataResult.Error("Unknown error") + WeeklyStatsWithDailyDataResult.Error(result.message) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt index 9875394edb0b..17963b45328f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt similarity index 94% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt index 06fb7d1dd293..725e70968dcc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats /** * UI State for the Today's Stats card in the new stats screen. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt index 55739425a0f5..a18cf844d2ef 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt new file mode 100644 index 000000000000..3076798dc9f6 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt @@ -0,0 +1,425 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.wordpress.android.ui.newstats.datasource.CommentsDataPoint +import org.wordpress.android.ui.newstats.datasource.LikesDataPoint +import org.wordpress.android.ui.newstats.datasource.PostsDataPoint +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsUnit +import org.wordpress.android.ui.newstats.datasource.StatsVisitsData +import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult +import org.wordpress.android.ui.newstats.datasource.VisitorsDataPoint +import org.wordpress.android.ui.newstats.datasource.VisitsDataPoint +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper + +@ExperimentalCoroutinesApi +class StatsRepositoryTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + // region init + @Test + fun `when init is called, then data source is initialized with access token`() { + repository.init(TEST_ACCESS_TOKEN) + + verify(statsDataSource).init(eq(TEST_ACCESS_TOKEN)) + } + // endregion + + // region fetchTodayAggregates + @Test + fun `given successful response, when fetchTodayAggregates is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createStatsVisitsData())) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS) + } + + @Test + fun `given successful response, when fetchTodayAggregates is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createStatsVisitsData())) + + repository.fetchTodayAggregates(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(1), + endDate = any() + ) + } + + @Test + fun `given empty data, when fetchTodayAggregates is called, then zeros are returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createEmptyStatsVisitsData())) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(0L) + assertThat(success.aggregates.visitors).isEqualTo(0L) + assertThat(success.aggregates.likes).isEqualTo(0L) + assertThat(success.aggregates.comments).isEqualTo(0L) + } + + @Test + fun `given error response, when fetchTodayAggregates is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + // endregion + + // region fetchHourlyViews + @Test + fun `given successful response, when fetchHourlyViews is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_VIEWS_2) + } + + @Test + fun `given successful response, when fetchHourlyViews is called, then data source is called with HOUR unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + repository.fetchHourlyViews(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.HOUR), + quantity = eq(24), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchHourlyViews is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given offset days, when fetchHourlyViews is called, then data source is called`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + repository.fetchHourlyViews(TEST_SITE_ID, offsetDays = 1) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.HOUR), + quantity = eq(24), + endDate = any() + ) + } + // endregion + + // region fetchWeeklyStats + @Test + fun `given successful response, when fetchWeeklyStats is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchWeeklyStats(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsResult.Success::class.java) + val success = result as WeeklyStatsResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS_1 + TEST_VISITORS_2) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES_1 + TEST_LIKES_2) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS_1 + TEST_COMMENTS_2) + assertThat(success.aggregates.posts).isEqualTo(TEST_POSTS_1 + TEST_POSTS_2) + } + + @Test + fun `given successful response, when fetchWeeklyStats is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStats(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchWeeklyStats is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchWeeklyStats(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsResult.Error::class.java) + assertThat((result as WeeklyStatsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given weeks ago parameter, when fetchWeeklyStats is called, then data source is called`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStats(TEST_SITE_ID, weeksAgo = 1) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + // endregion + + // region fetchDailyViewsForWeek + @Test + fun `given successful response, when fetchDailyViewsForWeek is called, then success result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + assertThat(result).isInstanceOf(DailyViewsResult.Success::class.java) + val success = result as DailyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_VIEWS_2) + } + + @Test + fun `given successful response, when fetchDailyViewsForWeek is called, then data source is called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchDailyViewsForWeek is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchDailyViewsForWeek(TEST_SITE_ID) + + assertThat(result).isInstanceOf(DailyViewsResult.Error::class.java) + assertThat((result as DailyViewsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + // endregion + + // region fetchWeeklyStatsWithDailyData + @Test + fun `given successful response, when fetchWeeklyStatsWithDailyData is called, then success result is returned`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsWithDailyDataResult.Success::class.java) + val success = result as WeeklyStatsWithDailyDataResult.Success + + // Verify aggregates + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS_1 + TEST_VISITORS_2) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES_1 + TEST_LIKES_2) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS_1 + TEST_COMMENTS_2) + assertThat(success.aggregates.posts).isEqualTo(TEST_POSTS_1 + TEST_POSTS_2) + + // Verify daily data points + assertThat(success.dailyDataPoints).hasSize(2) + assertThat(success.dailyDataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dailyDataPoints[0].views).isEqualTo(TEST_VIEWS_1) + } + + @Test + fun `given successful response, when fetchWeeklyStatsWithDailyData is called, data source is called correctly`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchWeeklyStatsWithDailyData is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID) + + assertThat(result).isInstanceOf(WeeklyStatsWithDailyDataResult.Error::class.java) + assertThat((result as WeeklyStatsWithDailyDataResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given weeks ago parameter, when fetchWeeklyStatsWithDailyData is called, then data source is called`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchWeeklyStatsWithDailyData(TEST_SITE_ID, weeksAgo = 2) + + verify(statsDataSource).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + // endregion + + // region Helper functions + private fun createStatsVisitsData() = StatsVisitsData( + visits = listOf(VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS)), + visitors = listOf(VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS)), + likes = listOf(LikesDataPoint(TEST_PERIOD_1, TEST_LIKES)), + comments = listOf(CommentsDataPoint(TEST_PERIOD_1, TEST_COMMENTS)), + posts = listOf(PostsDataPoint(TEST_PERIOD_1, TEST_POSTS)) + ) + + private fun createEmptyStatsVisitsData() = StatsVisitsData( + visits = emptyList(), + visitors = emptyList(), + likes = emptyList(), + comments = emptyList(), + posts = emptyList() + ) + + private fun createHourlyStatsVisitsData() = StatsVisitsData( + visits = listOf( + VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS_1), + VisitsDataPoint(TEST_PERIOD_2, TEST_VIEWS_2) + ), + visitors = listOf( + VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS_1), + VisitorsDataPoint(TEST_PERIOD_2, TEST_VISITORS_2) + ), + likes = emptyList(), + comments = emptyList(), + posts = emptyList() + ) + + private fun createWeeklyStatsVisitsData() = StatsVisitsData( + visits = listOf( + VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS_1), + VisitsDataPoint(TEST_PERIOD_2, TEST_VIEWS_2) + ), + visitors = listOf( + VisitorsDataPoint(TEST_PERIOD_1, TEST_VISITORS_1), + VisitorsDataPoint(TEST_PERIOD_2, TEST_VISITORS_2) + ), + likes = listOf( + LikesDataPoint(TEST_PERIOD_1, TEST_LIKES_1), + LikesDataPoint(TEST_PERIOD_2, TEST_LIKES_2) + ), + comments = listOf( + CommentsDataPoint(TEST_PERIOD_1, TEST_COMMENTS_1), + CommentsDataPoint(TEST_PERIOD_2, TEST_COMMENTS_2) + ), + posts = listOf( + PostsDataPoint(TEST_PERIOD_1, TEST_POSTS_1), + PostsDataPoint(TEST_PERIOD_2, TEST_POSTS_2) + ) + ) + // endregion + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val ERROR_MESSAGE = "Test error message" + + private const val TEST_PERIOD_1 = "2024-01-15" + private const val TEST_PERIOD_2 = "2024-01-16" + + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L + private const val TEST_POSTS = 5L + + private const val TEST_VIEWS_1 = 100L + private const val TEST_VIEWS_2 = 150L + private const val TEST_VISITORS_1 = 50L + private const val TEST_VISITORS_2 = 75L + private const val TEST_LIKES_1 = 10L + private const val TEST_LIKES_2 = 15L + private const val TEST_COMMENTS_1 = 5L + private const val TEST_COMMENTS_2 = 8L + private const val TEST_POSTS_1 = 2L + private const val TEST_POSTS_2 = 3L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt similarity index 99% rename from WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt index 93254e765c0d..68cd6d3c11a8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaysstat +package org.wordpress.android.ui.newstats.todaystats import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat From 45890d2cc146d3ffb37f6eb3a571061a06fc4e37 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 10:56:13 +0100 Subject: [PATCH 03/23] detekt --- .../datasource/StatsDataSourceImpl.kt | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index a24c07edadcc..92bc311557cd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -61,52 +61,37 @@ class StatsDataSourceImpl @Inject constructor( return when (result) { is WpRequestResult.Success -> { - val response = result.response.data - val statsData = StatsVisitsData( - visits = response.statsVisitsData().map { dataPoint -> - VisitsDataPoint( - period = dataPoint.period, - visits = dataPoint.visits.toLong() - ) - }, - visitors = response.statsVisitorsData().map { dataPoint -> - VisitorsDataPoint( - period = dataPoint.period, - visitors = dataPoint.visitors.toLong() - ) - }, - likes = response.statsLikesData().map { dataPoint -> - LikesDataPoint( - period = dataPoint.period, - likes = dataPoint.likes.toLong() - ) - }, - comments = response.statsCommentsData().map { dataPoint -> - CommentsDataPoint( - period = dataPoint.period, - comments = dataPoint.comments.toLong() - ) - }, - posts = response.statsPostsData().map { dataPoint -> - PostsDataPoint( - period = dataPoint.period, - posts = dataPoint.posts.toLong() - ) - } - ) - StatsVisitsDataResult.Success(statsData) + StatsVisitsDataResult.Success(mapToStatsVisitsData(result.response.data)) } - is WpRequestResult.WpError -> { StatsVisitsDataResult.Error(result.errorMessage) } - else -> { StatsVisitsDataResult.Error("Unknown error") } } } + private fun mapToStatsVisitsData(response: uniffi.wp_api.StatsVisitsResponse): StatsVisitsData { + return StatsVisitsData( + visits = response.statsVisitsData().map { dataPoint -> + VisitsDataPoint(period = dataPoint.period, visits = dataPoint.visits.toLong()) + }, + visitors = response.statsVisitorsData().map { dataPoint -> + VisitorsDataPoint(period = dataPoint.period, visitors = dataPoint.visitors.toLong()) + }, + likes = response.statsLikesData().map { dataPoint -> + LikesDataPoint(period = dataPoint.period, likes = dataPoint.likes.toLong()) + }, + comments = response.statsCommentsData().map { dataPoint -> + CommentsDataPoint(period = dataPoint.period, comments = dataPoint.comments.toLong()) + }, + posts = response.statsPostsData().map { dataPoint -> + PostsDataPoint(period = dataPoint.period, posts = dataPoint.posts.toLong()) + } + ) + } + private fun StatsUnit.toApiUnit(): StatsVisitsUnit = when (this) { StatsUnit.HOUR -> StatsVisitsUnit.HOUR StatsUnit.DAY -> StatsVisitsUnit.DAY From 6b038cae050f992c2c32191d1157d067c99a629f Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 10:57:34 +0100 Subject: [PATCH 04/23] rename --- .../org/wordpress/android/ui/newstats/NewStatsActivity.kt | 4 ++-- .../newstats/{todaystats => todaysstats}/TodaysStatsCard.kt | 2 +- .../{todaystats => todaysstats}/TodaysStatsCardUiState.kt | 2 +- .../{todaystats => todaysstats}/TodaysStatsViewModel.kt | 2 +- .../{todaystats => todaysstats}/TodaysStatsViewModelTest.kt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaystats => todaysstats}/TodaysStatsCard.kt (99%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaystats => todaysstats}/TodaysStatsCardUiState.kt (94%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{todaystats => todaysstats}/TodaysStatsViewModel.kt (99%) rename WordPress/src/test/java/org/wordpress/android/ui/newstats/{todaystats => todaysstats}/TodaysStatsViewModelTest.kt (99%) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index eb87e12e0ca5..ff50f2ea4254 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity -import org.wordpress.android.ui.newstats.todaystats.TodaysStatsCard -import org.wordpress.android.ui.newstats.todaystats.TodaysStatsViewModel +import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsCard +import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsViewModel import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCard.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCard.kt index 17963b45328f..55732e3f07c7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCard.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaystats +package org.wordpress.android.ui.newstats.todaysstats import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCardUiState.kt similarity index 94% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCardUiState.kt index 725e70968dcc..4072c9f802b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsCardUiState.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaystats +package org.wordpress.android.ui.newstats.todaysstats /** * UI State for the Today's Stats card in the new stats screen. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModel.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModel.kt index a18cf844d2ef..7007d639b978 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModel.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaystats +package org.wordpress.android.ui.newstats.todaysstats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModelTest.kt similarity index 99% rename from WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModelTest.kt index 68cd6d3c11a8..6f2f4c1a91bb 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaystats/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstats/TodaysStatsViewModelTest.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.todaystats +package org.wordpress.android.ui.newstats.todaysstats import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat From d93b3f769f8451f0d01d3c76d01f3ef56d888255 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 11:38:50 +0100 Subject: [PATCH 05/23] PR suggestions --- .../android/ui/newstats/datasource/StatsDataSourceImpl.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 92bc311557cd..1e0cccfa4fdc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -27,7 +27,7 @@ class StatsDataSourceImpl @Inject constructor( @Volatile private var accessToken: String? = null - private val wpComApiClient: WpComApiClient by lazy { + private val wpComApiClient: WpComApiClient by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { check(accessToken != null) { "DataSource not initialized" } wpComApiClientProvider.getWpComApiClient(accessToken!!) } @@ -42,10 +42,6 @@ class StatsDataSourceImpl @Inject constructor( quantity: Int, endDate: String ): StatsVisitsDataResult { - if (accessToken == null) { - return StatsVisitsDataResult.Error("DataSource not initialized") - } - val params = StatsVisitsParams( unit = unit.toApiUnit(), quantity = quantity.toUInt(), From 3ea51d52fe2ee0399be6ef3483a61ae73a74ecac Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 11:51:58 +0100 Subject: [PATCH 06/23] Adding period menu --- .../android/ui/newstats/NewStatsActivity.kt | 56 +++++++++++++++++++ .../android/ui/newstats/StatsPeriod.kt | 13 +++++ WordPress/src/main/res/values/strings.xml | 9 +++ 3 files changed, 78 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index ff50f2ea4254..afabb413fa2c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -14,6 +14,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,7 +33,10 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -79,6 +86,8 @@ private fun NewStatsScreen( val tabs = StatsTab.entries val pagerState = rememberPagerState(pageCount = { tabs.size }) val coroutineScope = rememberCoroutineScope() + var showPeriodMenu by remember { mutableStateOf(false) } + var selectedPeriod by remember { mutableStateOf(StatsPeriod.LAST_7_DAYS) } Scaffold( topBar = { @@ -93,6 +102,27 @@ private fun NewStatsScreen( contentDescription = stringResource(R.string.back) ) } + }, + actions = { + Box { + IconButton(onClick = { showPeriodMenu = true }) { + Icon( + imageVector = Icons.Default.DateRange, + contentDescription = stringResource( + R.string.stats_period_selector_content_description + ) + ) + } + StatsPeriodMenu( + expanded = showPeriodMenu, + selectedPeriod = selectedPeriod, + onDismiss = { showPeriodMenu = false }, + onPeriodSelected = { period -> + selectedPeriod = period + showPeriodMenu = false + } + ) + } } ) } @@ -189,6 +219,32 @@ private fun PlaceholderTabContent(tab: StatsTab) { } } +@Composable +private fun StatsPeriodMenu( + expanded: Boolean, + selectedPeriod: StatsPeriod, + onDismiss: () -> Unit, + onPeriodSelected: (StatsPeriod) -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss + ) { + StatsPeriod.entries.forEach { period -> + val isSelected = selectedPeriod == period + DropdownMenuItem( + text = { Text(text = stringResource(id = period.labelResId)) }, + onClick = { onPeriodSelected(period) }, + trailingIcon = if (isSelected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + } + ) + } + } +} + @Preview @Composable fun NewStatsScreenPreview() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt new file mode 100644 index 000000000000..d4fee21f3639 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.ui.newstats + +import androidx.annotation.StringRes +import org.wordpress.android.R + +enum class StatsPeriod(@StringRes val labelResId: Int) { + TODAY(R.string.stats_period_today), + LAST_7_DAYS(R.string.stats_period_last_7_days), + LAST_30_DAYS(R.string.stats_period_last_30_days), + LAST_6_MONTHS(R.string.stats_period_last_6_months), + LAST_12_MONTHS(R.string.stats_period_last_12_months), + CUSTOM(R.string.stats_period_custom) +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 1493050b762c..5dee9351abf1 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1558,6 +1558,15 @@ Failed to load stats Unknown error + + Today + Last 7 days + Last 30 days + Last 6 months + Last 12 months + Custom + Select stats period + Open Website Mark as Spam From 8cc6738d9213de6e40238509b9be9c8269e680d6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 12:49:40 +0100 Subject: [PATCH 07/23] Testing trunk push From 13ca0ad623a4667cd872c2fc6a05181a520cd1bb Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 12:58:38 +0100 Subject: [PATCH 08/23] syncing selected period with the chart --- .../android/ui/newstats/NewStatsActivity.kt | 11 +- .../ui/newstats/datasource/StatsDataSource.kt | 4 +- .../datasource/StatsDataSourceImpl.kt | 2 + .../ui/newstats/repository/StatsRepository.kt | 175 +++++++++++++++++ .../viewsstats/ViewsStatsViewModel.kt | 176 ++++++++++++------ 5 files changed, 306 insertions(+), 62 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index afabb413fa2c..aefa8faca320 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -150,16 +151,16 @@ private fun NewStatsScreen( state = pagerState, modifier = Modifier.fillMaxSize() ) { page -> - StatsTabContent(tab = tabs[page]) + StatsTabContent(tab = tabs[page], selectedPeriod = selectedPeriod) } } } } @Composable -private fun StatsTabContent(tab: StatsTab) { +private fun StatsTabContent(tab: StatsTab, selectedPeriod: StatsPeriod) { when (tab) { - StatsTab.TRAFFIC -> TrafficTabContent() + StatsTab.TRAFFIC -> TrafficTabContent(selectedPeriod = selectedPeriod) else -> PlaceholderTabContent(tab) } } @@ -167,9 +168,13 @@ private fun StatsTabContent(tab: StatsTab) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TrafficTabContent( + selectedPeriod: StatsPeriod, todaysStatsViewModel: TodaysStatsViewModel = viewModel(), viewsStatsViewModel: ViewsStatsViewModel = viewModel() ) { + LaunchedEffect(selectedPeriod) { + viewsStatsViewModel.onPeriodChanged(selectedPeriod) + } val todaysStatsUiState by todaysStatsViewModel.uiState.collectAsState() val viewsStatsUiState by viewsStatsViewModel.uiState.collectAsState() val isTodaysStatsRefreshing by todaysStatsViewModel.isRefreshing.collectAsState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index d8c0b0edf81f..9c549e14debd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -32,7 +32,9 @@ interface StatsDataSource { */ enum class StatsUnit { HOUR, - DAY + DAY, + WEEK, + MONTH } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 1e0cccfa4fdc..f9c4e1220fe2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -91,5 +91,7 @@ class StatsDataSourceImpl @Inject constructor( private fun StatsUnit.toApiUnit(): StatsVisitsUnit = when (this) { StatsUnit.HOUR -> StatsVisitsUnit.HOUR StatsUnit.DAY -> StatsVisitsUnit.DAY + StatsUnit.WEEK -> StatsVisitsUnit.WEEK + StatsUnit.MONTH -> StatsVisitsUnit.MONTH } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index dfe74fcc85ea..9ba52a29e3d3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -3,10 +3,12 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsUnit +import org.wordpress.android.ui.newstats.datasource.StatsVisitsData import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.util.AppLog import java.text.SimpleDateFormat import java.util.Calendar @@ -18,6 +20,9 @@ private const val HOURLY_QUANTITY = 24 private const val DAILY_QUANTITY = 1 private const val WEEKLY_QUANTITY = 7 private const val DAYS_BEFORE_END_DATE = -6 +private const val DAYS_IN_30_DAYS = 30 +private const val DAYS_IN_6_MONTHS = 180 +private const val DAYS_IN_12_MONTHS = 365 /** * Repository for fetching stats data using the wordpress-rs API. @@ -270,6 +275,162 @@ class StatsRepository @Inject constructor( } } + /** + * Fetches stats data for a specific period with comparison to the previous period. + * + * @param siteId The WordPress.com site ID + * @param period The stats period to fetch + * @return Combined stats for current and previous periods or error + */ + suspend fun fetchStatsForPeriod( + siteId: Long, + period: StatsPeriod + ): PeriodStatsResult = withContext(ioDispatcher) { + val periodRange = calculatePeriodDates(period) + val (currentStart, currentEnd, previousStart, previousEnd, quantity, unit) = periodRange + + val currentEndString = getDateFormat().format(currentEnd.time) + val previousEndString = getDateFormat().format(previousEnd.time) + + val currentResult = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = unit, + quantity = quantity, + endDate = currentEndString + ) + + val previousResult = statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = unit, + quantity = quantity, + endDate = previousEndString + ) + + if (currentResult is StatsVisitsDataResult.Success && + previousResult is StatsVisitsDataResult.Success + ) { + val currentAggregates = buildPeriodAggregates( + currentResult.data, + getDateFormat().format(currentStart.time), + currentEndString + ) + val previousAggregates = buildPeriodAggregates( + previousResult.data, + getDateFormat().format(previousStart.time), + previousEndString + ) + val currentDailyData = currentResult.data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + } + val previousDailyData = previousResult.data.visits.map { dataPoint -> + DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + } + + PeriodStatsResult.Success( + currentAggregates = currentAggregates, + previousAggregates = previousAggregates, + currentDailyData = currentDailyData, + previousDailyData = previousDailyData + ) + } else { + val errorMessage = when { + currentResult is StatsVisitsDataResult.Error -> currentResult.message + previousResult is StatsVisitsDataResult.Error -> previousResult.message + else -> "Unknown error" + } + appLogWrapper.e(AppLog.T.STATS, "API Error fetching period stats: $errorMessage") + PeriodStatsResult.Error(errorMessage) + } + } + + private fun buildPeriodAggregates( + data: StatsVisitsData, + startDate: String, + endDate: String + ): WeeklyAggregates { + return WeeklyAggregates( + views = data.visits.sumOf { it.visits }, + visitors = data.visitors.sumOf { it.visitors }, + likes = data.likes.sumOf { it.likes }, + comments = data.comments.sumOf { it.comments }, + posts = data.posts.sumOf { it.posts }, + startDate = startDate, + endDate = endDate + ) + } + + private data class PeriodDateRange( + val currentStart: Calendar, + val currentEnd: Calendar, + val previousStart: Calendar, + val previousEnd: Calendar, + val quantity: Int, + val unit: StatsUnit + ) + + @Suppress("MagicNumber") + private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { + val currentEnd = Calendar.getInstance() + val quantity: Int + val unitsBack: Int + val unit: StatsUnit + val calendarField: Int + + when (period) { + StatsPeriod.TODAY -> { + quantity = HOURLY_QUANTITY + unitsBack = 1 + unit = StatsUnit.HOUR + calendarField = Calendar.DAY_OF_YEAR + } + StatsPeriod.LAST_7_DAYS -> { + quantity = 7 + unitsBack = 7 + unit = StatsUnit.DAY + calendarField = Calendar.DAY_OF_YEAR + } + StatsPeriod.LAST_30_DAYS -> { + quantity = DAYS_IN_30_DAYS + unitsBack = DAYS_IN_30_DAYS + unit = StatsUnit.DAY + calendarField = Calendar.DAY_OF_YEAR + } + StatsPeriod.LAST_6_MONTHS -> { + quantity = 6 + unitsBack = 6 + unit = StatsUnit.MONTH + calendarField = Calendar.MONTH + } + StatsPeriod.LAST_12_MONTHS -> { + quantity = 12 + unitsBack = 12 + unit = StatsUnit.MONTH + calendarField = Calendar.MONTH + } + StatsPeriod.CUSTOM -> { + // For custom, default to 7 days for now + quantity = 7 + unitsBack = 7 + unit = StatsUnit.DAY + calendarField = Calendar.DAY_OF_YEAR + } + } + + val currentStart = (currentEnd.clone() as Calendar).apply { + add(calendarField, -(quantity - 1)) + } + + val previousEnd = (currentStart.clone() as Calendar).apply { + add(calendarField, -1) + } + + val previousStart = (previousEnd.clone() as Calendar).apply { + add(calendarField, -(quantity - 1)) + } + + return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, quantity, unit) + } + /** * Calculates the start and end dates for a given week. * @@ -371,3 +532,17 @@ sealed class WeeklyStatsWithDailyDataResult { ) : WeeklyStatsWithDailyDataResult() data class Error(val message: String) : WeeklyStatsWithDailyDataResult() } + +/** + * Result wrapper for period stats fetch operation. + * Contains aggregated stats and daily data for both current and previous periods. + */ +sealed class PeriodStatsResult { + data class Success( + val currentAggregates: WeeklyAggregates, + val previousAggregates: WeeklyAggregates, + val currentDailyData: List, + val previousDailyData: List + ) : PeriodStatsResult() + data class Error(val message: String) : PeriodStatsResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index 0bca63412a3e..1cbd54e6cee2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -3,8 +3,6 @@ package org.wordpress.android.ui.newstats.viewsstats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -13,18 +11,18 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.repository.PeriodStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.WeeklyAggregates -import org.wordpress.android.ui.newstats.repository.WeeklyStatsWithDailyDataResult import org.wordpress.android.viewmodel.ResourceProvider import java.time.LocalDate +import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale import javax.inject.Inject import kotlin.math.abs -private const val CURRENT_WEEK = 0 -private const val PREVIOUS_WEEK = 1 private const val PERCENTAGE_BASE = 100.0 @HiltViewModel @@ -41,11 +39,18 @@ class ViewsStatsViewModel @Inject constructor( val isRefreshing: StateFlow = _isRefreshing.asStateFlow() private var currentChartType: ChartType = ChartType.LINE + private var currentPeriod: StatsPeriod = StatsPeriod.LAST_7_DAYS init { loadData() } + fun onPeriodChanged(period: StatsPeriod) { + if (period == currentPeriod) return + currentPeriod = period + loadData() + } + fun onChartTypeChanged(chartType: ChartType) { currentChartType = chartType val currentState = _uiState.value @@ -91,19 +96,17 @@ class ViewsStatsViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun loadDataInternal(site: SiteModel) { try { - val (currentWeekResult, previousWeekResult) = fetchWeeklyData(site.siteId) - val currentWeekStats = (currentWeekResult as? WeeklyStatsWithDailyDataResult.Success)?.aggregates - val previousWeekStats = (previousWeekResult as? WeeklyStatsWithDailyDataResult.Success)?.aggregates - - if (currentWeekStats != null && previousWeekStats != null) { - _uiState.value = buildLoadedState( - currentWeekResult as WeeklyStatsWithDailyDataResult.Success, - previousWeekResult as WeeklyStatsWithDailyDataResult.Success - ) - } else { - _uiState.value = ViewsStatsCardUiState.Error( - message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load) - ) + val result = statsRepository.fetchStatsForPeriod(site.siteId, currentPeriod) + + when (result) { + is PeriodStatsResult.Success -> { + _uiState.value = buildLoadedState(result) + } + is PeriodStatsResult.Error -> { + _uiState.value = ViewsStatsCardUiState.Error( + message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load) + ) + } } } catch (e: Exception) { _uiState.value = ViewsStatsCardUiState.Error( @@ -112,45 +115,38 @@ class ViewsStatsViewModel @Inject constructor( } } - private suspend fun fetchWeeklyData( - siteId: Long - ): Pair = coroutineScope { - val currentWeekDeferred = async { - statsRepository.fetchWeeklyStatsWithDailyData(siteId, CURRENT_WEEK) - } - val previousWeekDeferred = async { - statsRepository.fetchWeeklyStatsWithDailyData(siteId, PREVIOUS_WEEK) - } - currentWeekDeferred.await() to previousWeekDeferred.await() - } - - private fun buildLoadedState( - currentWeekResult: WeeklyStatsWithDailyDataResult.Success, - previousWeekResult: WeeklyStatsWithDailyDataResult.Success - ): ViewsStatsCardUiState.Loaded { - val currentWeekStats = currentWeekResult.aggregates - val previousWeekStats = previousWeekResult.aggregates - val currentWeekDailyViews = currentWeekResult.dailyDataPoints - .map { DailyDataPoint(formatDayLabel(it.period), it.views) } - val previousWeekDailyViews = previousWeekResult.dailyDataPoints - .map { DailyDataPoint(formatDayLabel(it.period), it.views) } - - val weeklyAverage = if (currentWeekDailyViews.isNotEmpty()) { - currentWeekStats.views / currentWeekDailyViews.size + private fun buildLoadedState(result: PeriodStatsResult.Success): ViewsStatsCardUiState.Loaded { + val currentStats = result.currentAggregates + val previousStats = result.previousAggregates + val currentDataPoints = result.currentDailyData + .map { DailyDataPoint(formatDataPointLabel(it.period), it.views) } + val previousDataPoints = result.previousDailyData + .map { DailyDataPoint(formatDataPointLabel(it.period), it.views) } + + val average = if (currentDataPoints.isNotEmpty()) { + currentStats.views / currentDataPoints.size } else { 0L } return ViewsStatsCardUiState.Loaded( - currentWeekViews = currentWeekStats.views, - previousWeekViews = previousWeekStats.views, - viewsDifference = currentWeekStats.views - previousWeekStats.views, - viewsPercentageChange = calculatePercentageChange(currentWeekStats.views, previousWeekStats.views), - currentWeekDateRange = formatDateRange(currentWeekStats.startDate, currentWeekStats.endDate), - previousWeekDateRange = formatDateRange(previousWeekStats.startDate, previousWeekStats.endDate), - chartData = ViewsStatsChartData(currentWeek = currentWeekDailyViews, previousWeek = previousWeekDailyViews), - weeklyAverage = weeklyAverage, - bottomStats = buildBottomStats(currentWeekStats, previousWeekStats), + currentWeekViews = currentStats.views, + previousWeekViews = previousStats.views, + viewsDifference = currentStats.views - previousStats.views, + viewsPercentageChange = calculatePercentageChange(currentStats.views, previousStats.views), + currentWeekDateRange = formatDateRangeForPeriod( + currentStats.startDate, + currentStats.endDate, + currentPeriod + ), + previousWeekDateRange = formatDateRangeForPeriod( + previousStats.startDate, + previousStats.endDate, + currentPeriod + ), + chartData = ViewsStatsChartData(currentWeek = currentDataPoints, previousWeek = previousDataPoints), + weeklyAverage = average, + bottomStats = buildBottomStats(currentStats, previousStats), chartType = currentChartType ) } @@ -204,19 +200,83 @@ class ViewsStatsViewModel @Inject constructor( return ((current - previous).toDouble() / previous) * PERCENTAGE_BASE } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - private fun formatDayLabel(period: String): String { - return try { + @Suppress("TooGenericExceptionCaught", "SwallowedException", "MagicNumber") + private fun formatDataPointLabel(period: String): String { + // Try hourly format first (yyyy-MM-dd HH:mm:ss) + try { + val inputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val outputFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) + val dateTime = LocalDateTime.parse(period, inputFormat) + return dateTime.format(outputFormat) + } catch (_: Exception) { + // Not hourly format, continue + } + + // Try daily format (yyyy-MM-dd) + try { val date = LocalDate.parse(period, DateTimeFormatter.ISO_LOCAL_DATE) val outputFormat = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) - date.format(outputFormat) + return date.format(outputFormat) + } catch (_: Exception) { + // Not daily format, continue + } + + // Try monthly format (yyyy-MM) + try { + val parts = period.split("-") + if (parts.size == 2) { + val year = parts[0].toInt() + val month = parts[1].toInt() + val date = LocalDate.of(year, month, 1) + val outputFormat = DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) + return date.format(outputFormat) + } + } catch (_: Exception) { + // Not monthly format + } + + return period + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun formatDateRangeForPeriod(startDate: String, endDate: String, period: StatsPeriod): String { + return when (period) { + StatsPeriod.TODAY -> formatSingleDayRange(endDate) + StatsPeriod.LAST_6_MONTHS, StatsPeriod.LAST_12_MONTHS -> formatMonthRange(startDate, endDate) + else -> formatDayRange(startDate, endDate) + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun formatSingleDayRange(date: String): String { + return try { + val parsedDate = LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) + val outputFormat = DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) + parsedDate.format(outputFormat) } catch (e: Exception) { - period + date + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun formatMonthRange(startDate: String, endDate: String): String { + return try { + val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) + val end = LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE) + val monthFormat = DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) + + if (start.month == end.month && start.year == end.year) { + start.format(monthFormat) + } else { + "${start.format(monthFormat)} - ${end.format(monthFormat)}" + } + } catch (e: Exception) { + "$startDate - $endDate" } } @Suppress("TooGenericExceptionCaught", "SwallowedException") - private fun formatDateRange(startDate: String, endDate: String): String { + private fun formatDayRange(startDate: String, endDate: String): String { return try { val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) val end = LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE) From 6b5030e01aec5ed8c7d4ae77c6992575f932cc29 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 13:09:58 +0100 Subject: [PATCH 09/23] Interval fixes --- .../ui/newstats/repository/StatsRepository.kt | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 9ba52a29e3d3..e72d76652f1d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -309,15 +309,19 @@ class StatsRepository @Inject constructor( if (currentResult is StatsVisitsDataResult.Success && previousResult is StatsVisitsDataResult.Success ) { + // Use display dates for the legend (may differ from API dates for hourly queries) + val currentDisplayDateString = getDateFormat().format(periodRange.currentDisplayDate.time) + val previousDisplayDateString = getDateFormat().format(periodRange.previousDisplayDate.time) + val currentAggregates = buildPeriodAggregates( currentResult.data, getDateFormat().format(currentStart.time), - currentEndString + currentDisplayDateString ) val previousAggregates = buildPeriodAggregates( previousResult.data, getDateFormat().format(previousStart.time), - previousEndString + previousDisplayDateString ) val currentDailyData = currentResult.data.visits.map { dataPoint -> DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) @@ -365,52 +369,68 @@ class StatsRepository @Inject constructor( val previousStart: Calendar, val previousEnd: Calendar, val quantity: Int, - val unit: StatsUnit + val unit: StatsUnit, + // Display dates for the legend (may differ from API dates for hourly queries) + val currentDisplayDate: Calendar = currentEnd, + val previousDisplayDate: Calendar = previousEnd ) @Suppress("MagicNumber") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { + // Special handling for TODAY (hourly data) + // The API's endDate is exclusive for hourly queries, so: + // - To get today's hours: use tomorrow as end date + // - To get yesterday's hours: use today as end date + // But for display in the legend, we show today and yesterday + if (period == StatsPeriod.TODAY) { + val today = Calendar.getInstance() + val tomorrow = (today.clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, 1) + } + val yesterday = (today.clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, -1) + } + return PeriodDateRange( + currentStart = today, + currentEnd = tomorrow, + previousStart = yesterday, + previousEnd = today, + quantity = HOURLY_QUANTITY, + unit = StatsUnit.HOUR, + currentDisplayDate = today, + previousDisplayDate = yesterday + ) + } + val currentEnd = Calendar.getInstance() val quantity: Int - val unitsBack: Int val unit: StatsUnit val calendarField: Int when (period) { - StatsPeriod.TODAY -> { - quantity = HOURLY_QUANTITY - unitsBack = 1 - unit = StatsUnit.HOUR - calendarField = Calendar.DAY_OF_YEAR - } StatsPeriod.LAST_7_DAYS -> { quantity = 7 - unitsBack = 7 unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } StatsPeriod.LAST_30_DAYS -> { quantity = DAYS_IN_30_DAYS - unitsBack = DAYS_IN_30_DAYS unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } StatsPeriod.LAST_6_MONTHS -> { quantity = 6 - unitsBack = 6 unit = StatsUnit.MONTH calendarField = Calendar.MONTH } StatsPeriod.LAST_12_MONTHS -> { quantity = 12 - unitsBack = 12 unit = StatsUnit.MONTH calendarField = Calendar.MONTH } - StatsPeriod.CUSTOM -> { - // For custom, default to 7 days for now + StatsPeriod.CUSTOM, StatsPeriod.TODAY -> { + // Custom defaults to 7 days, TODAY handled above quantity = 7 - unitsBack = 7 unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } From 81b056e9e069d80bd54cb5136d10caeb3f69b997 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 13:16:35 +0100 Subject: [PATCH 10/23] Using async for period calls --- .../ui/newstats/repository/StatsRepository.kt | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index e72d76652f1d..14c3b2697aa7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -1,6 +1,8 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsUnit import org.wordpress.android.ui.newstats.datasource.StatsVisitsData @@ -292,19 +294,26 @@ class StatsRepository @Inject constructor( val currentEndString = getDateFormat().format(currentEnd.time) val previousEndString = getDateFormat().format(previousEnd.time) - val currentResult = statsDataSource.fetchStatsVisits( - siteId = siteId, - unit = unit, - quantity = quantity, - endDate = currentEndString - ) - - val previousResult = statsDataSource.fetchStatsVisits( - siteId = siteId, - unit = unit, - quantity = quantity, - endDate = previousEndString - ) + // Fetch both periods in parallel for better performance + val (currentResult, previousResult) = coroutineScope { + val currentDeferred = async { + statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = unit, + quantity = quantity, + endDate = currentEndString + ) + } + val previousDeferred = async { + statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = unit, + quantity = quantity, + endDate = previousEndString + ) + } + currentDeferred.await() to previousDeferred.await() + } if (currentResult is StatsVisitsDataResult.Success && previousResult is StatsVisitsDataResult.Success From 6222c89da9656e4abca339e5654ca6ac87f85338 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 13:20:47 +0100 Subject: [PATCH 11/23] Using better naming --- .../ui/newstats/repository/StatsRepository.kt | 50 +++---- .../ui/newstats/viewsstats/ViewsStatsCard.kt | 128 +++++++++--------- .../viewsstats/ViewsStatsCardUiState.kt | 24 ++-- .../viewsstats/ViewsStatsViewModel.kt | 46 +++---- 4 files changed, 124 insertions(+), 124 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 14c3b2697aa7..5488a8223405 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -162,7 +162,7 @@ class StatsRepository @Inject constructor( val startDateFormatted = getDateFormat().format(startDate.time) - val aggregates = WeeklyAggregates( + val aggregates = PeriodAggregates( views = totalViews, visitors = totalVisitors, likes = totalLikes, @@ -203,7 +203,7 @@ class StatsRepository @Inject constructor( when (result) { is StatsVisitsDataResult.Success -> { val dataPoints = result.data.visits.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } DailyViewsResult.Success(dataPoints) } @@ -249,7 +249,7 @@ class StatsRepository @Inject constructor( val totalPosts = data.posts.sumOf { it.posts } val startDateFormatted = getDateFormat().format(startDate.time) - val aggregates = WeeklyAggregates( + val aggregates = PeriodAggregates( views = totalViews, visitors = totalVisitors, likes = totalLikes, @@ -261,7 +261,7 @@ class StatsRepository @Inject constructor( // Build daily data points val dailyDataPoints = data.visits.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } WeeklyStatsWithDailyDataResult.Success(aggregates, dailyDataPoints) @@ -332,18 +332,18 @@ class StatsRepository @Inject constructor( getDateFormat().format(previousStart.time), previousDisplayDateString ) - val currentDailyData = currentResult.data.visits.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + val currentPeriodData = currentResult.data.visits.map { dataPoint -> + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } - val previousDailyData = previousResult.data.visits.map { dataPoint -> - DailyViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + val previousPeriodData = previousResult.data.visits.map { dataPoint -> + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } PeriodStatsResult.Success( currentAggregates = currentAggregates, previousAggregates = previousAggregates, - currentDailyData = currentDailyData, - previousDailyData = previousDailyData + currentPeriodData = currentPeriodData, + previousPeriodData = previousPeriodData ) } else { val errorMessage = when { @@ -360,8 +360,8 @@ class StatsRepository @Inject constructor( data: StatsVisitsData, startDate: String, endDate: String - ): WeeklyAggregates { - return WeeklyAggregates( + ): PeriodAggregates { + return PeriodAggregates( views = data.visits.sumOf { it.visits }, visitors = data.visitors.sumOf { it.visitors }, likes = data.likes.sumOf { it.likes }, @@ -517,14 +517,14 @@ data class TodayAggregates( * Result wrapper for weekly aggregated stats fetch operation. */ sealed class WeeklyStatsResult { - data class Success(val aggregates: WeeklyAggregates) : WeeklyStatsResult() + data class Success(val aggregates: PeriodAggregates) : WeeklyStatsResult() data class Error(val message: String) : WeeklyStatsResult() } /** - * Weekly aggregated stats data. + * Aggregated stats data for a period. */ -data class WeeklyAggregates( +data class PeriodAggregates( val views: Long, val visitors: Long, val likes: Long, @@ -538,14 +538,14 @@ data class WeeklyAggregates( * Result wrapper for daily views fetch operation. */ sealed class DailyViewsResult { - data class Success(val dataPoints: List) : DailyViewsResult() + data class Success(val dataPoints: List) : DailyViewsResult() data class Error(val message: String) : DailyViewsResult() } /** - * Raw daily data point from the stats API. + * A data point from the stats API representing views for a time unit (hour, day, or month). */ -data class DailyViewsDataPoint( +data class ViewsDataPoint( val period: String, val views: Long ) @@ -556,22 +556,22 @@ data class DailyViewsDataPoint( */ sealed class WeeklyStatsWithDailyDataResult { data class Success( - val aggregates: WeeklyAggregates, - val dailyDataPoints: List + val aggregates: PeriodAggregates, + val dailyDataPoints: List ) : WeeklyStatsWithDailyDataResult() data class Error(val message: String) : WeeklyStatsWithDailyDataResult() } /** * Result wrapper for period stats fetch operation. - * Contains aggregated stats and daily data for both current and previous periods. + * Contains aggregated stats and data points for both current and previous periods. */ sealed class PeriodStatsResult { data class Success( - val currentAggregates: WeeklyAggregates, - val previousAggregates: WeeklyAggregates, - val currentDailyData: List, - val previousDailyData: List + val currentAggregates: PeriodAggregates, + val previousAggregates: PeriodAggregates, + val currentPeriodData: List, + val previousPeriodData: List ) : PeriodStatsResult() data class Error(val message: String) : PeriodStatsResult() } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt index 999b9aaedb57..ee4b274235b5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt @@ -243,7 +243,7 @@ private fun LoadedContent( // Chart Section ViewsStatsChart( chartData = state.chartData, - weeklyAverage = state.weeklyAverage, + periodAverage = state.periodAverage, chartType = state.chartType ) Spacer(modifier = Modifier.height(16.dp)) @@ -295,18 +295,18 @@ private fun HeaderSection( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { - // Left: Current and previous week totals with difference + // Left: Current and previous period totals with difference Column { Row(verticalAlignment = Alignment.Bottom) { Text( - text = formatStatValue(state.currentWeekViews), + text = formatStatValue(state.currentPeriodViews), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = formatStatValue(state.previousWeekViews), + text = formatStatValue(state.previousPeriodViews), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -320,18 +320,18 @@ private fun HeaderSection( // Right: Date ranges with colored dots and average Column(horizontalAlignment = Alignment.End) { DateRangeWithDot( - dateRange = state.currentWeekDateRange, + dateRange = state.currentPeriodDateRange, dotColor = MaterialTheme.colorScheme.primary, isFilled = true ) Spacer(modifier = Modifier.height(4.dp)) DateRangeWithDot( - dateRange = state.previousWeekDateRange, + dateRange = state.previousPeriodDateRange, dotColor = MaterialTheme.colorScheme.outline, isFilled = true ) Spacer(modifier = Modifier.height(4.dp)) - AverageRow(average = state.weeklyAverage) + AverageRow(average = state.periodAverage) } } } @@ -460,33 +460,33 @@ private fun AverageRow(average: Long) { @Composable private fun ViewsStatsChart( chartData: ViewsStatsChartData, - weeklyAverage: Long, + periodAverage: Long, chartType: ChartType ) { // Key the model producer on chartType so it gets recreated when chart type changes val modelProducer = remember(chartType) { CartesianChartModelProducer() } // Use both lists as keys to ensure LaunchedEffect re-runs when either changes - LaunchedEffect(chartData.currentWeek, chartData.previousWeek, chartType) { - if (chartData.currentWeek.isNotEmpty()) { - // Check hasPreviousWeek inside the effect to avoid capturing stale values - val hasPreviousWeek = chartData.previousWeek.isNotEmpty() + LaunchedEffect(chartData.currentPeriod, chartData.previousPeriod, chartType) { + if (chartData.currentPeriod.isNotEmpty()) { + // Check hasPreviousPeriod inside the effect to avoid capturing stale values + val hasPreviousPeriod = chartData.previousPeriod.isNotEmpty() when (chartType) { ChartType.LINE -> modelProducer.runTransaction { lineSeries { - series(chartData.currentWeek.map { it.views.toInt() }) - if (hasPreviousWeek) { - series(chartData.previousWeek.map { it.views.toInt() }) + series(chartData.currentPeriod.map { it.views.toInt() }) + if (hasPreviousPeriod) { + series(chartData.previousPeriod.map { it.views.toInt() }) } } } ChartType.BAR -> modelProducer.runTransaction { columnSeries { // Current period first (primary color) - series(chartData.currentWeek.map { it.views.toInt() }) + series(chartData.currentPeriod.map { it.views.toInt() }) // Previous period second (grey) - if (hasPreviousWeek) { - series(chartData.previousWeek.map { it.views.toInt() }) + if (hasPreviousPeriod) { + series(chartData.previousPeriod.map { it.views.toInt() }) } } } @@ -494,7 +494,7 @@ private fun ViewsStatsChart( } } - if (chartData.currentWeek.isEmpty()) { + if (chartData.currentPeriod.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -515,8 +515,8 @@ private fun ViewsStatsChart( val primaryColor = MaterialTheme.colorScheme.primary val secondaryColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - // X-axis formatter to show date labels from current week data - val dateLabels = chartData.currentWeek.map { it.label } + // X-axis formatter to show date labels from current period data + val dateLabels = chartData.currentPeriod.map { it.label } val bottomAxisValueFormatter = CartesianValueFormatter { context, value, _ -> val index = value.toInt() if (index in dateLabels.indices) dateLabels[index] else "" @@ -524,7 +524,7 @@ private fun ViewsStatsChart( // Horizontal line for period average val averageLine = HorizontalLine( - y = { weeklyAverage.toDouble() }, + y = { periodAverage.toDouble() }, line = LineComponent( fill = fill(MaterialTheme.colorScheme.outline), thicknessDp = 1f @@ -761,33 +761,33 @@ private fun ViewsStatsCardLoadedPreview() { AppThemeM3 { ViewsStatsCard( uiState = ViewsStatsCardUiState.Loaded( - currentWeekViews = 7467, - previousWeekViews = 8289, + currentPeriodViews = 7467, + previousPeriodViews = 8289, viewsDifference = -822, viewsPercentageChange = -9.9, - currentWeekDateRange = "14-20 Jan", - previousWeekDateRange = "7-13 Jan", + currentPeriodDateRange = "14-20 Jan", + previousPeriodDateRange = "7-13 Jan", chartData = ViewsStatsChartData( - currentWeek = listOf( - DailyDataPoint("Jan 14", 800), - DailyDataPoint("Jan 15", 1200), - DailyDataPoint("Jan 16", 950), - DailyDataPoint("Jan 17", 1100), - DailyDataPoint("Jan 18", 1300), - DailyDataPoint("Jan 19", 1017), - DailyDataPoint("Jan 20", 1100) + currentPeriod = listOf( + ChartDataPoint("Jan 14", 800), + ChartDataPoint("Jan 15", 1200), + ChartDataPoint("Jan 16", 950), + ChartDataPoint("Jan 17", 1100), + ChartDataPoint("Jan 18", 1300), + ChartDataPoint("Jan 19", 1017), + ChartDataPoint("Jan 20", 1100) ), - previousWeek = listOf( - DailyDataPoint("Jan 7", 1000), - DailyDataPoint("Jan 8", 1400), - DailyDataPoint("Jan 9", 1150), - DailyDataPoint("Jan 10", 1200), - DailyDataPoint("Jan 11", 1350), - DailyDataPoint("Jan 12", 1089), - DailyDataPoint("Jan 13", 1100) + previousPeriod = listOf( + ChartDataPoint("Jan 7", 1000), + ChartDataPoint("Jan 8", 1400), + ChartDataPoint("Jan 9", 1150), + ChartDataPoint("Jan 10", 1200), + ChartDataPoint("Jan 11", 1350), + ChartDataPoint("Jan 12", 1089), + ChartDataPoint("Jan 13", 1100) ) ), - weeklyAverage = 1066, + periodAverage = 1066, bottomStats = listOf( StatItem("Views", 7467, StatChange.Negative(9.9)), StatItem("Visitors", 2000, StatChange.Negative(5.6)), @@ -822,33 +822,33 @@ private fun ViewsStatsCardLoadedDarkPreview() { AppThemeM3 { ViewsStatsCard( uiState = ViewsStatsCardUiState.Loaded( - currentWeekViews = 7467, - previousWeekViews = 8289, + currentPeriodViews = 7467, + previousPeriodViews = 8289, viewsDifference = -822, viewsPercentageChange = -9.9, - currentWeekDateRange = "14-20 Jan", - previousWeekDateRange = "7-13 Jan", + currentPeriodDateRange = "14-20 Jan", + previousPeriodDateRange = "7-13 Jan", chartData = ViewsStatsChartData( - currentWeek = listOf( - DailyDataPoint("Jan 14", 800), - DailyDataPoint("Jan 15", 1200), - DailyDataPoint("Jan 16", 950), - DailyDataPoint("Jan 17", 1100), - DailyDataPoint("Jan 18", 1300), - DailyDataPoint("Jan 19", 1017), - DailyDataPoint("Jan 20", 1100) + currentPeriod = listOf( + ChartDataPoint("Jan 14", 800), + ChartDataPoint("Jan 15", 1200), + ChartDataPoint("Jan 16", 950), + ChartDataPoint("Jan 17", 1100), + ChartDataPoint("Jan 18", 1300), + ChartDataPoint("Jan 19", 1017), + ChartDataPoint("Jan 20", 1100) ), - previousWeek = listOf( - DailyDataPoint("Jan 7", 1000), - DailyDataPoint("Jan 8", 1400), - DailyDataPoint("Jan 9", 1150), - DailyDataPoint("Jan 10", 1200), - DailyDataPoint("Jan 11", 1350), - DailyDataPoint("Jan 12", 1089), - DailyDataPoint("Jan 13", 1100) + previousPeriod = listOf( + ChartDataPoint("Jan 7", 1000), + ChartDataPoint("Jan 8", 1400), + ChartDataPoint("Jan 9", 1150), + ChartDataPoint("Jan 10", 1200), + ChartDataPoint("Jan 11", 1350), + ChartDataPoint("Jan 12", 1089), + ChartDataPoint("Jan 13", 1100) ) ), - weeklyAverage = 1066, + periodAverage = 1066, bottomStats = listOf( StatItem("Views", 7467, StatChange.Negative(9.9)), StatItem("Visitors", 2000, StatChange.Negative(5.6)), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCardUiState.kt index 8f1a6c319b8c..06e51356febb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCardUiState.kt @@ -7,14 +7,14 @@ sealed class ViewsStatsCardUiState { data object Loading : ViewsStatsCardUiState() data class Loaded( - val currentWeekViews: Long, - val previousWeekViews: Long, + val currentPeriodViews: Long, + val previousPeriodViews: Long, val viewsDifference: Long, val viewsPercentageChange: Double, - val currentWeekDateRange: String, - val previousWeekDateRange: String, + val currentPeriodDateRange: String, + val previousPeriodDateRange: String, val chartData: ViewsStatsChartData, - val weeklyAverage: Long, + val periodAverage: Long, val bottomStats: List, val chartType: ChartType = ChartType.LINE ) : ViewsStatsCardUiState() @@ -31,19 +31,19 @@ enum class ChartType { } /** - * Chart data containing current and previous period daily views. + * Chart data containing current and previous period views. */ data class ViewsStatsChartData( - val currentWeek: List, - val previousWeek: List + val currentPeriod: List, + val previousPeriod: List ) /** - * A single daily data point for the chart. - * @param label The formatted label for this day (e.g., "Jan 15") - * @param views The number of views for this day + * A single data point for the chart. + * @param label The formatted label for this time unit (e.g., "14:00", "Jan 15", "Jan") + * @param views The number of views for this time unit */ -data class DailyDataPoint( +data class ChartDataPoint( val label: String, val views: Long ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index 1cbd54e6cee2..721fc8ec6bbc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -14,7 +14,7 @@ import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.repository.PeriodStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository -import org.wordpress.android.ui.newstats.repository.WeeklyAggregates +import org.wordpress.android.ui.newstats.repository.PeriodAggregates import org.wordpress.android.viewmodel.ResourceProvider import java.time.LocalDate import java.time.LocalDateTime @@ -118,10 +118,10 @@ class ViewsStatsViewModel @Inject constructor( private fun buildLoadedState(result: PeriodStatsResult.Success): ViewsStatsCardUiState.Loaded { val currentStats = result.currentAggregates val previousStats = result.previousAggregates - val currentDataPoints = result.currentDailyData - .map { DailyDataPoint(formatDataPointLabel(it.period), it.views) } - val previousDataPoints = result.previousDailyData - .map { DailyDataPoint(formatDataPointLabel(it.period), it.views) } + val currentDataPoints = result.currentPeriodData + .map { ChartDataPoint(formatDataPointLabel(it.period), it.views) } + val previousDataPoints = result.previousPeriodData + .map { ChartDataPoint(formatDataPointLabel(it.period), it.views) } val average = if (currentDataPoints.isNotEmpty()) { currentStats.views / currentDataPoints.size @@ -130,56 +130,56 @@ class ViewsStatsViewModel @Inject constructor( } return ViewsStatsCardUiState.Loaded( - currentWeekViews = currentStats.views, - previousWeekViews = previousStats.views, + currentPeriodViews = currentStats.views, + previousPeriodViews = previousStats.views, viewsDifference = currentStats.views - previousStats.views, viewsPercentageChange = calculatePercentageChange(currentStats.views, previousStats.views), - currentWeekDateRange = formatDateRangeForPeriod( + currentPeriodDateRange = formatDateRangeForPeriod( currentStats.startDate, currentStats.endDate, currentPeriod ), - previousWeekDateRange = formatDateRangeForPeriod( + previousPeriodDateRange = formatDateRangeForPeriod( previousStats.startDate, previousStats.endDate, currentPeriod ), - chartData = ViewsStatsChartData(currentWeek = currentDataPoints, previousWeek = previousDataPoints), - weeklyAverage = average, + chartData = ViewsStatsChartData(currentPeriod = currentDataPoints, previousPeriod = previousDataPoints), + periodAverage = average, bottomStats = buildBottomStats(currentStats, previousStats), chartType = currentChartType ) } private fun buildBottomStats( - currentWeek: WeeklyAggregates, - previousWeek: WeeklyAggregates + currentPeriod: PeriodAggregates, + previousPeriod: PeriodAggregates ): List { return listOf( StatItem( label = resourceProvider.getString(R.string.stats_views), - value = currentWeek.views, - change = calculateStatChange(currentWeek.views, previousWeek.views) + value = currentPeriod.views, + change = calculateStatChange(currentPeriod.views, previousPeriod.views) ), StatItem( label = resourceProvider.getString(R.string.stats_visitors), - value = currentWeek.visitors, - change = calculateStatChange(currentWeek.visitors, previousWeek.visitors) + value = currentPeriod.visitors, + change = calculateStatChange(currentPeriod.visitors, previousPeriod.visitors) ), StatItem( label = resourceProvider.getString(R.string.stats_likes), - value = currentWeek.likes, - change = calculateStatChange(currentWeek.likes, previousWeek.likes) + value = currentPeriod.likes, + change = calculateStatChange(currentPeriod.likes, previousPeriod.likes) ), StatItem( label = resourceProvider.getString(R.string.stats_comments), - value = currentWeek.comments, - change = calculateStatChange(currentWeek.comments, previousWeek.comments) + value = currentPeriod.comments, + change = calculateStatChange(currentPeriod.comments, previousPeriod.comments) ), StatItem( label = resourceProvider.getString(R.string.posts), - value = currentWeek.posts, - change = calculateStatChange(currentWeek.posts, previousWeek.posts) + value = currentPeriod.posts, + change = calculateStatChange(currentPeriod.posts, previousPeriod.posts) ) ) } From 60766b81052bac5dc219b0f56d5681f7065c8bb7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 13:45:35 +0100 Subject: [PATCH 12/23] Adding custom selection --- .../android/ui/newstats/NewStatsActivity.kt | 38 ++++- .../ui/newstats/StatsDateRangePickerDialog.kt | 140 ++++++++++++++++++ .../android/ui/newstats/StatsPeriod.kt | 32 +++- .../ui/newstats/repository/StatsRepository.kt | 72 ++++++++- .../viewsstats/ViewsStatsViewModel.kt | 7 +- WordPress/src/main/res/values/strings.xml | 1 + 6 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index aefa8faca320..14e2b7a69a9e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -88,7 +88,18 @@ private fun NewStatsScreen( val pagerState = rememberPagerState(pageCount = { tabs.size }) val coroutineScope = rememberCoroutineScope() var showPeriodMenu by remember { mutableStateOf(false) } - var selectedPeriod by remember { mutableStateOf(StatsPeriod.LAST_7_DAYS) } + var showDateRangePicker by remember { mutableStateOf(false) } + var selectedPeriod: StatsPeriod by remember { mutableStateOf(StatsPeriod.Last7Days) } + + if (showDateRangePicker) { + StatsDateRangePickerDialog( + onDismiss = { showDateRangePicker = false }, + onDateRangeSelected = { startDate, endDate -> + selectedPeriod = StatsPeriod.Custom(startDate, endDate) + showDateRangePicker = false + } + ) + } Scaffold( topBar = { @@ -118,9 +129,13 @@ private fun NewStatsScreen( expanded = showPeriodMenu, selectedPeriod = selectedPeriod, onDismiss = { showPeriodMenu = false }, - onPeriodSelected = { period -> + onPresetSelected = { period -> selectedPeriod = period showPeriodMenu = false + }, + onCustomSelected = { + showPeriodMenu = false + showDateRangePicker = true } ) } @@ -229,17 +244,19 @@ private fun StatsPeriodMenu( expanded: Boolean, selectedPeriod: StatsPeriod, onDismiss: () -> Unit, - onPeriodSelected: (StatsPeriod) -> Unit + onPresetSelected: (StatsPeriod) -> Unit, + onCustomSelected: () -> Unit ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismiss ) { - StatsPeriod.entries.forEach { period -> + // Show preset periods + StatsPeriod.presets().forEach { period -> val isSelected = selectedPeriod == period DropdownMenuItem( text = { Text(text = stringResource(id = period.labelResId)) }, - onClick = { onPeriodSelected(period) }, + onClick = { onPresetSelected(period) }, trailingIcon = if (isSelected) { { Icon(Icons.Default.Check, contentDescription = null) } } else { @@ -247,6 +264,17 @@ private fun StatsPeriodMenu( } ) } + // Show Custom option + val isCustomSelected = selectedPeriod is StatsPeriod.Custom + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.stats_period_custom)) }, + onClick = { onCustomSelected() }, + trailingIcon = if (isCustomSelected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + } + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt new file mode 100644 index 000000000000..083488ae3459 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt @@ -0,0 +1,140 @@ +package org.wordpress.android.ui.newstats + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DateRangePicker +import androidx.compose.material3.DateRangePickerState +import androidx.compose.material3.DisplayMode +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDateRangePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import org.wordpress.android.R +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatsDateRangePickerDialog( + onDismiss: () -> Unit, + onDateRangeSelected: (startDate: LocalDate, endDate: LocalDate) -> Unit +) { + val todayMillis = LocalDate.now() + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + + val dateRangePickerState = rememberDateRangePickerState( + initialDisplayMode = DisplayMode.Picker, + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis <= todayMillis + } + } + ) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp + ) { + DateRangePickerContent( + state = dateRangePickerState, + onDismiss = onDismiss, + onConfirm = { + val startMillis = dateRangePickerState.selectedStartDateMillis + val endMillis = dateRangePickerState.selectedEndDateMillis + if (startMillis != null && endMillis != null) { + val startDate = Instant.ofEpochMilli(startMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val endDate = Instant.ofEpochMilli(endMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + onDateRangeSelected(startDate, endDate) + } + onDismiss() + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateRangePickerContent( + state: DateRangePickerState, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + val isConfirmEnabled = state.selectedStartDateMillis != null && + state.selectedEndDateMillis != null + + Column { + DateRangePicker( + state = state, + modifier = Modifier.heightIn(max = 500.dp), + title = { + Text( + text = stringResource(R.string.stats_select_date_range), + modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp) + ) + }, + showModeToggle = true, + colors = DatePickerDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + + DialogButtons( + onDismiss = onDismiss, + onConfirm = onConfirm, + isConfirmEnabled = isConfirmEnabled + ) + } +} + +@Composable +private fun DialogButtons( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + isConfirmEnabled: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + TextButton( + onClick = onConfirm, + enabled = isConfirmEnabled + ) { + Text(stringResource(R.string.ok)) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt index d4fee21f3639..58c4a80f3107 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt @@ -2,12 +2,30 @@ package org.wordpress.android.ui.newstats import androidx.annotation.StringRes import org.wordpress.android.R +import java.time.LocalDate -enum class StatsPeriod(@StringRes val labelResId: Int) { - TODAY(R.string.stats_period_today), - LAST_7_DAYS(R.string.stats_period_last_7_days), - LAST_30_DAYS(R.string.stats_period_last_30_days), - LAST_6_MONTHS(R.string.stats_period_last_6_months), - LAST_12_MONTHS(R.string.stats_period_last_12_months), - CUSTOM(R.string.stats_period_custom) +/** + * Represents the different time periods available for stats viewing. + */ +sealed class StatsPeriod(@StringRes val labelResId: Int) { + data object Today : StatsPeriod(R.string.stats_period_today) + data object Last7Days : StatsPeriod(R.string.stats_period_last_7_days) + data object Last30Days : StatsPeriod(R.string.stats_period_last_30_days) + data object Last6Months : StatsPeriod(R.string.stats_period_last_6_months) + data object Last12Months : StatsPeriod(R.string.stats_period_last_12_months) + data class Custom(val startDate: LocalDate, val endDate: LocalDate) : + StatsPeriod(R.string.stats_period_custom) + + companion object { + /** + * Returns all preset periods (excluding Custom which requires dates). + */ + fun presets(): List = listOf( + Today, + Last7Days, + Last30Days, + Last6Months, + Last12Months + ) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 5488a8223405..acef779049bf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -13,6 +13,9 @@ import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.util.AppLog import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit import java.util.Calendar import java.util.Locale import javax.inject.Inject @@ -384,14 +387,14 @@ class StatsRepository @Inject constructor( val previousDisplayDate: Calendar = previousEnd ) - @Suppress("MagicNumber") + @Suppress("MagicNumber", "CyclomaticComplexMethod") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { // Special handling for TODAY (hourly data) // The API's endDate is exclusive for hourly queries, so: // - To get today's hours: use tomorrow as end date // - To get yesterday's hours: use today as end date // But for display in the legend, we show today and yesterday - if (period == StatsPeriod.TODAY) { + if (period is StatsPeriod.Today) { val today = Calendar.getInstance() val tomorrow = (today.clone() as Calendar).apply { add(Calendar.DAY_OF_YEAR, 1) @@ -411,34 +414,39 @@ class StatsRepository @Inject constructor( ) } + // Special handling for Custom period + if (period is StatsPeriod.Custom) { + return calculateCustomPeriodDates(period.startDate, period.endDate) + } + val currentEnd = Calendar.getInstance() val quantity: Int val unit: StatsUnit val calendarField: Int when (period) { - StatsPeriod.LAST_7_DAYS -> { + is StatsPeriod.Last7Days -> { quantity = 7 unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } - StatsPeriod.LAST_30_DAYS -> { + is StatsPeriod.Last30Days -> { quantity = DAYS_IN_30_DAYS unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } - StatsPeriod.LAST_6_MONTHS -> { + is StatsPeriod.Last6Months -> { quantity = 6 unit = StatsUnit.MONTH calendarField = Calendar.MONTH } - StatsPeriod.LAST_12_MONTHS -> { + is StatsPeriod.Last12Months -> { quantity = 12 unit = StatsUnit.MONTH calendarField = Calendar.MONTH } - StatsPeriod.CUSTOM, StatsPeriod.TODAY -> { - // Custom defaults to 7 days, TODAY handled above + else -> { + // Fallback to 7 days quantity = 7 unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR @@ -460,6 +468,54 @@ class StatsRepository @Inject constructor( return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, quantity, unit) } + @Suppress("MagicNumber") + private fun calculateCustomPeriodDates(startDate: LocalDate, endDate: LocalDate): PeriodDateRange { + val daysBetween = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1 + + // Convert LocalDate to Calendar + val currentStart = localDateToCalendar(startDate) + val currentEnd = localDateToCalendar(endDate) + + // Calculate previous period with same duration + val previousEnd = (currentStart.clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, -1) + } + val previousStart = (previousEnd.clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, -(daysBetween - 1)) + } + + // Determine unit based on range + val unit = when { + daysBetween <= DAYS_IN_30_DAYS -> StatsUnit.DAY + else -> StatsUnit.MONTH + } + + val quantity = if (unit == StatsUnit.MONTH) { + // Calculate months between dates + val monthsBetween = ChronoUnit.MONTHS.between(startDate, endDate).toInt() + 1 + monthsBetween.coerceAtLeast(1) + } else { + daysBetween + } + + return PeriodDateRange( + currentStart = currentStart, + currentEnd = currentEnd, + previousStart = previousStart, + previousEnd = previousEnd, + quantity = quantity, + unit = unit + ) + } + + private fun localDateToCalendar(localDate: LocalDate): Calendar { + return Calendar.getInstance().apply { + time = java.util.Date.from( + localDate.atStartOfDay(ZoneId.systemDefault()).toInstant() + ) + } + } + /** * Calculates the start and end dates for a given week. * diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index 721fc8ec6bbc..f8d37e90ec52 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -39,7 +39,7 @@ class ViewsStatsViewModel @Inject constructor( val isRefreshing: StateFlow = _isRefreshing.asStateFlow() private var currentChartType: ChartType = ChartType.LINE - private var currentPeriod: StatsPeriod = StatsPeriod.LAST_7_DAYS + private var currentPeriod: StatsPeriod = StatsPeriod.Last7Days init { loadData() @@ -241,8 +241,9 @@ class ViewsStatsViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatDateRangeForPeriod(startDate: String, endDate: String, period: StatsPeriod): String { return when (period) { - StatsPeriod.TODAY -> formatSingleDayRange(endDate) - StatsPeriod.LAST_6_MONTHS, StatsPeriod.LAST_12_MONTHS -> formatMonthRange(startDate, endDate) + is StatsPeriod.Today -> formatSingleDayRange(endDate) + is StatsPeriod.Last6Months, is StatsPeriod.Last12Months -> formatMonthRange(startDate, endDate) + is StatsPeriod.Custom -> formatDayRange(startDate, endDate) else -> formatDayRange(startDate, endDate) } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 5dee9351abf1..2a18d25b85c5 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1566,6 +1566,7 @@ Last 12 months Custom Select stats period + Select date range Open Website From 949ba5fff4316144fadea7cfa1d74138667b8f54 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 22 Jan 2026 15:48:20 +0100 Subject: [PATCH 13/23] Adding tests --- .../ui/newstats/repository/StatsRepository.kt | 22 +- .../viewsstats/ViewsStatsViewModel.kt | 2 +- .../repository/StatsRepositoryTest.kt | 176 ++++++++++ .../viewsstats/ViewsStatsViewModelTest.kt | 325 ++++++++++-------- 4 files changed, 371 insertions(+), 154 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index acef779049bf..e3d9b95e67df 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -26,8 +26,6 @@ private const val DAILY_QUANTITY = 1 private const val WEEKLY_QUANTITY = 7 private const val DAYS_BEFORE_END_DATE = -6 private const val DAYS_IN_30_DAYS = 30 -private const val DAYS_IN_6_MONTHS = 180 -private const val DAYS_IN_12_MONTHS = 365 /** * Repository for fetching stats data using the wordpress-rs API. @@ -287,31 +285,31 @@ class StatsRepository @Inject constructor( * @param period The stats period to fetch * @return Combined stats for current and previous periods or error */ + @Suppress("LongMethod") suspend fun fetchStatsForPeriod( siteId: Long, period: StatsPeriod ): PeriodStatsResult = withContext(ioDispatcher) { val periodRange = calculatePeriodDates(period) - val (currentStart, currentEnd, previousStart, previousEnd, quantity, unit) = periodRange - val currentEndString = getDateFormat().format(currentEnd.time) - val previousEndString = getDateFormat().format(previousEnd.time) + val currentEndString = getDateFormat().format(periodRange.currentEnd.time) + val previousEndString = getDateFormat().format(periodRange.previousEnd.time) // Fetch both periods in parallel for better performance val (currentResult, previousResult) = coroutineScope { val currentDeferred = async { statsDataSource.fetchStatsVisits( siteId = siteId, - unit = unit, - quantity = quantity, + unit = periodRange.unit, + quantity = periodRange.quantity, endDate = currentEndString ) } val previousDeferred = async { statsDataSource.fetchStatsVisits( siteId = siteId, - unit = unit, - quantity = quantity, + unit = periodRange.unit, + quantity = periodRange.quantity, endDate = previousEndString ) } @@ -327,12 +325,12 @@ class StatsRepository @Inject constructor( val currentAggregates = buildPeriodAggregates( currentResult.data, - getDateFormat().format(currentStart.time), + getDateFormat().format(periodRange.currentStart.time), currentDisplayDateString ) val previousAggregates = buildPeriodAggregates( previousResult.data, - getDateFormat().format(previousStart.time), + getDateFormat().format(periodRange.previousStart.time), previousDisplayDateString ) val currentPeriodData = currentResult.data.visits.map { dataPoint -> @@ -387,7 +385,7 @@ class StatsRepository @Inject constructor( val previousDisplayDate: Calendar = previousEnd ) - @Suppress("MagicNumber", "CyclomaticComplexMethod") + @Suppress("MagicNumber", "CyclomaticComplexMethod", "LongMethod", "ReturnCount") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { // Special handling for TODAY (hourly data) // The API's endDate is exclusive for hourly queries, so: diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index f8d37e90ec52..4e906196e932 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -200,7 +200,7 @@ class ViewsStatsViewModel @Inject constructor( return ((current - previous).toDouble() / previous) * PERCENTAGE_BASE } - @Suppress("TooGenericExceptionCaught", "SwallowedException", "MagicNumber") + @Suppress("TooGenericExceptionCaught", "SwallowedException", "MagicNumber", "ReturnCount") private fun formatDataPointLabel(period: String): String { // Try hourly format first (yyyy-MM-dd HH:mm:ss) try { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt index 3076798dc9f6..0082794ebaa5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.datasource.CommentsDataPoint import org.wordpress.android.ui.newstats.datasource.LikesDataPoint import org.wordpress.android.ui.newstats.datasource.PostsDataPoint @@ -15,11 +16,14 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.utils.AppLogWrapper +import java.time.LocalDate @ExperimentalCoroutinesApi class StatsRepositoryTest : BaseUnitTest() { @@ -342,6 +346,178 @@ class StatsRepositoryTest : BaseUnitTest() { } // endregion + // region fetchStatsForPeriod + @Test + fun `given successful response, when fetchStatsForPeriod with Last7Days, then success result is returned`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val result = repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(PeriodStatsResult.Success::class.java) + val success = result as PeriodStatsResult.Success + assertThat(success.currentAggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.previousAggregates.views).isEqualTo(TEST_VIEWS_1 + TEST_VIEWS_2) + assertThat(success.currentPeriodData).hasSize(2) + assertThat(success.previousPeriodData).hasSize(2) + } + + @Test + fun `given successful response, when fetchStatsForPeriod with Last7Days, then data source called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last7Days) + + // Called twice: once for current period, once for previous period + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(7), + endDate = any() + ) + } + + @Test + fun `given successful response, when fetchStatsForPeriod with Last30Days, then data source called with DAY unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last30Days) + + // Called twice: once for current period, once for previous period + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(30), + endDate = any() + ) + } + + @Test + fun `given successful response, when fetchStatsForPeriod with Last6Months, then data source called with MONTH`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last6Months) + + // Called twice: once for current period, once for previous period + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.MONTH), + quantity = eq(6), + endDate = any() + ) + } + + @Test + fun `given successful response, when fetchStatsForPeriod with Last12Months, then data source called with MONTH`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last12Months) + + // Called twice: once for current period, once for previous period + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.MONTH), + quantity = eq(12), + endDate = any() + ) + } + + @Test + fun `given successful response, when fetchStatsForPeriod with Today, then data source called with HOUR unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createHourlyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Today) + + // Called twice: once for current period (today), once for previous period (yesterday) + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.HOUR), + quantity = eq(24), + endDate = any() + ) + } + + @Test + fun `given error response, when fetchStatsForPeriod is called, then error result is returned`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(PeriodStatsResult.Error::class.java) + assertThat((result as PeriodStatsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `given custom period, when fetchStatsForPeriod is called, then data source called with correct quantity`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val customPeriod = StatsPeriod.Custom( + startDate = LocalDate.of(2024, 1, 1), + endDate = LocalDate.of(2024, 1, 10) + ) + repository.fetchStatsForPeriod(TEST_SITE_ID, customPeriod) + + // 10 days custom period should use DAY unit with quantity 10 + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.DAY), + quantity = eq(10), + endDate = any() + ) + } + + @Test + fun `given long custom period, when fetchStatsForPeriod is called, then data source called with MONTH unit`() = + test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + val customPeriod = StatsPeriod.Custom( + startDate = LocalDate.of(2024, 1, 1), + endDate = LocalDate.of(2024, 6, 30) + ) + repository.fetchStatsForPeriod(TEST_SITE_ID, customPeriod) + + // Long custom period (>30 days) should use MONTH unit + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = eq(StatsUnit.MONTH), + quantity = argThat { this > 0 }, + endDate = any() + ) + } + + @Test + fun `given parallel fetch, when fetchStatsForPeriod is called, then both periods are fetched`() = test { + whenever(statsDataSource.fetchStatsVisits(any(), any(), any(), any())) + .thenReturn(StatsVisitsDataResult.Success(createWeeklyStatsVisitsData())) + + repository.fetchStatsForPeriod(TEST_SITE_ID, StatsPeriod.Last7Days) + + // Verify data source is called twice (current and previous period) + verify(statsDataSource, times(2)).fetchStatsVisits( + siteId = eq(TEST_SITE_ID), + unit = any(), + quantity = any(), + endDate = any() + ) + } + // endregion + // region Helper functions private fun createStatsVisitsData() = StatsVisitsData( visits = listOf(VisitsDataPoint(TEST_PERIOD_1, TEST_VIEWS)), diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt index a4a9af45075a..9ac326d009bf 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt @@ -7,7 +7,6 @@ import org.junit.Test import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any -import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -17,11 +16,13 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.repository.DailyViewsDataPoint +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.repository.ViewsDataPoint import org.wordpress.android.ui.newstats.repository.StatsRepository -import org.wordpress.android.ui.newstats.repository.WeeklyAggregates -import org.wordpress.android.ui.newstats.repository.WeeklyStatsWithDailyDataResult +import org.wordpress.android.ui.newstats.repository.PeriodAggregates +import org.wordpress.android.ui.newstats.repository.PeriodStatsResult import org.wordpress.android.viewmodel.ResourceProvider +import java.time.LocalDate @ExperimentalCoroutinesApi @Suppress("LargeClass") @@ -92,25 +93,13 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when data loads successfully, then loaded state is emitted with correct values`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult( - views = TEST_CURRENT_WEEK_VIEWS, - visitors = TEST_CURRENT_WEEK_VISITORS, - likes = TEST_CURRENT_WEEK_LIKES, - comments = TEST_CURRENT_WEEK_COMMENTS, - posts = TEST_CURRENT_WEEK_POSTS - ) - val previousWeekResult = createWeeklyStatsWithDailyDataResult( - views = TEST_PREVIOUS_WEEK_VIEWS, - visitors = TEST_PREVIOUS_WEEK_VISITORS, - likes = TEST_PREVIOUS_WEEK_LIKES, - comments = TEST_PREVIOUS_WEEK_COMMENTS, - posts = TEST_PREVIOUS_WEEK_POSTS + val result = createPeriodStatsResult( + currentViews = TEST_CURRENT_PERIOD_VIEWS, + previousViews = TEST_PREVIOUS_PERIOD_VIEWS ) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -118,15 +107,15 @@ class ViewsStatsViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(ViewsStatsCardUiState.Loaded::class.java) with(state as ViewsStatsCardUiState.Loaded) { - assertThat(currentWeekViews).isEqualTo(TEST_CURRENT_WEEK_VIEWS) - assertThat(previousWeekViews).isEqualTo(TEST_PREVIOUS_WEEK_VIEWS) + assertThat(currentPeriodViews).isEqualTo(TEST_CURRENT_PERIOD_VIEWS) + assertThat(previousPeriodViews).isEqualTo(TEST_PREVIOUS_PERIOD_VIEWS) } } @Test - fun `when weekly stats fetch fails, then error state is emitted`() = test { - whenever(statsRepository.fetchWeeklyStatsWithDailyData(any(), any())) - .thenReturn(WeeklyStatsWithDailyDataResult.Error("Network error")) + fun `when period stats fetch fails, then error state is emitted`() = test { + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(PeriodStatsResult.Error("Network error")) initViewModel() advanceUntilIdle() @@ -137,14 +126,11 @@ class ViewsStatsViewModelTest : BaseUnitTest() { } @Test - fun `when daily views data is empty, then chart data is empty but state is loaded`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(dailyDataPoints = emptyList()) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(dailyDataPoints = emptyList()) + fun `when period data is empty, then chart data is empty but state is loaded`() = test { + val result = createPeriodStatsResult(currentPeriodData = emptyList(), previousPeriodData = emptyList()) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -152,20 +138,17 @@ class ViewsStatsViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(ViewsStatsCardUiState.Loaded::class.java) with(state as ViewsStatsCardUiState.Loaded) { - assertThat(chartData.currentWeek).isEmpty() - assertThat(chartData.previousWeek).isEmpty() + assertThat(chartData.currentPeriod).isEmpty() + assertThat(chartData.previousPeriod).isEmpty() } } @Test - fun `when loadData is called with forced true, then repository is called`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult() - val previousWeekResult = createWeeklyStatsWithDailyDataResult() + fun `when loadData is called, then repository is called`() = test { + val result = createPeriodStatsResult() - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -173,20 +156,16 @@ class ViewsStatsViewModelTest : BaseUnitTest() { viewModel.loadData() advanceUntilIdle() - // Called twice for each week (current and previous): 2 during init, 2 during loadData - verify(statsRepository, times(2)).fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0)) - verify(statsRepository, times(2)).fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1)) + // Called twice: once during init, once during loadData + verify(statsRepository, times(2)).fetchStatsForPeriod(any(), any()) } @Test - fun `when onRetry is called, then loadData is called with forced true`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult() - val previousWeekResult = createWeeklyStatsWithDailyDataResult() + fun `when onRetry is called, then loadData is called`() = test { + val result = createPeriodStatsResult() - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -194,20 +173,16 @@ class ViewsStatsViewModelTest : BaseUnitTest() { viewModel.onRetry() advanceUntilIdle() - // Called twice for each week: once during init, once during onRetry - verify(statsRepository, times(2)).fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0)) - verify(statsRepository, times(2)).fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1)) + // Called twice: once during init, once during onRetry + verify(statsRepository, times(2)).fetchStatsForPeriod(any(), any()) } @Test fun `when data loads, then views difference is calculated correctly`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 7000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 8000L) + val result = createPeriodStatsResult(currentViews = 7000L, previousViews = 8000L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -218,13 +193,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when data loads, then percentage change is calculated correctly`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 9000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 10000L) + val result = createPeriodStatsResult(currentViews = 9000L, previousViews = 10000L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -234,14 +206,11 @@ class ViewsStatsViewModelTest : BaseUnitTest() { } @Test - fun `when previous week has zero views, then percentage change is 100 percent`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 1000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 0L) + fun `when previous period has zero views, then percentage change is 100 percent`() = test { + val result = createPeriodStatsResult(currentViews = 1000L, previousViews = 0L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -252,13 +221,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when refresh is called, then isRefreshing becomes true then false`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult() - val previousWeekResult = createWeeklyStatsWithDailyDataResult() + val result = createPeriodStatsResult() - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -285,13 +251,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when bottom stats are built, then they contain all stat types`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult() - val previousWeekResult = createWeeklyStatsWithDailyDataResult() + val result = createPeriodStatsResult() - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -305,13 +268,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when stat increases, then positive change is calculated`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 1000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 800L) + val result = createPeriodStatsResult(currentViews = 1000L, previousViews = 800L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -324,13 +284,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when stat decreases, then negative change is calculated`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 800L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 1000L) + val result = createPeriodStatsResult(currentViews = 800L, previousViews = 1000L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -343,13 +300,10 @@ class ViewsStatsViewModelTest : BaseUnitTest() { @Test fun `when stat is unchanged, then no change is calculated`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 1000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult(views = 1000L) + val result = createPeriodStatsResult(currentViews = 1000L, previousViews = 1000L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() @@ -360,26 +314,23 @@ class ViewsStatsViewModelTest : BaseUnitTest() { } @Test - fun `when weekly average is calculated, then it is based on daily views count`() = test { - val currentWeekResult = createWeeklyStatsWithDailyDataResult(views = 7000L) - val previousWeekResult = createWeeklyStatsWithDailyDataResult() + fun `when period average is calculated, then it is based on data points count`() = test { + val result = createPeriodStatsResult(currentViews = 7000L) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(0))) - .thenReturn(currentWeekResult) - whenever(statsRepository.fetchWeeklyStatsWithDailyData(eq(TEST_SITE_ID), eq(1))) - .thenReturn(previousWeekResult) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) initViewModel() advanceUntilIdle() val state = viewModel.uiState.value as ViewsStatsCardUiState.Loaded // 7000 views / 2 data points = 3500 average - assertThat(state.weeklyAverage).isEqualTo(3500L) + assertThat(state.periodAverage).isEqualTo(3500L) } @Test fun `when exception is thrown during fetch, then error state is emitted`() = test { - whenever(statsRepository.fetchWeeklyStatsWithDailyData(any(), any())) + whenever(statsRepository.fetchStatsForPeriod(any(), any())) .thenThrow(RuntimeException("Test exception")) initViewModel() @@ -390,32 +341,124 @@ class ViewsStatsViewModelTest : BaseUnitTest() { assertThat((state as ViewsStatsCardUiState.Error).message).isEqualTo("Test exception") } - private fun createWeeklyStatsWithDailyDataResult( - views: Long = TEST_CURRENT_WEEK_VIEWS, - visitors: Long = TEST_CURRENT_WEEK_VISITORS, - likes: Long = TEST_CURRENT_WEEK_LIKES, - comments: Long = TEST_CURRENT_WEEK_COMMENTS, - posts: Long = TEST_CURRENT_WEEK_POSTS, - dailyDataPoints: List = createDefaultDailyDataPoints() - ): WeeklyStatsWithDailyDataResult.Success { - val aggregates = WeeklyAggregates( - views = views, - visitors = visitors, - likes = likes, - comments = comments, - posts = posts, + @Test + fun `when onPeriodChanged is called with same period, then data is not reloaded`() = test { + val result = createPeriodStatsResult() + + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) + + initViewModel() + advanceUntilIdle() + + // Default period is Last7Days, calling with same period should not reload + viewModel.onPeriodChanged(StatsPeriod.Last7Days) + advanceUntilIdle() + + // Should only be called once during init + verify(statsRepository, times(1)).fetchStatsForPeriod(any(), any()) + } + + @Test + fun `when onPeriodChanged is called with different period, then data is reloaded`() = test { + val result = createPeriodStatsResult() + + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) + + initViewModel() + advanceUntilIdle() + + viewModel.onPeriodChanged(StatsPeriod.Last30Days) + advanceUntilIdle() + + // Called twice: once during init, once during period change + verify(statsRepository, times(2)).fetchStatsForPeriod(any(), any()) + } + + @Test + fun `when onPeriodChanged is called with custom period, then data is loaded`() = test { + val result = createPeriodStatsResult() + + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) + + initViewModel() + advanceUntilIdle() + + val customPeriod = StatsPeriod.Custom( + startDate = LocalDate.of(2024, 1, 1), + endDate = LocalDate.of(2024, 1, 15) + ) + viewModel.onPeriodChanged(customPeriod) + advanceUntilIdle() + + // Called twice: once during init, once during custom period change + verify(statsRepository, times(2)).fetchStatsForPeriod(any(), any()) + } + + @Test + fun `when onChartTypeChanged is called, then chart type is updated`() = test { + val result = createPeriodStatsResult() + + whenever(statsRepository.fetchStatsForPeriod(any(), any())) + .thenReturn(result) + + initViewModel() + advanceUntilIdle() + + viewModel.onChartTypeChanged(ChartType.BAR) + + val state = viewModel.uiState.value as ViewsStatsCardUiState.Loaded + assertThat(state.chartType).isEqualTo(ChartType.BAR) + } + + private fun createPeriodStatsResult( + currentViews: Long = TEST_CURRENT_PERIOD_VIEWS, + currentVisitors: Long = TEST_CURRENT_PERIOD_VISITORS, + currentLikes: Long = TEST_CURRENT_PERIOD_LIKES, + currentComments: Long = TEST_CURRENT_PERIOD_COMMENTS, + currentPosts: Long = TEST_CURRENT_PERIOD_POSTS, + previousViews: Long = TEST_PREVIOUS_PERIOD_VIEWS, + previousVisitors: Long = TEST_PREVIOUS_PERIOD_VISITORS, + previousLikes: Long = TEST_PREVIOUS_PERIOD_LIKES, + previousComments: Long = TEST_PREVIOUS_PERIOD_COMMENTS, + previousPosts: Long = TEST_PREVIOUS_PERIOD_POSTS, + currentPeriodData: List = createDefaultDataPoints(), + previousPeriodData: List = createDefaultDataPoints() + ): PeriodStatsResult.Success { + val currentAggregates = PeriodAggregates( + views = currentViews, + visitors = currentVisitors, + likes = currentLikes, + comments = currentComments, + posts = currentPosts, startDate = "2024-01-14", endDate = "2024-01-20" ) - return WeeklyStatsWithDailyDataResult.Success(aggregates, dailyDataPoints) + val previousAggregates = PeriodAggregates( + views = previousViews, + visitors = previousVisitors, + likes = previousLikes, + comments = previousComments, + posts = previousPosts, + startDate = "2024-01-07", + endDate = "2024-01-13" + ) + return PeriodStatsResult.Success( + currentAggregates = currentAggregates, + previousAggregates = previousAggregates, + currentPeriodData = currentPeriodData, + previousPeriodData = previousPeriodData + ) } - private fun createDefaultDailyDataPoints() = listOf( - DailyViewsDataPoint( + private fun createDefaultDataPoints() = listOf( + ViewsDataPoint( period = "2024-01-14", views = 1000L ), - DailyViewsDataPoint( + ViewsDataPoint( period = "2024-01-15", views = 1500L ) @@ -424,16 +467,16 @@ class ViewsStatsViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" - private const val TEST_CURRENT_WEEK_VIEWS = 7000L - private const val TEST_CURRENT_WEEK_VISITORS = 700L - private const val TEST_CURRENT_WEEK_LIKES = 50L - private const val TEST_CURRENT_WEEK_COMMENTS = 25L - private const val TEST_CURRENT_WEEK_POSTS = 5L - private const val TEST_PREVIOUS_WEEK_VIEWS = 8000L - private const val TEST_PREVIOUS_WEEK_VISITORS = 800L - private const val TEST_PREVIOUS_WEEK_LIKES = 60L - private const val TEST_PREVIOUS_WEEK_COMMENTS = 30L - private const val TEST_PREVIOUS_WEEK_POSTS = 4L + private const val TEST_CURRENT_PERIOD_VIEWS = 7000L + private const val TEST_CURRENT_PERIOD_VISITORS = 700L + private const val TEST_CURRENT_PERIOD_LIKES = 50L + private const val TEST_CURRENT_PERIOD_COMMENTS = 25L + private const val TEST_CURRENT_PERIOD_POSTS = 5L + private const val TEST_PREVIOUS_PERIOD_VIEWS = 8000L + private const val TEST_PREVIOUS_PERIOD_VISITORS = 800L + private const val TEST_PREVIOUS_PERIOD_LIKES = 60L + private const val TEST_PREVIOUS_PERIOD_COMMENTS = 30L + private const val TEST_PREVIOUS_PERIOD_POSTS = 4L private const val NO_SITE_SELECTED_ERROR = "No site selected" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" private const val UNKNOWN_ERROR = "Unknown error" From 7bf18799d062cc2ec358e38284375ac5a1f23a6e Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:10:51 +0100 Subject: [PATCH 14/23] Extracting magic numbers --- .../ui/newstats/repository/StatsRepository.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index e3d9b95e67df..092fcb802af5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -26,6 +26,9 @@ private const val DAILY_QUANTITY = 1 private const val WEEKLY_QUANTITY = 7 private const val DAYS_BEFORE_END_DATE = -6 private const val DAYS_IN_30_DAYS = 30 +private const val DAYS_IN_7_DAYS = 7 +private const val MONTHS_IN_6_MONTHS = 6 +private const val MONTHS_IN_12_MONTHS = 12 /** * Repository for fetching stats data using the wordpress-rs API. @@ -285,7 +288,6 @@ class StatsRepository @Inject constructor( * @param period The stats period to fetch * @return Combined stats for current and previous periods or error */ - @Suppress("LongMethod") suspend fun fetchStatsForPeriod( siteId: Long, period: StatsPeriod @@ -385,7 +387,7 @@ class StatsRepository @Inject constructor( val previousDisplayDate: Calendar = previousEnd ) - @Suppress("MagicNumber", "CyclomaticComplexMethod", "LongMethod", "ReturnCount") + @Suppress("ReturnCount") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { // Special handling for TODAY (hourly data) // The API's endDate is exclusive for hourly queries, so: @@ -424,7 +426,7 @@ class StatsRepository @Inject constructor( when (period) { is StatsPeriod.Last7Days -> { - quantity = 7 + quantity = DAYS_IN_7_DAYS unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } @@ -434,18 +436,18 @@ class StatsRepository @Inject constructor( calendarField = Calendar.DAY_OF_YEAR } is StatsPeriod.Last6Months -> { - quantity = 6 + quantity = MONTHS_IN_6_MONTHS unit = StatsUnit.MONTH calendarField = Calendar.MONTH } is StatsPeriod.Last12Months -> { - quantity = 12 + quantity = MONTHS_IN_12_MONTHS unit = StatsUnit.MONTH calendarField = Calendar.MONTH } else -> { // Fallback to 7 days - quantity = 7 + quantity = DAYS_IN_7_DAYS unit = StatsUnit.DAY calendarField = Calendar.DAY_OF_YEAR } @@ -466,7 +468,6 @@ class StatsRepository @Inject constructor( return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, quantity, unit) } - @Suppress("MagicNumber") private fun calculateCustomPeriodDates(startDate: LocalDate, endDate: LocalDate): PeriodDateRange { val daysBetween = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1 From 4e6327ae9c458411fe965d5a9b9e1229ff895beb Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:12:39 +0100 Subject: [PATCH 15/23] Some refactoring to avoid long functions --- .../ui/newstats/repository/StatsRepository.kt | 187 ++++++++---------- 1 file changed, 85 insertions(+), 102 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 092fcb802af5..edfb7b921f37 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -321,42 +321,57 @@ class StatsRepository @Inject constructor( if (currentResult is StatsVisitsDataResult.Success && previousResult is StatsVisitsDataResult.Success ) { - // Use display dates for the legend (may differ from API dates for hourly queries) - val currentDisplayDateString = getDateFormat().format(periodRange.currentDisplayDate.time) - val previousDisplayDateString = getDateFormat().format(periodRange.previousDisplayDate.time) - - val currentAggregates = buildPeriodAggregates( - currentResult.data, - getDateFormat().format(periodRange.currentStart.time), - currentDisplayDateString - ) - val previousAggregates = buildPeriodAggregates( - previousResult.data, - getDateFormat().format(periodRange.previousStart.time), - previousDisplayDateString - ) - val currentPeriodData = currentResult.data.visits.map { dataPoint -> - ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) - } - val previousPeriodData = previousResult.data.visits.map { dataPoint -> - ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) - } - - PeriodStatsResult.Success( - currentAggregates = currentAggregates, - previousAggregates = previousAggregates, - currentPeriodData = currentPeriodData, - previousPeriodData = previousPeriodData - ) + buildPeriodStatsSuccess(currentResult.data, previousResult.data, periodRange) } else { - val errorMessage = when { - currentResult is StatsVisitsDataResult.Error -> currentResult.message - previousResult is StatsVisitsDataResult.Error -> previousResult.message - else -> "Unknown error" - } - appLogWrapper.e(AppLog.T.STATS, "API Error fetching period stats: $errorMessage") - PeriodStatsResult.Error(errorMessage) + buildPeriodStatsError(currentResult, previousResult) + } + } + + private fun buildPeriodStatsSuccess( + currentData: StatsVisitsData, + previousData: StatsVisitsData, + periodRange: PeriodDateRange + ): PeriodStatsResult.Success { + val dateFormat = getDateFormat() + val currentDisplayDateString = dateFormat.format(periodRange.currentDisplayDate.time) + val previousDisplayDateString = dateFormat.format(periodRange.previousDisplayDate.time) + + val currentAggregates = buildPeriodAggregates( + currentData, + dateFormat.format(periodRange.currentStart.time), + currentDisplayDateString + ) + val previousAggregates = buildPeriodAggregates( + previousData, + dateFormat.format(periodRange.previousStart.time), + previousDisplayDateString + ) + val currentPeriodData = currentData.visits.map { dataPoint -> + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) + } + val previousPeriodData = previousData.visits.map { dataPoint -> + ViewsDataPoint(period = dataPoint.period, views = dataPoint.visits) } + + return PeriodStatsResult.Success( + currentAggregates = currentAggregates, + previousAggregates = previousAggregates, + currentPeriodData = currentPeriodData, + previousPeriodData = previousPeriodData + ) + } + + private fun buildPeriodStatsError( + currentResult: StatsVisitsDataResult, + previousResult: StatsVisitsDataResult + ): PeriodStatsResult.Error { + val errorMessage = when { + currentResult is StatsVisitsDataResult.Error -> currentResult.message + previousResult is StatsVisitsDataResult.Error -> previousResult.message + else -> "Unknown error" + } + appLogWrapper.e(AppLog.T.STATS, "API Error fetching period stats: $errorMessage") + return PeriodStatsResult.Error(errorMessage) } private fun buildPeriodAggregates( @@ -387,85 +402,53 @@ class StatsRepository @Inject constructor( val previousDisplayDate: Calendar = previousEnd ) + private data class PeriodConfig(val quantity: Int, val unit: StatsUnit, val calendarField: Int) + @Suppress("ReturnCount") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { - // Special handling for TODAY (hourly data) - // The API's endDate is exclusive for hourly queries, so: - // - To get today's hours: use tomorrow as end date - // - To get yesterday's hours: use today as end date - // But for display in the legend, we show today and yesterday - if (period is StatsPeriod.Today) { - val today = Calendar.getInstance() - val tomorrow = (today.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, 1) - } - val yesterday = (today.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, -1) - } - return PeriodDateRange( - currentStart = today, - currentEnd = tomorrow, - previousStart = yesterday, - previousEnd = today, - quantity = HOURLY_QUANTITY, - unit = StatsUnit.HOUR, - currentDisplayDate = today, - previousDisplayDate = yesterday - ) - } - - // Special handling for Custom period - if (period is StatsPeriod.Custom) { - return calculateCustomPeriodDates(period.startDate, period.endDate) - } + if (period is StatsPeriod.Today) return calculateTodayPeriodDates() + if (period is StatsPeriod.Custom) return calculateCustomPeriodDates(period.startDate, period.endDate) + val config = getPeriodConfig(period) val currentEnd = Calendar.getInstance() - val quantity: Int - val unit: StatsUnit - val calendarField: Int - - when (period) { - is StatsPeriod.Last7Days -> { - quantity = DAYS_IN_7_DAYS - unit = StatsUnit.DAY - calendarField = Calendar.DAY_OF_YEAR - } - is StatsPeriod.Last30Days -> { - quantity = DAYS_IN_30_DAYS - unit = StatsUnit.DAY - calendarField = Calendar.DAY_OF_YEAR - } - is StatsPeriod.Last6Months -> { - quantity = MONTHS_IN_6_MONTHS - unit = StatsUnit.MONTH - calendarField = Calendar.MONTH - } - is StatsPeriod.Last12Months -> { - quantity = MONTHS_IN_12_MONTHS - unit = StatsUnit.MONTH - calendarField = Calendar.MONTH - } - else -> { - // Fallback to 7 days - quantity = DAYS_IN_7_DAYS - unit = StatsUnit.DAY - calendarField = Calendar.DAY_OF_YEAR - } - } - val currentStart = (currentEnd.clone() as Calendar).apply { - add(calendarField, -(quantity - 1)) + add(config.calendarField, -(config.quantity - 1)) } - val previousEnd = (currentStart.clone() as Calendar).apply { - add(calendarField, -1) + add(config.calendarField, -1) } - val previousStart = (previousEnd.clone() as Calendar).apply { - add(calendarField, -(quantity - 1)) + add(config.calendarField, -(config.quantity - 1)) } + return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, config.quantity, config.unit) + } - return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, quantity, unit) + private fun getPeriodConfig(period: StatsPeriod): PeriodConfig = when (period) { + is StatsPeriod.Last7Days -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) + is StatsPeriod.Last30Days -> PeriodConfig(DAYS_IN_30_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) + is StatsPeriod.Last6Months -> PeriodConfig(MONTHS_IN_6_MONTHS, StatsUnit.MONTH, Calendar.MONTH) + is StatsPeriod.Last12Months -> PeriodConfig(MONTHS_IN_12_MONTHS, StatsUnit.MONTH, Calendar.MONTH) + else -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) // Fallback to 7 days + } + + /** + * Calculates period dates for TODAY (hourly data). + * The API's endDate is exclusive for hourly queries, so we use tomorrow as end date for today's hours. + */ + private fun calculateTodayPeriodDates(): PeriodDateRange { + val today = Calendar.getInstance() + val tomorrow = (today.clone() as Calendar).apply { add(Calendar.DAY_OF_YEAR, 1) } + val yesterday = (today.clone() as Calendar).apply { add(Calendar.DAY_OF_YEAR, -1) } + return PeriodDateRange( + currentStart = today, + currentEnd = tomorrow, + previousStart = yesterday, + previousEnd = today, + quantity = HOURLY_QUANTITY, + unit = StatsUnit.HOUR, + currentDisplayDate = today, + previousDisplayDate = yesterday + ) } private fun calculateCustomPeriodDates(startDate: LocalDate, endDate: LocalDate): PeriodDateRange { From 850022307d71de35230e5b3433a0dd729b143b3c Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:13:50 +0100 Subject: [PATCH 16/23] Extracting common examople code --- .../ui/newstats/viewsstats/ViewsStatsCard.kt | 119 ++++++------------ 1 file changed, 39 insertions(+), 80 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt index ee4b274235b5..f0728629b532 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt @@ -755,50 +755,48 @@ private fun ViewsStatsCardLoadingPreview() { } } +private fun sampleLoadedState() = ViewsStatsCardUiState.Loaded( + currentPeriodViews = 7467, + previousPeriodViews = 8289, + viewsDifference = -822, + viewsPercentageChange = -9.9, + currentPeriodDateRange = "14-20 Jan", + previousPeriodDateRange = "7-13 Jan", + chartData = ViewsStatsChartData( + currentPeriod = listOf( + ChartDataPoint("Jan 14", 800), + ChartDataPoint("Jan 15", 1200), + ChartDataPoint("Jan 16", 950), + ChartDataPoint("Jan 17", 1100), + ChartDataPoint("Jan 18", 1300), + ChartDataPoint("Jan 19", 1017), + ChartDataPoint("Jan 20", 1100) + ), + previousPeriod = listOf( + ChartDataPoint("Jan 7", 1000), + ChartDataPoint("Jan 8", 1400), + ChartDataPoint("Jan 9", 1150), + ChartDataPoint("Jan 10", 1200), + ChartDataPoint("Jan 11", 1350), + ChartDataPoint("Jan 12", 1089), + ChartDataPoint("Jan 13", 1100) + ) + ), + periodAverage = 1066, + bottomStats = listOf( + StatItem("Views", 7467, StatChange.Negative(9.9)), + StatItem("Visitors", 2000, StatChange.Negative(5.6)), + StatItem("Likes", 0, StatChange.NoChange), + StatItem("Comments", 0, StatChange.NoChange), + StatItem("Posts", 5, StatChange.Positive(25.0)) + ) +) + @Preview(showBackground = true) @Composable private fun ViewsStatsCardLoadedPreview() { AppThemeM3 { - ViewsStatsCard( - uiState = ViewsStatsCardUiState.Loaded( - currentPeriodViews = 7467, - previousPeriodViews = 8289, - viewsDifference = -822, - viewsPercentageChange = -9.9, - currentPeriodDateRange = "14-20 Jan", - previousPeriodDateRange = "7-13 Jan", - chartData = ViewsStatsChartData( - currentPeriod = listOf( - ChartDataPoint("Jan 14", 800), - ChartDataPoint("Jan 15", 1200), - ChartDataPoint("Jan 16", 950), - ChartDataPoint("Jan 17", 1100), - ChartDataPoint("Jan 18", 1300), - ChartDataPoint("Jan 19", 1017), - ChartDataPoint("Jan 20", 1100) - ), - previousPeriod = listOf( - ChartDataPoint("Jan 7", 1000), - ChartDataPoint("Jan 8", 1400), - ChartDataPoint("Jan 9", 1150), - ChartDataPoint("Jan 10", 1200), - ChartDataPoint("Jan 11", 1350), - ChartDataPoint("Jan 12", 1089), - ChartDataPoint("Jan 13", 1100) - ) - ), - periodAverage = 1066, - bottomStats = listOf( - StatItem("Views", 7467, StatChange.Negative(9.9)), - StatItem("Visitors", 2000, StatChange.Negative(5.6)), - StatItem("Likes", 0, StatChange.NoChange), - StatItem("Comments", 0, StatChange.NoChange), - StatItem("Posts", 5, StatChange.Positive(25.0)) - ) - ), - onChartTypeChanged = {}, - onRetry = {} - ) + ViewsStatsCard(uiState = sampleLoadedState(), onChartTypeChanged = {}, onRetry = {}) } } @@ -820,45 +818,6 @@ private fun ViewsStatsCardErrorPreview() { @Composable private fun ViewsStatsCardLoadedDarkPreview() { AppThemeM3 { - ViewsStatsCard( - uiState = ViewsStatsCardUiState.Loaded( - currentPeriodViews = 7467, - previousPeriodViews = 8289, - viewsDifference = -822, - viewsPercentageChange = -9.9, - currentPeriodDateRange = "14-20 Jan", - previousPeriodDateRange = "7-13 Jan", - chartData = ViewsStatsChartData( - currentPeriod = listOf( - ChartDataPoint("Jan 14", 800), - ChartDataPoint("Jan 15", 1200), - ChartDataPoint("Jan 16", 950), - ChartDataPoint("Jan 17", 1100), - ChartDataPoint("Jan 18", 1300), - ChartDataPoint("Jan 19", 1017), - ChartDataPoint("Jan 20", 1100) - ), - previousPeriod = listOf( - ChartDataPoint("Jan 7", 1000), - ChartDataPoint("Jan 8", 1400), - ChartDataPoint("Jan 9", 1150), - ChartDataPoint("Jan 10", 1200), - ChartDataPoint("Jan 11", 1350), - ChartDataPoint("Jan 12", 1089), - ChartDataPoint("Jan 13", 1100) - ) - ), - periodAverage = 1066, - bottomStats = listOf( - StatItem("Views", 7467, StatChange.Negative(9.9)), - StatItem("Visitors", 2000, StatChange.Negative(5.6)), - StatItem("Likes", 0, StatChange.NoChange), - StatItem("Comments", 0, StatChange.NoChange), - StatItem("Posts", 5, StatChange.Positive(25.0)) - ) - ), - onChartTypeChanged = {}, - onRetry = {} - ) + ViewsStatsCard(uiState = sampleLoadedState(), onChartTypeChanged = {}, onRetry = {}) } } From 44aaec8c489fb9822f658c0ac9335091a966215c Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:15:26 +0100 Subject: [PATCH 17/23] Somo ktlint fixes --- .../ui/newstats/viewsstats/ViewsStatsCard.kt | 72 ++++++++++--------- .../viewsstats/ViewsStatsViewModel.kt | 12 ++-- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt index f0728629b532..6ddc58f0211c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsCard.kt @@ -91,6 +91,19 @@ private val BadgeCornerRadius = 4.dp private val ChangeBadgePositiveColor = Color(0xFF4CAF50) private val ChangeBadgeNegativeColor = Color(0xFFE91E63) +// Preview sample data constants +private const val SAMPLE_CURRENT_VIEWS = 7467L +private const val SAMPLE_PREVIOUS_VIEWS = 8289L +private const val SAMPLE_VIEWS_DIFFERENCE = -822L +private const val SAMPLE_VIEWS_PERCENTAGE = -9.9 +private const val SAMPLE_VISITORS = 2000L +private const val SAMPLE_VISITORS_PERCENTAGE = 5.6 +private const val SAMPLE_POSTS = 5L +private const val SAMPLE_POSTS_PERCENTAGE = 25.0 +private const val SAMPLE_PERIOD_AVERAGE = 1066L +private val SAMPLE_CURRENT_PERIOD_DATA = listOf(800L, 1200L, 950L, 1100L, 1300L, 1017L, 1100L) +private val SAMPLE_PREVIOUS_PERIOD_DATA = listOf(1000L, 1400L, 1150L, 1200L, 1350L, 1089L, 1100L) + @Composable fun ViewsStatsCard( uiState: ViewsStatsCardUiState, @@ -755,42 +768,35 @@ private fun ViewsStatsCardLoadingPreview() { } } -private fun sampleLoadedState() = ViewsStatsCardUiState.Loaded( - currentPeriodViews = 7467, - previousPeriodViews = 8289, - viewsDifference = -822, - viewsPercentageChange = -9.9, - currentPeriodDateRange = "14-20 Jan", - previousPeriodDateRange = "7-13 Jan", - chartData = ViewsStatsChartData( - currentPeriod = listOf( - ChartDataPoint("Jan 14", 800), - ChartDataPoint("Jan 15", 1200), - ChartDataPoint("Jan 16", 950), - ChartDataPoint("Jan 17", 1100), - ChartDataPoint("Jan 18", 1300), - ChartDataPoint("Jan 19", 1017), - ChartDataPoint("Jan 20", 1100) +private fun sampleLoadedState(): ViewsStatsCardUiState.Loaded { + val currentPeriodLabels = listOf("Jan 14", "Jan 15", "Jan 16", "Jan 17", "Jan 18", "Jan 19", "Jan 20") + val previousPeriodLabels = listOf("Jan 7", "Jan 8", "Jan 9", "Jan 10", "Jan 11", "Jan 12", "Jan 13") + + return ViewsStatsCardUiState.Loaded( + currentPeriodViews = SAMPLE_CURRENT_VIEWS, + previousPeriodViews = SAMPLE_PREVIOUS_VIEWS, + viewsDifference = SAMPLE_VIEWS_DIFFERENCE, + viewsPercentageChange = SAMPLE_VIEWS_PERCENTAGE, + currentPeriodDateRange = "14-20 Jan", + previousPeriodDateRange = "7-13 Jan", + chartData = ViewsStatsChartData( + currentPeriod = currentPeriodLabels.zip(SAMPLE_CURRENT_PERIOD_DATA) { label, views -> + ChartDataPoint(label, views) + }, + previousPeriod = previousPeriodLabels.zip(SAMPLE_PREVIOUS_PERIOD_DATA) { label, views -> + ChartDataPoint(label, views) + } ), - previousPeriod = listOf( - ChartDataPoint("Jan 7", 1000), - ChartDataPoint("Jan 8", 1400), - ChartDataPoint("Jan 9", 1150), - ChartDataPoint("Jan 10", 1200), - ChartDataPoint("Jan 11", 1350), - ChartDataPoint("Jan 12", 1089), - ChartDataPoint("Jan 13", 1100) + periodAverage = SAMPLE_PERIOD_AVERAGE, + bottomStats = listOf( + StatItem("Views", SAMPLE_CURRENT_VIEWS, StatChange.Negative(SAMPLE_VIEWS_PERCENTAGE)), + StatItem("Visitors", SAMPLE_VISITORS, StatChange.Negative(SAMPLE_VISITORS_PERCENTAGE)), + StatItem("Likes", 0, StatChange.NoChange), + StatItem("Comments", 0, StatChange.NoChange), + StatItem("Posts", SAMPLE_POSTS, StatChange.Positive(SAMPLE_POSTS_PERCENTAGE)) ) - ), - periodAverage = 1066, - bottomStats = listOf( - StatItem("Views", 7467, StatChange.Negative(9.9)), - StatItem("Visitors", 2000, StatChange.Negative(5.6)), - StatItem("Likes", 0, StatChange.NoChange), - StatItem("Comments", 0, StatChange.NoChange), - StatItem("Posts", 5, StatChange.Positive(25.0)) ) -) +} @Preview(showBackground = true) @Composable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index 4e906196e932..c36bee4c6ac2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -200,7 +200,7 @@ class ViewsStatsViewModel @Inject constructor( return ((current - previous).toDouble() / previous) * PERCENTAGE_BASE } - @Suppress("TooGenericExceptionCaught", "SwallowedException", "MagicNumber", "ReturnCount") + @Suppress("ReturnCount") private fun formatDataPointLabel(period: String): String { // Try hourly format first (yyyy-MM-dd HH:mm:ss) try { @@ -238,7 +238,6 @@ class ViewsStatsViewModel @Inject constructor( return period } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatDateRangeForPeriod(startDate: String, endDate: String, period: StatsPeriod): String { return when (period) { is StatsPeriod.Today -> formatSingleDayRange(endDate) @@ -248,18 +247,16 @@ class ViewsStatsViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatSingleDayRange(date: String): String { return try { val parsedDate = LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) val outputFormat = DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) parsedDate.format(outputFormat) - } catch (e: Exception) { + } catch (_: Exception) { date } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatMonthRange(startDate: String, endDate: String): String { return try { val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) @@ -271,12 +268,11 @@ class ViewsStatsViewModel @Inject constructor( } else { "${start.format(monthFormat)} - ${end.format(monthFormat)}" } - } catch (e: Exception) { + } catch (_: Exception) { "$startDate - $endDate" } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatDayRange(startDate: String, endDate: String): String { return try { val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) @@ -289,7 +285,7 @@ class ViewsStatsViewModel @Inject constructor( } else { "${start.format(dayMonthFormat)} - ${end.format(dayMonthFormat)}" } - } catch (e: Exception) { + } catch (_: Exception) { "$startDate - $endDate" } } From 50d5fe6eb190b339bd2afe182cd9e78500a3e34e Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:25:47 +0100 Subject: [PATCH 18/23] Fixing month range labels --- .../viewsstats/ViewsStatsViewModel.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index c36bee4c6ac2..81ca0a4944c0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -19,11 +19,13 @@ import org.wordpress.android.viewmodel.ResourceProvider import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.util.Locale import javax.inject.Inject import kotlin.math.abs private const val PERCENTAGE_BASE = 100.0 +private const val DAYS_THRESHOLD_FOR_MONTHLY_DISPLAY = 30 @HiltViewModel class ViewsStatsViewModel @Inject constructor( @@ -119,9 +121,9 @@ class ViewsStatsViewModel @Inject constructor( val currentStats = result.currentAggregates val previousStats = result.previousAggregates val currentDataPoints = result.currentPeriodData - .map { ChartDataPoint(formatDataPointLabel(it.period), it.views) } + .map { ChartDataPoint(formatDataPointLabel(it.period, currentPeriod), it.views) } val previousDataPoints = result.previousPeriodData - .map { ChartDataPoint(formatDataPointLabel(it.period), it.views) } + .map { ChartDataPoint(formatDataPointLabel(it.period, currentPeriod), it.views) } val average = if (currentDataPoints.isNotEmpty()) { currentStats.views / currentDataPoints.size @@ -201,7 +203,11 @@ class ViewsStatsViewModel @Inject constructor( } @Suppress("ReturnCount") - private fun formatDataPointLabel(period: String): String { + private fun formatDataPointLabel(period: String, statsPeriod: StatsPeriod): String { + val isMonthlyPeriod = statsPeriod is StatsPeriod.Last6Months || + statsPeriod is StatsPeriod.Last12Months || + (statsPeriod is StatsPeriod.Custom && isCustomPeriodMonthly(statsPeriod)) + // Try hourly format first (yyyy-MM-dd HH:mm:ss) try { val inputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) @@ -215,7 +221,9 @@ class ViewsStatsViewModel @Inject constructor( // Try daily format (yyyy-MM-dd) try { val date = LocalDate.parse(period, DateTimeFormatter.ISO_LOCAL_DATE) - val outputFormat = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + // For monthly periods, show only month; otherwise show month and day + val pattern = if (isMonthlyPeriod) "MMM" else "MMM d" + val outputFormat = DateTimeFormatter.ofPattern(pattern, Locale.getDefault()) return date.format(outputFormat) } catch (_: Exception) { // Not daily format, continue @@ -238,11 +246,19 @@ class ViewsStatsViewModel @Inject constructor( return period } + private fun isCustomPeriodMonthly(custom: StatsPeriod.Custom): Boolean { + val daysBetween = ChronoUnit.DAYS.between(custom.startDate, custom.endDate) + 1 + return daysBetween > DAYS_THRESHOLD_FOR_MONTHLY_DISPLAY + } + private fun formatDateRangeForPeriod(startDate: String, endDate: String, period: StatsPeriod): String { return when (period) { is StatsPeriod.Today -> formatSingleDayRange(endDate) is StatsPeriod.Last6Months, is StatsPeriod.Last12Months -> formatMonthRange(startDate, endDate) - is StatsPeriod.Custom -> formatDayRange(startDate, endDate) + is StatsPeriod.Custom -> { + if (isCustomPeriodMonthly(period)) formatMonthRange(startDate, endDate) + else formatDayRange(startDate, endDate) + } else -> formatDayRange(startDate, endDate) } } From 0804f2c9e4bec1a4d7da02aadaa9d2c25041545a Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 16:49:10 +0100 Subject: [PATCH 19/23] PR suggestions --- .../viewsstats/ViewsStatsViewModel.kt | 125 +++++++++--------- 1 file changed, 60 insertions(+), 65 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index 81ca0a4944c0..f4e260bbd624 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -15,6 +15,7 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.repository.PeriodStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.PeriodAggregates +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider import java.time.LocalDate import java.time.LocalDateTime @@ -27,6 +28,10 @@ import kotlin.math.abs private const val PERCENTAGE_BASE = 100.0 private const val DAYS_THRESHOLD_FOR_MONTHLY_DISPLAY = 30 +private val HOURLY_FORMAT_REGEX = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""") +private val DAILY_FORMAT_REGEX = Regex("""\d{4}-\d{2}-\d{2}""") +private val MONTHLY_FORMAT_REGEX = Regex("""\d{4}-\d{2}""") + @HiltViewModel class ViewsStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, @@ -128,6 +133,12 @@ class ViewsStatsViewModel @Inject constructor( val average = if (currentDataPoints.isNotEmpty()) { currentStats.views / currentDataPoints.size } else { + if (currentStats.views > 0) { + AppLog.w( + AppLog.T.STATS, + "Data inconsistency: no data points but views=${currentStats.views}" + ) + } 0L } @@ -202,48 +213,35 @@ class ViewsStatsViewModel @Inject constructor( return ((current - previous).toDouble() / previous) * PERCENTAGE_BASE } - @Suppress("ReturnCount") private fun formatDataPointLabel(period: String, statsPeriod: StatsPeriod): String { val isMonthlyPeriod = statsPeriod is StatsPeriod.Last6Months || statsPeriod is StatsPeriod.Last12Months || (statsPeriod is StatsPeriod.Custom && isCustomPeriodMonthly(statsPeriod)) - // Try hourly format first (yyyy-MM-dd HH:mm:ss) - try { - val inputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - val outputFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) - val dateTime = LocalDateTime.parse(period, inputFormat) - return dateTime.format(outputFormat) - } catch (_: Exception) { - // Not hourly format, continue + return when { + period.matches(HOURLY_FORMAT_REGEX) -> formatHourlyLabel(period) + period.matches(DAILY_FORMAT_REGEX) -> formatDailyLabel(period, isMonthlyPeriod) + period.matches(MONTHLY_FORMAT_REGEX) -> formatMonthlyLabel(period) + else -> period } + } - // Try daily format (yyyy-MM-dd) - try { - val date = LocalDate.parse(period, DateTimeFormatter.ISO_LOCAL_DATE) - // For monthly periods, show only month; otherwise show month and day - val pattern = if (isMonthlyPeriod) "MMM" else "MMM d" - val outputFormat = DateTimeFormatter.ofPattern(pattern, Locale.getDefault()) - return date.format(outputFormat) - } catch (_: Exception) { - // Not daily format, continue - } + private fun formatHourlyLabel(period: String): String { + val inputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val outputFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) + return LocalDateTime.parse(period, inputFormat).format(outputFormat) + } - // Try monthly format (yyyy-MM) - try { - val parts = period.split("-") - if (parts.size == 2) { - val year = parts[0].toInt() - val month = parts[1].toInt() - val date = LocalDate.of(year, month, 1) - val outputFormat = DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) - return date.format(outputFormat) - } - } catch (_: Exception) { - // Not monthly format - } + private fun formatDailyLabel(period: String, showMonthOnly: Boolean): String { + val date = LocalDate.parse(period, DateTimeFormatter.ISO_LOCAL_DATE) + val pattern = if (showMonthOnly) "MMM" else "MMM d" + return date.format(DateTimeFormatter.ofPattern(pattern, Locale.getDefault())) + } - return period + private fun formatMonthlyLabel(period: String): String { + val parts = period.split("-") + val date = LocalDate.of(parts[0].toInt(), parts[1].toInt(), 1) + return date.format(DateTimeFormatter.ofPattern("MMM", Locale.getDefault())) } private fun isCustomPeriodMonthly(custom: StatsPeriod.Custom): Boolean { @@ -263,46 +261,43 @@ class ViewsStatsViewModel @Inject constructor( } } - private fun formatSingleDayRange(date: String): String { - return try { - val parsedDate = LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) - val outputFormat = DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) - parsedDate.format(outputFormat) - } catch (_: Exception) { - date + private fun parseDate(dateString: String): LocalDate? { + return if (dateString.matches(DAILY_FORMAT_REGEX)) { + LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE) + } else { + null } } + private fun formatSingleDayRange(date: String): String { + val parsedDate = parseDate(date) ?: return date + return parsedDate.format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault())) + } + + @Suppress("ReturnCount") private fun formatMonthRange(startDate: String, endDate: String): String { - return try { - val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) - val end = LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE) - val monthFormat = DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) - - if (start.month == end.month && start.year == end.year) { - start.format(monthFormat) - } else { - "${start.format(monthFormat)} - ${end.format(monthFormat)}" - } - } catch (_: Exception) { - "$startDate - $endDate" + val start = parseDate(startDate) ?: return "$startDate - $endDate" + val end = parseDate(endDate) ?: return "$startDate - $endDate" + val monthFormat = DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) + + return if (start.month == end.month && start.year == end.year) { + start.format(monthFormat) + } else { + "${start.format(monthFormat)} - ${end.format(monthFormat)}" } } + @Suppress("ReturnCount") private fun formatDayRange(startDate: String, endDate: String): String { - return try { - val start = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) - val end = LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE) - val dayFormat = DateTimeFormatter.ofPattern("d", Locale.getDefault()) - val dayMonthFormat = DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) - - if (start.month == end.month) { - "${start.format(dayFormat)}-${end.format(dayMonthFormat)}" - } else { - "${start.format(dayMonthFormat)} - ${end.format(dayMonthFormat)}" - } - } catch (_: Exception) { - "$startDate - $endDate" + val start = parseDate(startDate) ?: return "$startDate - $endDate" + val end = parseDate(endDate) ?: return "$startDate - $endDate" + val dayFormat = DateTimeFormatter.ofPattern("d", Locale.getDefault()) + val dayMonthFormat = DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) + + return if (start.month == end.month) { + "${start.format(dayFormat)}-${end.format(dayMonthFormat)}" + } else { + "${start.format(dayMonthFormat)} - ${end.format(dayMonthFormat)}" } } From f03262b87aac651c42a0f0324290d4ca6e77426c Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 17:01:59 +0100 Subject: [PATCH 20/23] Custom range date selection improvements --- .../android/ui/newstats/StatsDateRangePickerDialog.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt index 083488ae3459..4fd7943414af 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt @@ -72,7 +72,12 @@ fun StatsDateRangePickerDialog( val endDate = Instant.ofEpochMilli(endMillis) .atZone(ZoneId.systemDefault()) .toLocalDate() - onDateRangeSelected(startDate, endDate) + // Ensure start date is before or equal to end date, swap if needed + if (startDate.isAfter(endDate)) { + onDateRangeSelected(endDate, startDate) + } else { + onDateRangeSelected(startDate, endDate) + } } onDismiss() } From d3b591df7c3372e5aeec6732f10d9c571aefc7ce Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 17:05:33 +0100 Subject: [PATCH 21/23] Saving selected period in a more consistent way --- .../android/ui/newstats/NewStatsActivity.kt | 23 ++++--- .../viewsstats/ViewsStatsViewModel.kt | 60 ++++++++++++++++++- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 14e2b7a69a9e..b4e5084e2197 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -84,18 +83,20 @@ private enum class StatsTab(val titleResId: Int) { private fun NewStatsScreen( onBackPressed: () -> Unit ) { + val viewsStatsViewModel: ViewsStatsViewModel = viewModel() + val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() + val tabs = StatsTab.entries val pagerState = rememberPagerState(pageCount = { tabs.size }) val coroutineScope = rememberCoroutineScope() var showPeriodMenu by remember { mutableStateOf(false) } var showDateRangePicker by remember { mutableStateOf(false) } - var selectedPeriod: StatsPeriod by remember { mutableStateOf(StatsPeriod.Last7Days) } if (showDateRangePicker) { StatsDateRangePickerDialog( onDismiss = { showDateRangePicker = false }, onDateRangeSelected = { startDate, endDate -> - selectedPeriod = StatsPeriod.Custom(startDate, endDate) + viewsStatsViewModel.onPeriodChanged(StatsPeriod.Custom(startDate, endDate)) showDateRangePicker = false } ) @@ -130,7 +131,7 @@ private fun NewStatsScreen( selectedPeriod = selectedPeriod, onDismiss = { showPeriodMenu = false }, onPresetSelected = { period -> - selectedPeriod = period + viewsStatsViewModel.onPeriodChanged(period) showPeriodMenu = false }, onCustomSelected = { @@ -166,16 +167,16 @@ private fun NewStatsScreen( state = pagerState, modifier = Modifier.fillMaxSize() ) { page -> - StatsTabContent(tab = tabs[page], selectedPeriod = selectedPeriod) + StatsTabContent(tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel) } } } } @Composable -private fun StatsTabContent(tab: StatsTab, selectedPeriod: StatsPeriod) { +private fun StatsTabContent(tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel) { when (tab) { - StatsTab.TRAFFIC -> TrafficTabContent(selectedPeriod = selectedPeriod) + StatsTab.TRAFFIC -> TrafficTabContent(viewsStatsViewModel = viewsStatsViewModel) else -> PlaceholderTabContent(tab) } } @@ -183,13 +184,9 @@ private fun StatsTabContent(tab: StatsTab, selectedPeriod: StatsPeriod) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TrafficTabContent( - selectedPeriod: StatsPeriod, - todaysStatsViewModel: TodaysStatsViewModel = viewModel(), - viewsStatsViewModel: ViewsStatsViewModel = viewModel() + viewsStatsViewModel: ViewsStatsViewModel, + todaysStatsViewModel: TodaysStatsViewModel = viewModel() ) { - LaunchedEffect(selectedPeriod) { - viewsStatsViewModel.onPeriodChanged(selectedPeriod) - } val todaysStatsUiState by todaysStatsViewModel.uiState.collectAsState() val viewsStatsUiState by viewsStatsViewModel.uiState.collectAsState() val isTodaysStatsRefreshing by todaysStatsViewModel.isRefreshing.collectAsState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt index f4e260bbd624..54f3628c8b24 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModel.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.viewsstats +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,12 +33,24 @@ private val HOURLY_FORMAT_REGEX = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"" private val DAILY_FORMAT_REGEX = Regex("""\d{4}-\d{2}-\d{2}""") private val MONTHLY_FORMAT_REGEX = Regex("""\d{4}-\d{2}""") +private const val KEY_PERIOD_TYPE = "period_type" +private const val KEY_CUSTOM_START_DATE = "custom_start_date" +private const val KEY_CUSTOM_END_DATE = "custom_end_date" + +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" + @HiltViewModel class ViewsStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider + private val resourceProvider: ResourceProvider, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val _uiState = MutableStateFlow(ViewsStatsCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -45,8 +58,11 @@ class ViewsStatsViewModel @Inject constructor( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val _selectedPeriod = MutableStateFlow(restorePeriod()) + val selectedPeriod: StateFlow = _selectedPeriod.asStateFlow() + private var currentChartType: ChartType = ChartType.LINE - private var currentPeriod: StatsPeriod = StatsPeriod.Last7Days + private var currentPeriod: StatsPeriod = _selectedPeriod.value init { loadData() @@ -55,9 +71,49 @@ class ViewsStatsViewModel @Inject constructor( fun onPeriodChanged(period: StatsPeriod) { if (period == currentPeriod) return currentPeriod = period + _selectedPeriod.value = period + savePeriod(period) loadData() } + private fun savePeriod(period: StatsPeriod) { + when (period) { + is StatsPeriod.Today -> savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_TODAY + is StatsPeriod.Last7Days -> savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_LAST_7_DAYS + is StatsPeriod.Last30Days -> savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_LAST_30_DAYS + is StatsPeriod.Last6Months -> savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_LAST_6_MONTHS + is StatsPeriod.Last12Months -> savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_LAST_12_MONTHS + is StatsPeriod.Custom -> { + savedStateHandle[KEY_PERIOD_TYPE] = PERIOD_CUSTOM + savedStateHandle[KEY_CUSTOM_START_DATE] = period.startDate.toEpochDay() + savedStateHandle[KEY_CUSTOM_END_DATE] = period.endDate.toEpochDay() + } + } + } + + private fun restorePeriod(): StatsPeriod { + return when (savedStateHandle.get(KEY_PERIOD_TYPE)) { + PERIOD_TODAY -> StatsPeriod.Today + PERIOD_LAST_7_DAYS -> StatsPeriod.Last7Days + PERIOD_LAST_30_DAYS -> StatsPeriod.Last30Days + PERIOD_LAST_6_MONTHS -> StatsPeriod.Last6Months + PERIOD_LAST_12_MONTHS -> StatsPeriod.Last12Months + PERIOD_CUSTOM -> { + val startEpochDay = savedStateHandle.get(KEY_CUSTOM_START_DATE) + val endEpochDay = savedStateHandle.get(KEY_CUSTOM_END_DATE) + if (startEpochDay != null && endEpochDay != null) { + StatsPeriod.Custom( + LocalDate.ofEpochDay(startEpochDay), + LocalDate.ofEpochDay(endEpochDay) + ) + } else { + StatsPeriod.Last7Days + } + } + else -> StatsPeriod.Last7Days + } + } + fun onChartTypeChanged(chartType: ChartType) { currentChartType = chartType val currentState = _uiState.value From 23d7a8157c187aef8c05cfa7f9f7bca9ce11afe3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 17:11:12 +0100 Subject: [PATCH 22/23] Using the same datetime function in the repo as in the VM for consistency --- .../ui/newstats/repository/StatsRepository.kt | 140 +++++++----------- 1 file changed, 52 insertions(+), 88 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index edfb7b921f37..eb542bfee5a8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -12,12 +12,9 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.util.AppLog -import java.text.SimpleDateFormat import java.time.LocalDate -import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit -import java.util.Calendar -import java.util.Locale import javax.inject.Inject import javax.inject.Named @@ -39,16 +36,7 @@ class StatsRepository @Inject constructor( private val appLogWrapper: AppLogWrapper, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { - /** - * Thread-local date formatter for thread-safe date formatting. - * SimpleDateFormat is NOT thread-safe, so we use ThreadLocal to provide each thread - * with its own instance, avoiding the overhead of creating new instances on every call. - */ - private val dateFormat = ThreadLocal.withInitial { - SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) - } - - private fun getDateFormat(): SimpleDateFormat = dateFormat.get()!! + private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE fun init(accessToken: String) { statsDataSource.init(accessToken) @@ -61,8 +49,7 @@ class StatsRepository @Inject constructor( * @return Today's aggregated stats or error */ suspend fun fetchTodayAggregates(siteId: Long): TodayAggregatesResult = withContext(ioDispatcher) { - val calendar = Calendar.getInstance() - val dateString = getDateFormat().format(calendar.time) + val dateString = LocalDate.now().format(dateFormatter) val result = statsDataSource.fetchStatsVisits( siteId = siteId, @@ -106,13 +93,11 @@ class StatsRepository @Inject constructor( siteId: Long, offsetDays: Int = 0 ): HourlyViewsResult = withContext(ioDispatcher) { - val calendar = Calendar.getInstance() // The API's endDate is exclusive for hourly queries, so we need to add 1 day to get // the target day's hours. Formula: 1 (for exclusive end) - offsetDays (0=today, 1=yesterday) // Examples: offsetDays=0 → tomorrow's date → fetches today's hours // offsetDays=1 → today's date → fetches yesterday's hours - calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) - val dateString = getDateFormat().format(calendar.time) + val dateString = LocalDate.now().plusDays((1 - offsetDays).toLong()).format(dateFormatter) val result = statsDataSource.fetchStatsVisits( siteId = siteId, @@ -146,7 +131,7 @@ class StatsRepository @Inject constructor( suspend fun fetchWeeklyStats(siteId: Long, weeksAgo: Int = 0): WeeklyStatsResult = withContext(ioDispatcher) { val (startDate, endDate) = calculateWeekDateRange(weeksAgo) - val endDateString = getDateFormat().format(endDate.time) + val endDateString = endDate.format(dateFormatter) val result = statsDataSource.fetchStatsVisits( siteId = siteId, @@ -164,7 +149,7 @@ class StatsRepository @Inject constructor( val totalComments = data.comments.sumOf { it.comments } val totalPosts = data.posts.sumOf { it.posts } - val startDateFormatted = getDateFormat().format(startDate.time) + val startDateFormatted = startDate.format(dateFormatter) val aggregates = PeriodAggregates( views = totalViews, @@ -195,7 +180,7 @@ class StatsRepository @Inject constructor( suspend fun fetchDailyViewsForWeek(siteId: Long, weeksAgo: Int = 0): DailyViewsResult = withContext(ioDispatcher) { val (_, endDate) = calculateWeekDateRange(weeksAgo) - val endDateString = getDateFormat().format(endDate.time) + val endDateString = endDate.format(dateFormatter) val result = statsDataSource.fetchStatsVisits( siteId = siteId, @@ -232,7 +217,7 @@ class StatsRepository @Inject constructor( weeksAgo: Int = 0 ): WeeklyStatsWithDailyDataResult = withContext(ioDispatcher) { val (startDate, endDate) = calculateWeekDateRange(weeksAgo) - val endDateString = getDateFormat().format(endDate.time) + val endDateString = endDate.format(dateFormatter) val result = statsDataSource.fetchStatsVisits( siteId = siteId, @@ -251,7 +236,7 @@ class StatsRepository @Inject constructor( val totalLikes = data.likes.sumOf { it.likes } val totalComments = data.comments.sumOf { it.comments } val totalPosts = data.posts.sumOf { it.posts } - val startDateFormatted = getDateFormat().format(startDate.time) + val startDateFormatted = startDate.format(dateFormatter) val aggregates = PeriodAggregates( views = totalViews, @@ -294,8 +279,8 @@ class StatsRepository @Inject constructor( ): PeriodStatsResult = withContext(ioDispatcher) { val periodRange = calculatePeriodDates(period) - val currentEndString = getDateFormat().format(periodRange.currentEnd.time) - val previousEndString = getDateFormat().format(periodRange.previousEnd.time) + val currentEndString = periodRange.currentEnd.format(dateFormatter) + val previousEndString = periodRange.previousEnd.format(dateFormatter) // Fetch both periods in parallel for better performance val (currentResult, previousResult) = coroutineScope { @@ -332,18 +317,17 @@ class StatsRepository @Inject constructor( previousData: StatsVisitsData, periodRange: PeriodDateRange ): PeriodStatsResult.Success { - val dateFormat = getDateFormat() - val currentDisplayDateString = dateFormat.format(periodRange.currentDisplayDate.time) - val previousDisplayDateString = dateFormat.format(periodRange.previousDisplayDate.time) + val currentDisplayDateString = periodRange.currentDisplayDate.format(dateFormatter) + val previousDisplayDateString = periodRange.previousDisplayDate.format(dateFormatter) val currentAggregates = buildPeriodAggregates( currentData, - dateFormat.format(periodRange.currentStart.time), + periodRange.currentStart.format(dateFormatter), currentDisplayDateString ) val previousAggregates = buildPeriodAggregates( previousData, - dateFormat.format(periodRange.previousStart.time), + periodRange.previousStart.format(dateFormatter), previousDisplayDateString ) val currentPeriodData = currentData.visits.map { dataPoint -> @@ -391,18 +375,20 @@ class StatsRepository @Inject constructor( } private data class PeriodDateRange( - val currentStart: Calendar, - val currentEnd: Calendar, - val previousStart: Calendar, - val previousEnd: Calendar, + val currentStart: LocalDate, + val currentEnd: LocalDate, + val previousStart: LocalDate, + val previousEnd: LocalDate, val quantity: Int, val unit: StatsUnit, // Display dates for the legend (may differ from API dates for hourly queries) - val currentDisplayDate: Calendar = currentEnd, - val previousDisplayDate: Calendar = previousEnd + val currentDisplayDate: LocalDate = currentEnd, + val previousDisplayDate: LocalDate = previousEnd ) - private data class PeriodConfig(val quantity: Int, val unit: StatsUnit, val calendarField: Int) + private enum class DateUnit { DAY, MONTH } + + private data class PeriodConfig(val quantity: Int, val unit: StatsUnit, val dateUnit: DateUnit) @Suppress("ReturnCount") private fun calculatePeriodDates(period: StatsPeriod): PeriodDateRange { @@ -410,25 +396,27 @@ class StatsRepository @Inject constructor( if (period is StatsPeriod.Custom) return calculateCustomPeriodDates(period.startDate, period.endDate) val config = getPeriodConfig(period) - val currentEnd = Calendar.getInstance() - val currentStart = (currentEnd.clone() as Calendar).apply { - add(config.calendarField, -(config.quantity - 1)) - } - val previousEnd = (currentStart.clone() as Calendar).apply { - add(config.calendarField, -1) - } - val previousStart = (previousEnd.clone() as Calendar).apply { - add(config.calendarField, -(config.quantity - 1)) - } + val currentEnd = LocalDate.now() + val currentStart = subtractFromDate(currentEnd, config.quantity - 1, config.dateUnit) + val previousEnd = subtractFromDate(currentStart, 1, config.dateUnit) + val previousStart = subtractFromDate(previousEnd, config.quantity - 1, config.dateUnit) + return PeriodDateRange(currentStart, currentEnd, previousStart, previousEnd, config.quantity, config.unit) } + private fun subtractFromDate(date: LocalDate, amount: Int, unit: DateUnit): LocalDate { + return when (unit) { + DateUnit.DAY -> date.minusDays(amount.toLong()) + DateUnit.MONTH -> date.minusMonths(amount.toLong()) + } + } + private fun getPeriodConfig(period: StatsPeriod): PeriodConfig = when (period) { - is StatsPeriod.Last7Days -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) - is StatsPeriod.Last30Days -> PeriodConfig(DAYS_IN_30_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) - is StatsPeriod.Last6Months -> PeriodConfig(MONTHS_IN_6_MONTHS, StatsUnit.MONTH, Calendar.MONTH) - is StatsPeriod.Last12Months -> PeriodConfig(MONTHS_IN_12_MONTHS, StatsUnit.MONTH, Calendar.MONTH) - else -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, Calendar.DAY_OF_YEAR) // Fallback to 7 days + is StatsPeriod.Last7Days -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, DateUnit.DAY) + is StatsPeriod.Last30Days -> PeriodConfig(DAYS_IN_30_DAYS, StatsUnit.DAY, DateUnit.DAY) + is StatsPeriod.Last6Months -> PeriodConfig(MONTHS_IN_6_MONTHS, StatsUnit.MONTH, DateUnit.MONTH) + is StatsPeriod.Last12Months -> PeriodConfig(MONTHS_IN_12_MONTHS, StatsUnit.MONTH, DateUnit.MONTH) + else -> PeriodConfig(DAYS_IN_7_DAYS, StatsUnit.DAY, DateUnit.DAY) // Fallback to 7 days } /** @@ -436,9 +424,9 @@ class StatsRepository @Inject constructor( * The API's endDate is exclusive for hourly queries, so we use tomorrow as end date for today's hours. */ private fun calculateTodayPeriodDates(): PeriodDateRange { - val today = Calendar.getInstance() - val tomorrow = (today.clone() as Calendar).apply { add(Calendar.DAY_OF_YEAR, 1) } - val yesterday = (today.clone() as Calendar).apply { add(Calendar.DAY_OF_YEAR, -1) } + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val yesterday = today.minusDays(1) return PeriodDateRange( currentStart = today, currentEnd = tomorrow, @@ -454,17 +442,8 @@ class StatsRepository @Inject constructor( private fun calculateCustomPeriodDates(startDate: LocalDate, endDate: LocalDate): PeriodDateRange { val daysBetween = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1 - // Convert LocalDate to Calendar - val currentStart = localDateToCalendar(startDate) - val currentEnd = localDateToCalendar(endDate) - - // Calculate previous period with same duration - val previousEnd = (currentStart.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, -1) - } - val previousStart = (previousEnd.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, -(daysBetween - 1)) - } + val previousEnd = startDate.minusDays(1) + val previousStart = previousEnd.minusDays(daysBetween.toLong() - 1) // Determine unit based on range val unit = when { @@ -473,7 +452,6 @@ class StatsRepository @Inject constructor( } val quantity = if (unit == StatsUnit.MONTH) { - // Calculate months between dates val monthsBetween = ChronoUnit.MONTHS.between(startDate, endDate).toInt() + 1 monthsBetween.coerceAtLeast(1) } else { @@ -481,8 +459,8 @@ class StatsRepository @Inject constructor( } return PeriodDateRange( - currentStart = currentStart, - currentEnd = currentEnd, + currentStart = startDate, + currentEnd = endDate, previousStart = previousStart, previousEnd = previousEnd, quantity = quantity, @@ -490,29 +468,15 @@ class StatsRepository @Inject constructor( ) } - private fun localDateToCalendar(localDate: LocalDate): Calendar { - return Calendar.getInstance().apply { - time = java.util.Date.from( - localDate.atStartOfDay(ZoneId.systemDefault()).toInstant() - ) - } - } - /** * Calculates the start and end dates for a given week. * * @param weeksAgo Number of weeks to go back (0 = current week, 1 = previous week) - * @return Pair of (startDate, endDate) Calendars representing the 7-day period + * @return Pair of (startDate, endDate) LocalDates representing the 7-day period */ - private fun calculateWeekDateRange(weeksAgo: Int): Pair { - val endDate = Calendar.getInstance().apply { - add(Calendar.WEEK_OF_YEAR, -weeksAgo) - } - - val startDate = (endDate.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, DAYS_BEFORE_END_DATE) - } - + private fun calculateWeekDateRange(weeksAgo: Int): Pair { + val endDate = LocalDate.now().minusWeeks(weeksAgo.toLong()) + val startDate = endDate.plusDays(DAYS_BEFORE_END_DATE.toLong()) return startDate to endDate } } From dd2b56392ac064aca3cf31ae334577c3fd9ae7be Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 23 Jan 2026 17:12:49 +0100 Subject: [PATCH 23/23] Fixing tests --- .../android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt index 9ac326d009bf..c0a0cb60986e 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/viewsstats/ViewsStatsViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.viewsstats +import androidx.lifecycle.SavedStateHandle import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -75,7 +76,8 @@ class ViewsStatsViewModelTest : BaseUnitTest() { selectedSiteRepository, accountStore, statsRepository, - resourceProvider + resourceProvider, + SavedStateHandle() ) }