diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt index 1d073c497e..5dfd640fb0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt @@ -32,6 +32,12 @@ data class TokenUsageActivityDayRow( val tokens: Long, ) +data class TokenUsageActivityHourRow( + val localDate: String, + val localHour: Int, + val tokens: Long, +) + @Dao abstract class TokenUsageDao { @@ -189,4 +195,35 @@ abstract class TokenUsageDao { providerModels: List, allModels: Boolean, ): List + + @Query( + """ + SELECT + strftime('%Y-%m-%d', occurredAtMs / 1000, 'unixepoch', 'localtime') AS localDate, + CAST(strftime('%H', occurredAtMs / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localHour, + COALESCE(SUM( + COALESCE( + totalInputTokens, + CASE + WHEN uncachedInputTokens IS NOT NULL + AND cachedInputTokens IS NOT NULL + AND cacheWriteTokens IS NOT NULL + THEN uncachedInputTokens + cachedInputTokens + cacheWriteTokens + END, + 0 + ) + COALESCE(outputTokens, 0) + ), 0) AS tokens + FROM token_usage_records + WHERE occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + GROUP BY localDate, localHour, configId, provider, model + ORDER BY localDate, localHour, provider, model, configId + """ + ) + abstract suspend fun getActivityHoursInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + ): List } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt index 0fe9b1386c..8e9e17e3ab 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -1,15 +1,17 @@ package com.ai.assistance.operit.data.stats import java.time.LocalDate +import java.time.LocalDateTime +import java.time.YearMonth import java.time.ZoneId import java.time.temporal.ChronoUnit import kotlin.math.ceil - -enum class TokenActivityViewMode { DAILY, WEEKLY, CUMULATIVE } +enum class TokenActivityViewMode { DAILY, WEEKLY, MONTHLY, YEARLY, CUMULATIVE } internal data class TokenActivitySnapshot( val zone: ZoneId, val dayTotals: Map, + val hourTotals: Map = emptyMap(), ) data class TokenActivityDay(val date: LocalDate, val tokens: Long, val level: Int) @@ -21,6 +23,28 @@ data class TokenActivityWeek( val barHeight: Int, ) +data class TokenActivityMonth( + val startDate: LocalDate, + val tokens: Long, + val level: Int, + val barHeight: Int, +) + +data class TokenActivityYear( + val startDate: LocalDate, + val tokens: Long, + val level: Int, + val barHeight: Int, +) + +data class TokenActivityHour( + val startDate: LocalDate, + val hour: Int, + val tokens: Long, + val level: Int, + val barHeight: Int, +) + data class TokenActivityStats( val totalTokens: Long = 0L, val peakTokens: Long = 0L, @@ -31,23 +55,27 @@ data class TokenActivityStats( data class TokenActivityRangeData( val daily: List, val weekly: List, + val monthly: List, + val yearly: List, + val hourly: List, val cumulative: List, val stats: TokenActivityStats, ) object TokenActivityAggregator { - /** Builds all three activity views from the same explicit calendar range. */ + /** Builds all activity views from the same explicit calendar range. */ internal fun rangeData( snapshot: TokenActivitySnapshot, range: TokenStatsTimeRange, ): TokenActivityRangeData { val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(snapshot.zone).toLocalDate() val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(snapshot.zone).toLocalDate() - return rangeData(snapshot.dayTotals, start, end) + return rangeData(snapshot.dayTotals, snapshot.hourTotals, start, end) } private fun rangeData( dayTotals: Map, + hourTotals: Map, start: LocalDate, end: LocalDate, ): TokenActivityRangeData { @@ -67,26 +95,92 @@ object TokenActivityAggregator { val cumulativeLevels = QuantileLevels.from(cumulativeRaw.map(TokenActivityDay::tokens)) val cumulative = cumulativeRaw.map { it.copy(level = cumulativeLevels.level(it.tokens)) } - val firstWeek = start.minusDays((start.dayOfWeek.value % 7).toLong()) - val lastWeek = end.minusDays((end.dayOfWeek.value % 7).toLong()) - val weekCount = ChronoUnit.WEEKS.between(firstWeek, lastWeek).toInt() + 1 + // Rolling 7-day buckets aligned to the range start: a week runs from the + // range's first day, matching the mode policy that ends the window today + // and starts seven days earlier. The final bucket may be shorter when the + // range is not an exact multiple of seven days. + val weekCount = ((dayCount + 6) / 7).coerceAtLeast(1) val weekTotals = LongArray(weekCount) raw.forEach { day -> - val weekStart = day.date.minusDays((day.date.dayOfWeek.value % 7).toLong()) - val index = ChronoUnit.WEEKS.between(firstWeek, weekStart).toInt() + val index = (ChronoUnit.DAYS.between(start, day.date).toInt() / 7) + .coerceIn(0, weekCount - 1) weekTotals[index] = TokenCostCalculator.saturatedAdd(weekTotals[index], day.tokens) } val weekLevels = QuantileLevels.from(weekTotals.toList()) val heights = barHeights(weekTotals.toList()) val weekly = List(weekCount) { index -> TokenActivityWeek( - startDate = firstWeek.plusWeeks(index.toLong()), + startDate = start.plusDays(index.toLong() * 7L), tokens = weekTotals[index], level = weekLevels.level(weekTotals[index]), barHeight = heights[index], ) } - return TokenActivityRangeData(daily, weekly, cumulative, stats(raw)) + + val monthTotals = linkedMapOf() + raw.forEach { day -> + val key = YearMonth.from(day.date) + monthTotals[key] = TokenCostCalculator.saturatedAdd(monthTotals[key] ?: 0L, day.tokens) + } + val monthLevels = QuantileLevels.from(monthTotals.values.toList()) + val monthHeights = barHeights(monthTotals.values.toList()) + val monthly = monthTotals.entries.mapIndexed { index, (yearMonth, tokens) -> + TokenActivityMonth( + startDate = yearMonth.atDay(1), + tokens = tokens, + level = monthLevels.level(tokens), + barHeight = monthHeights[index], + ) + } + + val yearTotals = linkedMapOf() + raw.forEach { day -> + val key = day.date.year + yearTotals[key] = TokenCostCalculator.saturatedAdd(yearTotals[key] ?: 0L, day.tokens) + } + val yearLevels = QuantileLevels.from(yearTotals.values.toList()) + val yearHeights = barHeights(yearTotals.values.toList()) + val yearly = yearTotals.entries.mapIndexed { index, (year, tokens) -> + TokenActivityYear( + startDate = LocalDate.of(year, 1, 1), + tokens = tokens, + level = yearLevels.level(tokens), + barHeight = yearHeights[index], + ) + } + + // Hourly buckets are only meaningful for single-day views (24 bars). Keep the + // range short to avoid wasteful computation for weekly/monthly/yearly ranges. + val hourly = if (dayCount <= 2 && hourTotals.isNotEmpty()) { + val hourEntries = buildList { + var current = start.atStartOfDay() + val endExclusive = end.plusDays(1L).atStartOfDay() + while (current < endExclusive) { + add( + TokenActivityHour( + startDate = current.toLocalDate(), + hour = current.hour, + tokens = hourTotals[current] ?: 0L, + level = 0, + barHeight = 0, + ) + ) + current = current.plusHours(1L) + } + } + val hourLevels = QuantileLevels.from(hourEntries.map(TokenActivityHour::tokens)) + val hourHeights = barHeights(hourEntries.map(TokenActivityHour::tokens)) + hourEntries.mapIndexed { index, entry -> + entry.copy( + level = hourLevels.level(entry.tokens), + barHeight = hourHeights[index], + ) + } + } else { + emptyList() + } + + return TokenActivityRangeData(daily, weekly, monthly, yearly, hourly, cumulative, stats(raw)) } private fun stats(days: List): TokenActivityStats { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 4a2793d1d4..6d9e4474fc 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.dao.TokenUsageActivityDayRow +import com.ai.assistance.operit.data.dao.TokenUsageActivityHourRow import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow import com.ai.assistance.operit.data.model.TokenStatsModelEntity import com.ai.assistance.operit.data.model.normalizeProviderModel @@ -107,12 +108,24 @@ object TokenStatsQueryService { providerModels = params.providerModels.queryValues(), allModels = params.providerModels == null, ) + val hours = dao.getActivityHoursInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + ) TokenActivitySnapshot( zone = zone, dayTotals = days.groupBy(TokenUsageActivityDayRow::localDate).mapValues { (_, rows) -> rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } }.mapKeys { (date, _) -> LocalDate.parse(date) }, + hourTotals = + hours.groupBy { it.localDate to it.localHour }.mapValues { (_, rows) -> + rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } + }.mapKeys { (key, _) -> + LocalDate.parse(key.first).atTime(key.second, 0) + }, ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/ActivityDateRangePolicy.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/ActivityDateRangePolicy.kt index 3fb8fe5eed..0b744c9802 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/ActivityDateRangePolicy.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/ActivityDateRangePolicy.kt @@ -3,32 +3,47 @@ package com.ai.assistance.operit.ui.features.tokenstats import com.ai.assistance.operit.data.stats.TokenActivityViewMode import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges -import java.time.Instant import java.time.LocalDate import java.time.ZoneId -internal fun activityRangeAnchorDate( - range: TokenStatsTimeRange, - zone: ZoneId, -): LocalDate = Instant.ofEpochMilli(range.endMs - 1L).atZone(zone).toLocalDate() - internal fun activityRangeForMode( mode: TokenActivityViewMode, anchorDate: LocalDate, historyStartDate: LocalDate?, zone: ZoneId, ): TokenStatsTimeRange? { - val startDate = when (mode) { - TokenActivityViewMode.DAILY -> anchorDate + // Each periodic mode covers the most recent complete period, because future + // time has not happened yet and cannot be counted. Weekly, monthly and yearly + // mirror each other: weekly runs from the same weekday last week through + // yesterday (exactly seven days); monthly runs from the same day last month + // through yesterday, so its length equals the previous month's day count (a + // 28-day February yields four week bars, any longer month yields five); + // yearly runs from the same day last year through yesterday. + val (startDate, inclusiveEndDate) = when (mode) { + TokenActivityViewMode.DAILY -> anchorDate to anchorDate TokenActivityViewMode.WEEKLY -> { - // Keep the range aligned with TokenActivityAggregator's Sunday-first weeks. - anchorDate.minusDays((anchorDate.dayOfWeek.value % 7).toLong()) + val start = anchorDate.minusDays(7L) + start to anchorDate.minusDays(1L) + } + TokenActivityViewMode.MONTHLY -> { + // minusMonths clamps month-end dates (e.g. Mar 31 -> Feb 28/29). + val start = anchorDate.minusMonths(1L) + start to anchorDate.minusDays(1L) + } + TokenActivityViewMode.YEARLY -> { + // Rolling year mirroring weekly/monthly: same day last year through + // yesterday. minusYears clamps leap days (e.g. Feb 29 -> Feb 28). + val start = anchorDate.minusYears(1L) + start to anchorDate.minusDays(1L) + } + TokenActivityViewMode.CUMULATIVE -> { + val start = historyStartDate ?: return null + start to anchorDate } - TokenActivityViewMode.CUMULATIVE -> historyStartDate ?: return null } - if (startDate.isAfter(anchorDate)) return null + if (startDate.isAfter(inclusiveEndDate)) return null return TokenStatsTimeRanges.customRange( startDate.atStartOfDay(zone).toInstant().toEpochMilli(), - anchorDate.plusDays(1L).atStartOfDay(zone).toInstant().toEpochMilli(), + inclusiveEndDate.plusDays(1L).atStartOfDay(zone).toInstant().toEpochMilli(), ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt index 9612fd047c..ff8aff442a 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -69,11 +69,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull /** - * 活跃记录卡(信息架构重构版,设计规范 §6.5): - * - 标题行右侧展示当前连续 / 最长连续胶囊(数据来自 activity.stats); - * - 热力图左侧固定星期标签(一~日),与网格同步滚动方向无关; - * - 热力色阶 = 未激活灰格 + 主色 5 档透明度阶; - * - 视图模式(每日/每周/累计)由页面顶部三段式选择器驱动,本卡只负责呈现。 + * 活跃记录区(信息架构重构版,设计规范 §6.5): + * - 主卡按模式展示柱状图/折线图:每日=24 小时柱、每周=7 天柱、每月=周柱、 + * 每年=月柱、累计=折线; + * - 独立的“活跃日历”热力图卡固定在下方,展示最近一年的日历网格,与模式解耦。 */ @Composable internal fun TokenActivitySection( @@ -83,34 +82,65 @@ internal fun TokenActivitySection( val locale = LocalConfiguration.current.locales[0] val stats = state.rangeData?.stats + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section)) { + TokenStatsCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + TokenStatsCardTitle(stringResource(R.string.token_activity_title)) { + stats?.let { + TokenStatsPill( + stringResource(R.string.token_activity_current_streak_badge, it.currentStreak) + ) + Spacer(Modifier.width(6.dp)) + TokenStatsPill( + stringResource(R.string.token_activity_longest_streak_badge, it.longestStreak) + ) + } + } + Crossfade( + targetState = state.viewMode, + animationSpec = tween(150), + label = "token_activity_chart", + ) { mode -> + TokenActivityVisualization( + state = state.copy(viewMode = mode), + locale = locale, + tokenDisplayUnit = tokenDisplayUnit, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + state.heatmap?.let { heatmap -> + TokenActivityHeatmapCard( + data = heatmap, + locale = locale, + tokenDisplayUnit = tokenDisplayUnit, + ) + } + } +} + +@Composable +private fun TokenActivityHeatmapCard( + data: com.ai.assistance.operit.data.stats.TokenActivityRangeData, + locale: Locale, + tokenDisplayUnit: TokenStatsDisplayUnit, +) { TokenStatsCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(TokenStatsSpacing.card), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - TokenStatsCardTitle(stringResource(R.string.token_activity_title)) { - stats?.let { - TokenStatsPill( - stringResource(R.string.token_activity_current_streak_badge, it.currentStreak) - ) - Spacer(Modifier.width(6.dp)) - TokenStatsPill( - stringResource(R.string.token_activity_longest_streak_badge, it.longestStreak) - ) - } - } - Crossfade( - targetState = state.viewMode, - animationSpec = tween(150), - label = "token_activity_heatmap", - ) { mode -> - TokenActivityVisualization( - state = state.copy(viewMode = mode), - locale = locale, - tokenDisplayUnit = tokenDisplayUnit, - modifier = Modifier.fillMaxWidth(), - ) - } + TokenStatsCardTitle(stringResource(R.string.token_activity_calendar)) + TokenActivityDailyHeatmap( + data = data, + locale = locale, + tokenDisplayUnit = tokenDisplayUnit, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -129,22 +159,21 @@ private fun TokenActivityVisualization( return } when (state.viewMode) { - TokenActivityViewMode.DAILY -> TokenActivityDailyHeatmap(state, locale, tokenDisplayUnit, modifier) - TokenActivityViewMode.WEEKLY -> TokenActivityWeeklyChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.DAILY -> TokenActivityHourlyChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.WEEKLY -> TokenActivityDailyBarChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.MONTHLY -> TokenActivityWeeklyChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.YEARLY -> TokenActivityMonthlyChart(state, locale, tokenDisplayUnit, modifier) TokenActivityViewMode.CUMULATIVE -> TokenActivityCumulativeChart(state, locale, tokenDisplayUnit, modifier) } } @Composable private fun TokenActivityDailyHeatmap( - state: TokenActivityUiState, + data: com.ai.assistance.operit.data.stats.TokenActivityRangeData, locale: Locale, tokenDisplayUnit: TokenStatsDisplayUnit, modifier: Modifier = Modifier, ) { - val data = state.rangeData - checkNotNull(data) - val days = data.daily val firstDate = days.firstOrNull()?.date // 网格和左侧标签都按周一到周日排列;此前沿用周日首列偏移,导致标签与日期错行。 @@ -191,16 +220,16 @@ private fun TokenActivityDailyHeatmap( } } val width = (block + gap) * columns - gap - var selectedDay by remember(days, state.viewMode) { + var selectedDay by remember(days, data) { mutableStateOf(null) } - var indicatorDay by remember(grid, state.viewMode) { + var indicatorDay by remember(grid, data) { mutableStateOf(null) } - var indicatorColumn by remember(grid, state.viewMode) { + var indicatorColumn by remember(grid, data) { mutableIntStateOf(-1) } - var indicatorRow by remember(grid, state.viewMode) { + var indicatorRow by remember(grid, data) { mutableIntStateOf(-1) } val monthLabels = remember(grid, locale) { @@ -231,7 +260,7 @@ private fun TokenActivityDailyHeatmap( } } - LaunchedEffect(columns, days, state.viewMode) { + LaunchedEffect(columns, days, data) { snapshotFlow { scroll.maxValue }.first { it > 0 } scroll.scrollTo(scroll.maxValue) } @@ -477,6 +506,64 @@ private fun TokenActivityDailyHeatmap( } } +@Composable +private fun TokenActivityHourlyChart( + state: TokenActivityUiState, + locale: Locale, + tokenDisplayUnit: TokenStatsDisplayUnit, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.hourly.map { entry -> + TokenActivitySeriesPoint( + startDate = entry.startDate, + endDate = entry.startDate, + tokens = entry.tokens, + hour = entry.hour, + ) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + tapHint = stringResource(R.string.token_activity_chart_tap_hint), + ) { point -> + stringResource( + R.string.token_activity_hour_detail, + point.startDate.format(localizedDateFormatter(locale)), + point.hour ?: 0, + formatTokenCount(point.tokens, tokenDisplayUnit), + ) + } +} + +@Composable +private fun TokenActivityDailyBarChart( + state: TokenActivityUiState, + locale: Locale, + tokenDisplayUnit: TokenStatsDisplayUnit, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.daily.map { day -> + TokenActivitySeriesPoint(day.date, day.date, day.tokens) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + tapHint = stringResource(R.string.token_activity_chart_tap_hint), + ) { point -> + stringResource( + R.string.token_activity_day_detail, + point.startDate.format(localizedDateFormatter(locale)), + formatTokenCount(point.tokens, tokenDisplayUnit), + ) + } +} + @Composable private fun TokenActivityWeeklyChart( state: TokenActivityUiState, @@ -485,10 +572,13 @@ private fun TokenActivityWeeklyChart( modifier: Modifier = Modifier, ) { val data = checkNotNull(state.rangeData) + val rangeEnd = data.daily.lastOrNull()?.date val points = data.weekly.map { week -> TokenActivitySeriesPoint( startDate = week.startDate, - endDate = week.startDate.plusDays(6), + endDate = rangeEnd + ?.let { minOf(week.startDate.plusDays(6), it) } + ?: week.startDate.plusDays(6), tokens = week.tokens, ) } @@ -508,6 +598,77 @@ private fun TokenActivityWeeklyChart( } } +@Composable +private fun TokenActivityMonthlyChart( + state: TokenActivityUiState, + locale: Locale, + tokenDisplayUnit: TokenStatsDisplayUnit, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val rangeEnd = data.daily.lastOrNull()?.date + val points = data.monthly.map { month -> + TokenActivitySeriesPoint( + startDate = month.startDate, + endDate = rangeEnd + ?.let { minOf(month.startDate.withDayOfMonth(month.startDate.lengthOfMonth()), it) } + ?: month.startDate.withDayOfMonth(month.startDate.lengthOfMonth()), + tokens = month.tokens, + ) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + tapHint = stringResource(R.string.token_activity_chart_tap_hint), + ) { point -> + stringResource( + R.string.token_activity_month_detail, + point.startDate.format(localizedDateFormatter(locale)), + point.endDate.format(localizedDateFormatter(locale)), + formatTokenCount(point.tokens, tokenDisplayUnit), + ) + } +} + +@Composable +private fun TokenActivityYearlyChart( + state: TokenActivityUiState, + locale: Locale, + tokenDisplayUnit: TokenStatsDisplayUnit, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val rangeStart = data.daily.firstOrNull()?.date + val rangeEnd = data.daily.lastOrNull()?.date + val points = data.yearly.map { year -> + TokenActivitySeriesPoint( + startDate = rangeStart + ?.let { maxOf(year.startDate, it) } + ?: year.startDate, + endDate = rangeEnd + ?.let { minOf(year.startDate.withDayOfYear(year.startDate.lengthOfYear()), it) } + ?: year.startDate.withDayOfYear(year.startDate.lengthOfYear()), + tokens = year.tokens, + ) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + tapHint = stringResource(R.string.token_activity_chart_tap_hint), + ) { point -> + stringResource( + R.string.token_activity_year_detail, + point.startDate.format(localizedDateFormatter(locale)), + point.endDate.format(localizedDateFormatter(locale)), + formatTokenCount(point.tokens, tokenDisplayUnit), + ) + } +} + @Composable private fun TokenActivityCumulativeChart( state: TokenActivityUiState, @@ -545,14 +706,6 @@ private fun TokenActivityTimeSeriesChart( ) { val density = LocalDensity.current val scroll = rememberScrollState() - val pointWidth = if (style == TokenActivitySeriesStyle.BAR) 18.dp else 14.dp - val chartWidth = (pointWidth * points.size).coerceAtLeast(280.dp) - val plotHeight = 124.dp - val labelHeight = 24.dp - val canvasHeight = plotHeight + labelHeight - val stepPx = with(density) { pointWidth.toPx() } - val plotHeightPx = with(density) { plotHeight.toPx() } - val maxTokens = points.maxOfOrNull(TokenActivitySeriesPoint::tokens)?.coerceAtLeast(1L) ?: 1L val palette = LocalTokenStatsColors.current val primary = palette.chartAccent val grid = palette.chartGrid @@ -564,13 +717,42 @@ private fun TokenActivityTimeSeriesChart( isAntiAlias = true } } - val monthLabels = remember(points, locale) { - val formatter = DateTimeFormatter.ofPattern("MMM", locale) + val monthFormatter = remember(locale) { DateTimeFormatter.ofPattern("MMM", locale) } + val monthLabelTexts = remember(points, locale) { + points.map { monthFormatter.format(it.startDate) }.distinct() + } + // When month labels are dense (the twelve-month yearly chart is the densest + // case), widen the bar spacing so every label stays readable without any + // label being skipped or overlapping. Sparse bars (weekly 7 / monthly 4-5) + // get a fixed wider spacing so the bars read as thick blocks instead of thin + // needles. Dense bars (daily 24) keep the base width. + val pointWidth = if (style == TokenActivitySeriesStyle.BAR) { + when { + monthLabelTexts.size >= 4 -> { + with(density) { + val widestPx = monthLabelTexts.maxOf { labelPaint.measureText(it) } + maxOf(18.dp, ((widestPx / density.density) + 12f).dp) + } + } + points.size <= 7 -> 30.dp + else -> 18.dp + } + } else { + 14.dp + } + val chartWidth = (pointWidth * points.size).coerceAtLeast(280.dp) + val plotHeight = 124.dp + val labelHeight = 24.dp + val canvasHeight = plotHeight + labelHeight + val stepPx = with(density) { pointWidth.toPx() } + val plotHeightPx = with(density) { plotHeight.toPx() } + val maxTokens = points.maxOfOrNull(TokenActivitySeriesPoint::tokens)?.coerceAtLeast(1L) ?: 1L + val monthLabels = remember(points, monthFormatter) { buildList { var previousMonth = -1 points.forEachIndexed { index, point -> if (index == 0 || point.startDate.monthValue != previousMonth) { - add(TokenActivityMonthLabel(index, formatter.format(point.startDate))) + add(TokenActivityMonthLabel(index, monthFormatter.format(point.startDate))) previousMonth = point.startDate.monthValue } } @@ -669,6 +851,7 @@ private data class TokenActivitySeriesPoint( val startDate: java.time.LocalDate, val endDate: java.time.LocalDate, val tokens: Long, + val hour: Int? = null, ) private enum class TokenActivitySeriesStyle { BAR, LINE } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index efdf15e7b4..5d1cf97409 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -233,6 +233,8 @@ internal fun TokenStatsSegmentedControl( when (mode) { TokenActivityViewMode.DAILY -> R.string.token_activity_daily TokenActivityViewMode.WEEKLY -> R.string.token_activity_weekly + TokenActivityViewMode.MONTHLY -> R.string.token_activity_monthly + TokenActivityViewMode.YEARLY -> R.string.token_activity_yearly TokenActivityViewMode.CUMULATIVE -> R.string.token_activity_cumulative } ), @@ -246,7 +248,7 @@ internal fun TokenStatsSegmentedControl( if (isSelected) colors.selectedSegmentContainer else Color.Transparent, ) .clickable { onSelect(mode) } - .padding(horizontal = 14.dp, vertical = 6.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index d52d70110d..24aff4a967 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -42,6 +42,7 @@ data class TokenActivityUiState( val loading: Boolean = true, val viewMode: TokenActivityViewMode = TokenActivityViewMode.DAILY, val rangeData: TokenActivityRangeData? = null, + val heatmap: TokenActivityRangeData? = null, ) data class TokenStatsUiState( @@ -104,8 +105,10 @@ class TokenUsageStatisticsViewModel( fun setActivityViewMode(mode: TokenActivityViewMode) { val snapshot = _state.value - val currentRange = snapshot.currentRange ?: defaultDateRange(nowMs(), zone) - val anchorDate = activityRangeAnchorDate(currentRange, zone) + // Tapping a mode re-anchors the window to today: daily spans today, weekly + // rolls back seven days, monthly/yearly roll back one calendar unit, and + // cumulative ends today while keeping the first recorded date. + val anchorDate = java.time.Instant.ofEpochMilli(nowMs()).atZone(zone).toLocalDate() val providerModels = providerModelsFor(snapshot) modeRangeJob?.cancel() modeRangeJob = viewModelScope.launch(dispatcher) { @@ -212,6 +215,18 @@ class TokenUsageStatisticsViewModel( val activityDeferred = async(Dispatchers.IO) { TokenStatsQueryService.activitySnapshot(appContext, range, rangeParams, zone) } + val heatmapDeferred = async(Dispatchers.IO) { + runCatching { + val today = java.time.Instant.ofEpochMilli(nowMs()).atZone(zone).toLocalDate() + val heatmapRange = TokenStatsTimeRanges.customRange( + today.minusDays(364L).atStartOfDay(zone).toInstant().toEpochMilli(), + today.plusDays(1L).atStartOfDay(zone).toInstant().toEpochMilli(), + ) + val snapshot = + TokenStatsQueryService.activitySnapshot(appContext, heatmapRange, rangeParams, zone) + TokenActivityAggregator.rangeData(snapshot, heatmapRange) + }.getOrNull() + } val rangeData = rangeDeferred.await() val configurationIds = rangeData @@ -236,6 +251,7 @@ class TokenUsageStatisticsViewModel( prices = pricesDeferred.await(), configurationNames = configurationNamesDeferred.await(), activity = TokenActivityAggregator.rangeData(activityDeferred.await(), range), + heatmap = heatmapDeferred.await(), ) } @@ -258,7 +274,11 @@ class TokenUsageStatisticsViewModel( knownModelNames = knownModelNames.toMap(), configurationNames = result.configurationNames, priceSettings = result.prices, - activity = it.activity.copy(loading = false, rangeData = result.activity), + activity = it.activity.copy( + loading = false, + rangeData = result.activity, + heatmap = result.heatmap, + ), refreshVersion = it.refreshVersion + 1L, ) } @@ -424,6 +444,7 @@ private data class QueryLoadResult( val prices: List, val configurationNames: Map, val activity: TokenActivityRangeData, + val heatmap: TokenActivityRangeData?, ) private fun defaultDateRange(nowMs: Long, zone: ZoneId): TokenStatsTimeRange { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..a2dccbbbba 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8257,7 +8257,10 @@ Review the audit record and check which operation was denied and why. If it is still needed, return to the main chat and clearly state the exact operation, target, and scope before asking the AI to try again. Do not authorize an operation you do not understand. Daily Weekly + Monthly + Yearly Cumulative + Activity calendar Activity %1$d-day streak Longest %1$d days @@ -8268,7 +8271,10 @@ Longest streak %1$d days %1$s used %2$s tokens + %1$s %2$d:00 used %3$s tokens %1$s - %2$s used %3$s tokens + %1$s - %2$s used %3$s tokens + %1$s - %2$s used %3$s tokens %2$s tokens through %1$s Tap a cell to view details Less diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 91d19cc84e..4c1192d290 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7518,7 +7518,10 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Racha más larga %1$d días %1$s usó %2$s Token + %1$s %2$d:00 usó %3$s Token %1$s - %2$s usó %3$s Token + %1$s - %2$s usó %3$s Token + %1$s - %2$s usó %3$s Token Hasta %1$s, uso acumulado de %2$s Token Toca un cuadrado para ver datos detallados Menos @@ -7694,7 +7697,10 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Revisa primero el registro de revisión para confirmar las operaciones denegadas y los motivos. Si aún necesitas ejecutarlas, vuelve al chat principal, especifica claramente la operación, el objetivo y el alcance, y luego haz que la IA lo reintente; no autorices operaciones que no entiendas. Diario Semanal + Mensual + Anual Acumulado + Calendario de actividad Registro de actividad Racha actual: %1$d días Racha más larga: %1$d días diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 864f535afd..d082ba4794 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -7450,7 +7450,10 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Lari terpanjang %1$d hari %1$s menggunakan %2$s Token + %1$s %2$d:00 menggunakan %3$s Token %1$s - %2$s menggunakan %3$s Token + %1$s - %2$s menggunakan %3$s Token + %1$s - %2$s menggunakan %3$s Token Hingga %1$s total penggunaan %2$s Token Ketuk kotak untuk melihat data detail Sedikit @@ -7626,7 +7629,10 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Periksa catatan peninjauan terlebih dahulu, konfirmasi operasi yang ditolak dan alasannya. Jika masih perlu dieksekusi, kembali ke percakapan utama, jelaskan secara spesifik operasi, tujuan, dan cakupan, lalu minta AI mencoba lagi; jangan otorisasi operasi yang tidak Anda pahami. Harian Mingguan + Bulanan + Tahunan Kumulatif + Kalender aktivitas Catatan aktivitas Streak saat ini %1$d hari Streak terpanjang %1$d hari diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bef17c9d23..c09955b836 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -7297,7 +7297,10 @@ 최장 연속 %1$d일 %1$s님이 %2$s Token을 사용했습니다 + %1$s %2$d:00에 %3$s Token을 사용했습니다 %1$s - %2$s님이 %3$s Token을 사용했습니다 + %1$s - %2$s님이 %3$s Token을 사용했습니다 + %1$s - %2$s님이 %3$s Token을 사용했습니다 %1$s까지 누적 %2$s Token 사용 셀을 탭하여 상세 데이터 보기 적음 @@ -7473,7 +7476,10 @@ 먼저 검토 기록을 확인하여 거부된 작업과 이유를 확인하세요. 여전히 실행해야 한다면 기본 대화로 돌아가 구체적인 작업, 목표 및 범위를 명확히 설명한 후 AI가 다시 시도하도록 하세요. 이해하지 못하는 작업은 승인하지 마세요. 일일 주간 + 월간 + 연간 누적 + 활동 캘린더 활동 기록 현재 연속 %1$d일 최장 연속 %1$d일 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e471122d20..c4125e86e2 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -7447,7 +7447,10 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Rentetan terpanjang %1$d hari %1$s menggunakan %2$s Token + %1$s %2$d:00 menggunakan %3$s Token %1$s - %2$s menggunakan %3$s Token + %1$s - %2$s menggunakan %3$s Token + %1$s - %2$s menggunakan %3$s Token Sehingga %1$s, jumlah penggunaan %2$s Token Klik petak untuk melihat data terperinci Kurang @@ -7623,7 +7626,10 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Semak dahulu rekod semakan untuk mengesahkan operasi yang ditolak dan sebabnya. Jika masih perlu dilaksanakan, kembali ke perbualan utama, nyatakan dengan jelas operasi, matlamat dan skop, kemudian minta AI mencuba semula; jangan benarkan operasi yang anda tidak faham. Harian Mingguan + Bulanan + Tahunan Kumulatif + Kalendar aktiviti Rekod Aktiviti Rentetan semasa %1$d hari Rentetan terpanjang %1$d hari diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 31e3e667ec..e2bfbb439d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -7288,7 +7288,10 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Maior sequência %1$d dias %1$s usou %2$s Token + %1$s %2$d:00 usou %3$s Token %1$s - %2$s usou %3$s Token + %1$s - %2$s usou %3$s Token + %1$s - %2$s usou %3$s Token Até %1$s, uso acumulado de %2$s Token Toque no quadrado para ver dados detalhados Menos @@ -7464,7 +7467,10 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Primeiro, revise os registros de auditoria para confirmar as operações recusadas e os motivos. Se ainda precisar executar, volte ao chat principal, descreva claramente a operação específica, o objetivo e o escopo e peça ao IA para tentar novamente; não autorize operações que você não entende. Diário Semanal + Mensal + Anual Acumulado + Calendário de atividade Registro de atividade Sequência atual: %1$d dias Maior sequência: %1$d dias diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 25f57b895c..d35f245330 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -8045,7 +8045,10 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Cea mai lungă serie %1$d zile %1$s a folosit %2$s Token + %1$s %2$d:00 a folosit %3$s Token %1$s - %2$s a folosit %3$s Token + %1$s - %2$s a folosit %3$s Token + %1$s - %2$s a folosit %3$s Token Până la %1$s, total utilizat %2$s Token Atingeți un pătrat pentru a vedea datele detaliate Mai puțin @@ -8221,7 +8224,10 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Verificați mai întâi înregistrările de revizuire pentru a confirma operațiunile refuzate și motivele. Dacă tot trebuie să le executați, reveniți la dialogul principal, specificați clar operațiunea, scopul și domeniul, apoi lăsați AI-ul să reîncerce; nu autorizați operațiuni pe care nu le înțelegeți. Zilnic Săptămânal + Lunar + Anual Cumulativ + Calendar de activitate Înregistrări de activitate Curent: %1$d zile consecutive Cel mai lung: %1$d zile consecutive diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..b9f1961798 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8294,7 +8294,10 @@ 先查看审核记录,确认被拒绝的操作和原因。若仍需执行,请回到主对话,明确说明具体操作、目标和范围后再让 AI 重试;不要授权你不理解的操作。 每日 每周 + 每月 + 每年 累计 + 活跃日历 活跃记录 当前连续 %1$d 天 最长连续 %1$d 天 @@ -8305,7 +8308,10 @@ 最长连续 %1$d 天 %1$s使用了 %2$s Token + %1$s %2$d:00 使用了 %3$s Token %1$s - %2$s 使用了 %3$s Token + %1$s - %2$s 使用了 %3$s Token + %1$s - %2$s 使用了 %3$s Token 截至 %1$s 累计使用 %2$s Token 点击方格查看详细数据 diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt index f139a86eb8..c5fb3a23e4 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt @@ -36,6 +36,75 @@ class TokenActivityAggregatorTest { assertEquals(30L, result.stats.peakTokens) } + @Test + fun `range data groups monthly totals by calendar month`() { + val range = dateRange("2026-01-15", "2026-03-10") + val snapshot = TokenActivitySnapshot( + zone = zone, + dayTotals = mapOf( + LocalDate.of(2026, 1, 20) to 10L, + LocalDate.of(2026, 2, 5) to 20L, + LocalDate.of(2026, 3, 1) to 30L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals( + listOf( + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 2, 1), + LocalDate.of(2026, 3, 1), + ), + result.monthly.map(TokenActivityMonth::startDate), + ) + assertEquals(listOf(10L, 20L, 30L), result.monthly.map(TokenActivityMonth::tokens)) + } + + @Test + fun `range data groups yearly totals by calendar year`() { + val range = dateRange("2025-06-01", "2026-06-30") + val snapshot = TokenActivitySnapshot( + zone = zone, + dayTotals = mapOf( + LocalDate.of(2025, 7, 1) to 15L, + LocalDate.of(2025, 12, 31) to 25L, + LocalDate.of(2026, 1, 1) to 35L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals( + listOf( + LocalDate.of(2025, 1, 1), + LocalDate.of(2026, 1, 1), + ), + result.yearly.map(TokenActivityYear::startDate), + ) + assertEquals(listOf(40L, 35L), result.yearly.map(TokenActivityYear::tokens)) + } + + @Test + fun `range data builds hourly buckets for a single day range`() { + val range = dateRange("2026-08-02", "2026-08-02") + val snapshot = TokenActivitySnapshot( + zone = zone, + dayTotals = mapOf(LocalDate.of(2026, 8, 2) to 70L), + hourTotals = mapOf( + LocalDate.of(2026, 8, 2).atTime(9, 0) to 10L, + LocalDate.of(2026, 8, 2).atTime(21, 0) to 60L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals(24, result.hourly.size) + assertEquals(10L, result.hourly.first { it.hour == 9 }.tokens) + assertEquals(60L, result.hourly.first { it.hour == 21 }.tokens) + assertEquals(0L, result.hourly.first { it.hour == 3 }.tokens) + } + @Test fun `range data calculates streaks and cumulative totals inside the selected range`() { val range = dateRange("2026-08-01", "2026-08-05") diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsActivityRangePolicyTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsActivityRangePolicyTest.kt index f6f1fe52f8..4292d5a9f9 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsActivityRangePolicyTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsActivityRangePolicyTest.kt @@ -31,7 +31,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `weekly mode selects the existing Sunday-first calendar week`() { + fun `weekly mode covers the last seven complete days ending yesterday`() { val range = activityRangeForMode( mode = TokenActivityViewMode.WEEKLY, anchorDate = LocalDate.of(2026, 8, 22), @@ -41,8 +41,103 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2026, 8, 16), - LocalDate.of(2026, 8, 22), + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 8, 21), + zone, + ), + range, + ) + } + + @Test + fun `monthly mode mirrors weekly with the same day last month through yesterday`() { + val range = activityRangeForMode( + mode = TokenActivityViewMode.MONTHLY, + anchorDate = LocalDate.of(2026, 3, 31), + historyStartDate = null, + zone = zone, + ) + + assertEquals( + customRangeInclusiveEnd( + LocalDate.of(2026, 2, 28), + LocalDate.of(2026, 3, 30), + zone, + ), + range, + ) + } + + @Test + fun `monthly mode clamps month-end dates to february 29 in a leap year`() { + val range = activityRangeForMode( + mode = TokenActivityViewMode.MONTHLY, + anchorDate = LocalDate.of(2024, 3, 31), + historyStartDate = null, + zone = zone, + ) + + assertEquals( + customRangeInclusiveEnd( + LocalDate.of(2024, 2, 29), + LocalDate.of(2024, 3, 30), + zone, + ), + range, + ) + } + + @Test + fun `monthly mode spans exactly twenty eight days for a february anchor`() { + val range = activityRangeForMode( + mode = TokenActivityViewMode.MONTHLY, + anchorDate = LocalDate.of(2026, 3, 5), + historyStartDate = null, + zone = zone, + ) + + assertEquals( + customRangeInclusiveEnd( + LocalDate.of(2026, 2, 5), + LocalDate.of(2026, 3, 4), + zone, + ), + range, + ) + } + + @Test + fun `yearly mode mirrors weekly with the same day last year through yesterday`() { + val range = activityRangeForMode( + mode = TokenActivityViewMode.YEARLY, + anchorDate = LocalDate.of(2026, 9, 5), + historyStartDate = null, + zone = zone, + ) + + assertEquals( + customRangeInclusiveEnd( + LocalDate.of(2025, 9, 5), + LocalDate.of(2026, 9, 4), + zone, + ), + range, + ) + } + + @Test + fun `yearly mode clamps leap day to february 28 in a non-leap year`() { + val range = activityRangeForMode( + mode = TokenActivityViewMode.YEARLY, + anchorDate = LocalDate.of(2024, 2, 29), + historyStartDate = null, + zone = zone, + ) + + assertEquals( + customRangeInclusiveEnd( + LocalDate.of(2023, 2, 28), + LocalDate.of(2024, 2, 28), zone, ), range,