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..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 @@ -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 @@ -76,9 +83,24 @@ 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) } + + if (showDateRangePicker) { + StatsDateRangePickerDialog( + onDismiss = { showDateRangePicker = false }, + onDateRangeSelected = { startDate, endDate -> + viewsStatsViewModel.onPeriodChanged(StatsPeriod.Custom(startDate, endDate)) + showDateRangePicker = false + } + ) + } Scaffold( topBar = { @@ -93,6 +115,31 @@ 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 }, + onPresetSelected = { period -> + viewsStatsViewModel.onPeriodChanged(period) + showPeriodMenu = false + }, + onCustomSelected = { + showPeriodMenu = false + showDateRangePicker = true + } + ) + } } ) } @@ -120,16 +167,16 @@ private fun NewStatsScreen( state = pagerState, modifier = Modifier.fillMaxSize() ) { page -> - StatsTabContent(tab = tabs[page]) + StatsTabContent(tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel) } } } } @Composable -private fun StatsTabContent(tab: StatsTab) { +private fun StatsTabContent(tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel) { when (tab) { - StatsTab.TRAFFIC -> TrafficTabContent() + StatsTab.TRAFFIC -> TrafficTabContent(viewsStatsViewModel = viewsStatsViewModel) else -> PlaceholderTabContent(tab) } } @@ -137,8 +184,8 @@ private fun StatsTabContent(tab: StatsTab) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TrafficTabContent( - todaysStatsViewModel: TodaysStatsViewModel = viewModel(), - viewsStatsViewModel: ViewsStatsViewModel = viewModel() + viewsStatsViewModel: ViewsStatsViewModel, + todaysStatsViewModel: TodaysStatsViewModel = viewModel() ) { val todaysStatsUiState by todaysStatsViewModel.uiState.collectAsState() val viewsStatsUiState by viewsStatsViewModel.uiState.collectAsState() @@ -189,6 +236,45 @@ private fun PlaceholderTabContent(tab: StatsTab) { } } +@Composable +private fun StatsPeriodMenu( + expanded: Boolean, + selectedPeriod: StatsPeriod, + onDismiss: () -> Unit, + onPresetSelected: (StatsPeriod) -> Unit, + onCustomSelected: () -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss + ) { + // Show preset periods + StatsPeriod.presets().forEach { period -> + val isSelected = selectedPeriod == period + DropdownMenuItem( + text = { Text(text = stringResource(id = period.labelResId)) }, + onClick = { onPresetSelected(period) }, + trailingIcon = if (isSelected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + } + ) + } + // 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 + } + ) + } +} + @Preview @Composable fun NewStatsScreenPreview() { 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..4fd7943414af --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsDateRangePickerDialog.kt @@ -0,0 +1,145 @@ +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() + // 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() + } + ) + } + } +} + +@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 new file mode 100644 index 000000000000..58c4a80f3107 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.ui.newstats + +import androidx.annotation.StringRes +import org.wordpress.android.R +import java.time.LocalDate + +/** + * 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/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..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 @@ -1,16 +1,20 @@ 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 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 -import java.util.Locale +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Named @@ -18,6 +22,10 @@ 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_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. @@ -28,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) @@ -50,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, @@ -95,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, @@ -135,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, @@ -153,9 +149,9 @@ 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 = WeeklyAggregates( + val aggregates = PeriodAggregates( views = totalViews, visitors = totalVisitors, likes = totalLikes, @@ -184,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, @@ -196,7 +192,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) } @@ -221,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, @@ -240,9 +236,9 @@ 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 = WeeklyAggregates( + val aggregates = PeriodAggregates( views = totalViews, visitors = totalVisitors, likes = totalLikes, @@ -254,7 +250,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) @@ -271,20 +267,216 @@ class StatsRepository @Inject constructor( } /** - * Calculates the start and end dates for a given week. + * Fetches stats data for a specific period with comparison to the previous period. * - * @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 + * @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 currentEndString = periodRange.currentEnd.format(dateFormatter) + val previousEndString = periodRange.previousEnd.format(dateFormatter) + + // Fetch both periods in parallel for better performance + val (currentResult, previousResult) = coroutineScope { + val currentDeferred = async { + statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = periodRange.unit, + quantity = periodRange.quantity, + endDate = currentEndString + ) + } + val previousDeferred = async { + statsDataSource.fetchStatsVisits( + siteId = siteId, + unit = periodRange.unit, + quantity = periodRange.quantity, + endDate = previousEndString + ) + } + currentDeferred.await() to previousDeferred.await() + } + + if (currentResult is StatsVisitsDataResult.Success && + previousResult is StatsVisitsDataResult.Success + ) { + buildPeriodStatsSuccess(currentResult.data, previousResult.data, periodRange) + } else { + buildPeriodStatsError(currentResult, previousResult) + } + } + + private fun buildPeriodStatsSuccess( + currentData: StatsVisitsData, + previousData: StatsVisitsData, + periodRange: PeriodDateRange + ): PeriodStatsResult.Success { + val currentDisplayDateString = periodRange.currentDisplayDate.format(dateFormatter) + val previousDisplayDateString = periodRange.previousDisplayDate.format(dateFormatter) + + val currentAggregates = buildPeriodAggregates( + currentData, + periodRange.currentStart.format(dateFormatter), + currentDisplayDateString + ) + val previousAggregates = buildPeriodAggregates( + previousData, + periodRange.previousStart.format(dateFormatter), + 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( + data: StatsVisitsData, + startDate: String, + endDate: String + ): PeriodAggregates { + return PeriodAggregates( + 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: 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: LocalDate = currentEnd, + val previousDisplayDate: LocalDate = previousEnd + ) + + 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 { + if (period is StatsPeriod.Today) return calculateTodayPeriodDates() + if (period is StatsPeriod.Custom) return calculateCustomPeriodDates(period.startDate, period.endDate) + + val config = getPeriodConfig(period) + 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, 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 + } + + /** + * 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 calculateWeekDateRange(weeksAgo: Int): Pair { - val endDate = Calendar.getInstance().apply { - add(Calendar.WEEK_OF_YEAR, -weeksAgo) + private fun calculateTodayPeriodDates(): PeriodDateRange { + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val yesterday = today.minusDays(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 { + val daysBetween = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1 + + val previousEnd = startDate.minusDays(1) + val previousStart = previousEnd.minusDays(daysBetween.toLong() - 1) + + // Determine unit based on range + val unit = when { + daysBetween <= DAYS_IN_30_DAYS -> StatsUnit.DAY + else -> StatsUnit.MONTH } - val startDate = (endDate.clone() as Calendar).apply { - add(Calendar.DAY_OF_YEAR, DAYS_BEFORE_END_DATE) + val quantity = if (unit == StatsUnit.MONTH) { + val monthsBetween = ChronoUnit.MONTHS.between(startDate, endDate).toInt() + 1 + monthsBetween.coerceAtLeast(1) + } else { + daysBetween } + return PeriodDateRange( + currentStart = startDate, + currentEnd = endDate, + previousStart = previousStart, + previousEnd = previousEnd, + quantity = quantity, + unit = unit + ) + } + + /** + * 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) LocalDates representing the 7-day period + */ + 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 } } @@ -327,14 +519,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, @@ -348,14 +540,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 ) @@ -366,8 +558,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 data points for both current and previous periods. + */ +sealed class PeriodStatsResult { + data class Success( + 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..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, @@ -243,7 +256,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 +308,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 +333,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 +473,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 +507,7 @@ private fun ViewsStatsChart( } } - if (chartData.currentWeek.isEmpty()) { + if (chartData.currentPeriod.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -515,8 +528,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 +537,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 @@ -755,50 +768,41 @@ private fun ViewsStatsCardLoadingPreview() { } } +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) + } + ), + 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)) + ) + ) +} + @Preview(showBackground = true) @Composable private fun ViewsStatsCardLoadedPreview() { AppThemeM3 { - ViewsStatsCard( - uiState = ViewsStatsCardUiState.Loaded( - currentWeekViews = 7467, - previousWeekViews = 8289, - viewsDifference = -822, - viewsPercentageChange = -9.9, - currentWeekDateRange = "14-20 Jan", - previousWeekDateRange = "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) - ), - 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) - ) - ), - weeklyAverage = 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 +824,6 @@ private fun ViewsStatsCardErrorPreview() { @Composable private fun ViewsStatsCardLoadedDarkPreview() { AppThemeM3 { - ViewsStatsCard( - uiState = ViewsStatsCardUiState.Loaded( - currentWeekViews = 7467, - previousWeekViews = 8289, - viewsDifference = -822, - viewsPercentageChange = -9.9, - currentWeekDateRange = "14-20 Jan", - previousWeekDateRange = "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) - ), - 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) - ) - ), - weeklyAverage = 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 = {}) } } 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 0bca63412a3e..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,10 +1,9 @@ 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 -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -13,26 +12,45 @@ 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.ui.newstats.repository.PeriodAggregates +import org.wordpress.android.util.AppLog 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 CURRENT_WEEK = 0 -private const val PREVIOUS_WEEK = 1 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}""") + +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() @@ -40,12 +58,62 @@ 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 = _selectedPeriod.value init { loadData() } + 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 @@ -91,19 +159,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,78 +178,77 @@ 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.currentPeriodData + .map { ChartDataPoint(formatDataPointLabel(it.period, currentPeriod), it.views) } + val previousDataPoints = result.previousPeriodData + .map { ChartDataPoint(formatDataPointLabel(it.period, currentPeriod), it.views) } + + 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 } 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), + currentPeriodViews = currentStats.views, + previousPeriodViews = previousStats.views, + viewsDifference = currentStats.views - previousStats.views, + viewsPercentageChange = calculatePercentageChange(currentStats.views, previousStats.views), + currentPeriodDateRange = formatDateRangeForPeriod( + currentStats.startDate, + currentStats.endDate, + currentPeriod + ), + previousPeriodDateRange = formatDateRangeForPeriod( + previousStats.startDate, + previousStats.endDate, + currentPeriod + ), + 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) ) ) } @@ -204,32 +269,91 @@ class ViewsStatsViewModel @Inject constructor( return ((current - previous).toDouble() / previous) * PERCENTAGE_BASE } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - private fun formatDayLabel(period: String): String { - return try { - val date = LocalDate.parse(period, DateTimeFormatter.ISO_LOCAL_DATE) - val outputFormat = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) - date.format(outputFormat) - } catch (e: Exception) { - period + private fun formatDataPointLabel(period: String, statsPeriod: StatsPeriod): String { + val isMonthlyPeriod = statsPeriod is StatsPeriod.Last6Months || + statsPeriod is StatsPeriod.Last12Months || + (statsPeriod is StatsPeriod.Custom && isCustomPeriodMonthly(statsPeriod)) + + 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 } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - private fun formatDateRange(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()) + 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) + } + + 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())) + } - if (start.month == end.month) { - "${start.format(dayFormat)}-${end.format(dayMonthFormat)}" - } else { - "${start.format(dayMonthFormat)} - ${end.format(dayMonthFormat)}" + 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 { + 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 -> { + if (isCustomPeriodMonthly(period)) formatMonthRange(startDate, endDate) + else formatDayRange(startDate, endDate) } - } catch (e: Exception) { - "$startDate - $endDate" + else -> formatDayRange(startDate, endDate) + } + } + + 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 { + 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 { + 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)}" } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index d6e74987f4db..ed424c696fb9 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1558,6 +1558,16 @@ Failed to load stats Unknown error + + Today + Last 7 days + Last 30 days + Last 6 months + Last 12 months + Custom + Select stats period + Select date range + Open Website Mark as Spam 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..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 @@ -7,7 +8,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 +17,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") @@ -74,7 +76,8 @@ class ViewsStatsViewModelTest : BaseUnitTest() { selectedSiteRepository, accountStore, statsRepository, - resourceProvider + resourceProvider, + SavedStateHandle() ) } @@ -92,25 +95,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 +109,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 +128,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 +140,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 +158,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 +175,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 +195,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 +208,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 +223,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 +253,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 +270,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 +286,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 +302,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 +316,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 +343,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 +469,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"