From 9efa89d9b66db2d72400a1c85ed01474ac055049 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:33:08 +0000 Subject: [PATCH 1/5] feat(tokenstats): add monthly/yearly activity views and re-anchor ranges to today --- .../operit/data/stats/TokenActivityModels.kt | 71 +++++++++++++--- .../tokenstats/ActivityDateRangePolicy.kt | 20 +++-- .../tokenstats/TokenActivitySection.kt | 75 ++++++++++++++++- .../tokenstats/TokenStatsComponents.kt | 4 +- .../TokenUsageStatisticsViewModel.kt | 6 +- app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-id/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-ms/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-ro/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../data/stats/TokenActivityAggregatorTest.kt | 49 ++++++++++++ .../TokenStatsActivityRangePolicyTest.kt | 80 ++++++++++++++++++- 15 files changed, 310 insertions(+), 27 deletions(-) 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..9a0d42bb75 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,11 +1,11 @@ package com.ai.assistance.operit.data.stats import java.time.LocalDate +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, @@ -21,6 +21,20 @@ 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 TokenActivityStats( val totalTokens: Long = 0L, val peakTokens: Long = 0L, @@ -31,12 +45,14 @@ data class TokenActivityStats( data class TokenActivityRangeData( val daily: List, val weekly: List, + val monthly: List, + val yearly: 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, @@ -67,26 +83,61 @@ 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], + ) + } + + return TokenActivityRangeData(daily, weekly, monthly, yearly, cumulative, stats(raw)) } private fun stats(days: List): TokenActivityStats { 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..c3b24677e6 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,15 +3,8 @@ 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, @@ -20,10 +13,15 @@ internal fun activityRangeForMode( ): TokenStatsTimeRange? { val startDate = when (mode) { TokenActivityViewMode.DAILY -> anchorDate - TokenActivityViewMode.WEEKLY -> { - // Keep the range aligned with TokenActivityAggregator's Sunday-first weeks. - anchorDate.minusDays((anchorDate.dayOfWeek.value % 7).toLong()) - } + // Rolling 7-day window anchored at today: the future has not happened yet, + // so the window reaches backwards from the anchor instead of forwards. + TokenActivityViewMode.WEEKLY -> anchorDate.minusDays(7L) + // Rolling one-month window. minusMonths clamps month-end dates automatically + // (e.g. Mar 31 -> Feb 28/29), so no hard-coded month-length rules are needed. + TokenActivityViewMode.MONTHLY -> anchorDate.minusMonths(1L) + // Rolling one-year window. minusYears clamps leap days automatically + // (e.g. Feb 29 -> Feb 28 in a non-leap year). + TokenActivityViewMode.YEARLY -> anchorDate.minusYears(1L) TokenActivityViewMode.CUMULATIVE -> historyStartDate ?: return null } if (startDate.isAfter(anchorDate)) return null 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..7e9e950acb 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 @@ -131,6 +131,8 @@ private fun TokenActivityVisualization( when (state.viewMode) { TokenActivityViewMode.DAILY -> TokenActivityDailyHeatmap(state, locale, tokenDisplayUnit, modifier) TokenActivityViewMode.WEEKLY -> TokenActivityWeeklyChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.MONTHLY -> TokenActivityMonthlyChart(state, locale, tokenDisplayUnit, modifier) + TokenActivityViewMode.YEARLY -> TokenActivityYearlyChart(state, locale, tokenDisplayUnit, modifier) TokenActivityViewMode.CUMULATIVE -> TokenActivityCumulativeChart(state, locale, tokenDisplayUnit, modifier) } } @@ -485,10 +487,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 +513,74 @@ 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 rangeEnd = data.daily.lastOrNull()?.date + val points = data.yearly.map { year -> + TokenActivitySeriesPoint( + startDate = 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, 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..cb3fac1355 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 @@ -104,8 +104,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) { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..450a2e2f7d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8257,6 +8257,8 @@ 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 %1$d-day streak @@ -8269,6 +8271,8 @@ %1$d days %1$s used %2$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..f3547c88e5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7519,6 +7519,8 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. %1$d días %1$s usó %2$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,6 +7696,8 @@ 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 Registro de actividad Racha actual: %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..82e54b9fd5 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -7451,6 +7451,8 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. %1$d hari %1$s menggunakan %2$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,6 +7628,8 @@ 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 Catatan aktivitas Streak saat ini %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..c146f9a673 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -7298,6 +7298,8 @@ %1$d일 %1$s님이 %2$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,6 +7475,8 @@ 먼저 검토 기록을 확인하여 거부된 작업과 이유를 확인하세요. 여전히 실행해야 한다면 기본 대화로 돌아가 구체적인 작업, 목표 및 범위를 명확히 설명한 후 AI가 다시 시도하도록 하세요. 이해하지 못하는 작업은 승인하지 마세요. 일일 주간 + 월간 + 연간 누적 활동 기록 현재 연속 %1$d일 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e471122d20..a547372db5 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -7448,6 +7448,8 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. %1$d hari %1$s menggunakan %2$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,6 +7625,8 @@ 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 Rekod Aktiviti Rentetan semasa %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..10d545813b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -7289,6 +7289,8 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. %1$d dias %1$s usou %2$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,6 +7466,8 @@ 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 Registro de atividade Sequência atual: %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..98d0e19c7e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -8046,6 +8046,8 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.%1$d zile %1$s a folosit %2$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,6 +8223,8 @@ 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 Înregistrări de activitate Curent: %1$d zile consecutive diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..6f22afdbdb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8294,6 +8294,8 @@ 先查看审核记录,确认被拒绝的操作和原因。若仍需执行,请回到主对话,明确说明具体操作、目标和范围后再让 AI 重试;不要授权你不理解的操作。 每日 每周 + 每月 + 每年 累计 活跃记录 当前连续 %1$d 天 @@ -8306,6 +8308,8 @@ %1$d 天 %1$s使用了 %2$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..a0bc1f2d97 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,55 @@ 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 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..1ff5f60a68 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 selects the rolling seven day window ending at the anchor`() { val range = activityRangeForMode( mode = TokenActivityViewMode.WEEKLY, anchorDate = LocalDate.of(2026, 8, 22), @@ -41,7 +41,7 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2026, 8, 16), + LocalDate.of(2026, 8, 15), LocalDate.of(2026, 8, 22), zone, ), @@ -49,6 +49,82 @@ class TokenStatsActivityRangePolicyTest { ) } + @Test + fun `monthly mode rolls back one calendar month with month-end clamping`() { + 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, 31), + zone, + ), + range, + ) + } + + @Test + fun `monthly mode keeps february 29 when the previous year is 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,31), + zone, + ), + range, + ) + } + + @Test + fun `yearly mode rolls back one calendar year`() { + 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, 5), + 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, 29), + zone, + ), + range, + ) + } + @Test fun `cumulative mode starts at the filtered history start and ends at anchor`() { val range = activityRangeForMode( From 7b9d594278a2337b971cab2c555ff886cfe10870 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:41:57 +0000 Subject: [PATCH 2/5] feat(tokenstats): granular bar views per mode and standalone activity calendar --- .../operit/data/dao/TokenUsageDao.kt | 37 ++++ .../operit/data/stats/TokenActivityModels.kt | 47 ++++- .../data/stats/TokenStatsQueryService.kt | 13 ++ .../tokenstats/ActivityDateRangePolicy.kt | 44 +++-- .../tokenstats/TokenActivitySection.kt | 168 +++++++++++++----- .../TokenUsageStatisticsViewModel.kt | 21 ++- app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-id/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-ms/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-ro/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../data/stats/TokenActivityAggregatorTest.kt | 20 +++ .../TokenStatsActivityRangePolicyTest.kt | 24 +-- 16 files changed, 320 insertions(+), 70 deletions(-) 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 9a0d42bb75..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,6 +1,7 @@ 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 @@ -10,6 +11,7 @@ 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) @@ -35,6 +37,14 @@ data class TokenActivityYear( 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, @@ -47,6 +57,7 @@ data class TokenActivityRangeData( val weekly: List, val monthly: List, val yearly: List, + val hourly: List, val cumulative: List, val stats: TokenActivityStats, ) @@ -59,11 +70,12 @@ object TokenActivityAggregator { ): 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 { @@ -137,7 +149,38 @@ object TokenActivityAggregator { ) } - return TokenActivityRangeData(daily, weekly, monthly, yearly, cumulative, stats(raw)) + // 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 c3b24677e6..d3b0c4b811 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 @@ -4,29 +4,45 @@ 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.LocalDate +import java.time.YearMonth import java.time.ZoneId + internal fun activityRangeForMode( mode: TokenActivityViewMode, anchorDate: LocalDate, historyStartDate: LocalDate?, zone: ZoneId, ): TokenStatsTimeRange? { - val startDate = when (mode) { - TokenActivityViewMode.DAILY -> anchorDate - // Rolling 7-day window anchored at today: the future has not happened yet, - // so the window reaches backwards from the anchor instead of forwards. - TokenActivityViewMode.WEEKLY -> anchorDate.minusDays(7L) - // Rolling one-month window. minusMonths clamps month-end dates automatically - // (e.g. Mar 31 -> Feb 28/29), so no hard-coded month-length rules are needed. - TokenActivityViewMode.MONTHLY -> anchorDate.minusMonths(1L) - // Rolling one-year window. minusYears clamps leap days automatically - // (e.g. Feb 29 -> Feb 28 in a non-leap year). - TokenActivityViewMode.YEARLY -> anchorDate.minusYears(1L) - TokenActivityViewMode.CUMULATIVE -> historyStartDate ?: return null + // Each periodic mode covers the most recent complete period, because future + // time has not happened yet and cannot be counted. Weekly aligns to the same + // weekday as today (e.g. today Saturday -> last Saturday through yesterday, + // exactly seven days); monthly covers the previous natural month; yearly + // covers the trailing twelve natural months (this month last year through + // last month). + val (startDate, inclusiveEndDate) = when (mode) { + TokenActivityViewMode.DAILY -> anchorDate to anchorDate + TokenActivityViewMode.WEEKLY -> { + val start = anchorDate.minusDays(7L) + start to anchorDate.minusDays(1L) + } + TokenActivityViewMode.MONTHLY -> { + val previousMonth = YearMonth.from(anchorDate).minusMonths(1L) + previousMonth.atDay(1) to previousMonth.atEndOfMonth() + } + TokenActivityViewMode.YEARLY -> { + val currentMonth = YearMonth.from(anchorDate) + val startMonth = currentMonth.minusMonths(12L) + val endMonth = currentMonth.minusMonths(1L) + startMonth.atDay(1) to endMonth.atEndOfMonth() + } + TokenActivityViewMode.CUMULATIVE -> { + val start = historyStartDate ?: return null + start to anchorDate + } } - 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 7e9e950acb..6927e0e72e 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,24 +159,21 @@ private fun TokenActivityVisualization( return } when (state.viewMode) { - TokenActivityViewMode.DAILY -> TokenActivityDailyHeatmap(state, locale, tokenDisplayUnit, modifier) - TokenActivityViewMode.WEEKLY -> TokenActivityWeeklyChart(state, locale, tokenDisplayUnit, modifier) - TokenActivityViewMode.MONTHLY -> TokenActivityMonthlyChart(state, locale, tokenDisplayUnit, modifier) - TokenActivityViewMode.YEARLY -> TokenActivityYearlyChart(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 // 网格和左侧标签都按周一到周日排列;此前沿用周日首列偏移,导致标签与日期错行。 @@ -193,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) { @@ -233,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) } @@ -479,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, @@ -742,6 +827,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/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index cb3fac1355..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( @@ -214,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 @@ -238,6 +251,7 @@ class TokenUsageStatisticsViewModel( prices = pricesDeferred.await(), configurationNames = configurationNamesDeferred.await(), activity = TokenActivityAggregator.rangeData(activityDeferred.await(), range), + heatmap = heatmapDeferred.await(), ) } @@ -260,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, ) } @@ -426,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 450a2e2f7d..a2dccbbbba 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8260,6 +8260,7 @@ Monthly Yearly Cumulative + Activity calendar Activity %1$d-day streak Longest %1$d days @@ -8270,6 +8271,7 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f3547c88e5..4c1192d290 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7518,6 +7518,7 @@ 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 @@ -7699,6 +7700,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. 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 82e54b9fd5..d082ba4794 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -7450,6 +7450,7 @@ 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 @@ -7631,6 +7632,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. 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 c146f9a673..c09955b836 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -7297,6 +7297,7 @@ 최장 연속 %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을 사용했습니다 @@ -7478,6 +7479,7 @@ 월간 연간 누적 + 활동 캘린더 활동 기록 현재 연속 %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 a547372db5..c4125e86e2 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -7447,6 +7447,7 @@ 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 @@ -7628,6 +7629,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. 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 10d545813b..e2bfbb439d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -7288,6 +7288,7 @@ 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 @@ -7469,6 +7470,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. 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 98d0e19c7e..d35f245330 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -8045,6 +8045,7 @@ 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 @@ -8226,6 +8227,7 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.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 6f22afdbdb..b9f1961798 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8297,6 +8297,7 @@ 每月 每年 累计 + 活跃日历 活跃记录 当前连续 %1$d 天 最长连续 %1$d 天 @@ -8307,6 +8308,7 @@ 最长连续 %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 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 a0bc1f2d97..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 @@ -85,6 +85,26 @@ class TokenActivityAggregatorTest { 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 1ff5f60a68..7adf386680 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 rolling seven day window ending at the anchor`() { + fun `weekly mode covers the last seven complete days ending yesterday`() { val range = activityRangeForMode( mode = TokenActivityViewMode.WEEKLY, anchorDate = LocalDate.of(2026, 8, 22), @@ -42,7 +42,7 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( LocalDate.of(2026, 8, 15), - LocalDate.of(2026, 8, 22), + LocalDate.of(2026, 8, 21), zone, ), range, @@ -50,7 +50,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `monthly mode rolls back one calendar month with month-end clamping`() { + fun `monthly mode covers the previous natural month`() { val range = activityRangeForMode( mode = TokenActivityViewMode.MONTHLY, anchorDate = LocalDate.of(2026, 3, 31), @@ -60,8 +60,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( + LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 28), - LocalDate.of(2026, 3, 31), zone, ), range, @@ -69,7 +69,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `monthly mode keeps february 29 when the previous year is a leap year`() { + fun `monthly mode keeps february 29 when the previous month is a leap february`() { val range = activityRangeForMode( mode = TokenActivityViewMode.MONTHLY, anchorDate = LocalDate.of(2024, 3, 31), @@ -79,8 +79,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( + LocalDate.of(2024, 2, 1), LocalDate.of(2024, 2, 29), - LocalDate.of(2024, 3,31), zone, ), range, @@ -88,7 +88,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `yearly mode rolls back one calendar year`() { + fun `yearly mode covers the trailing twelve natural months`() { val range = activityRangeForMode( mode = TokenActivityViewMode.YEARLY, anchorDate = LocalDate.of(2026, 9, 5), @@ -98,8 +98,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2025, 9, 5), - LocalDate.of(2026, 9, 5), + LocalDate.of(2025, 9, 1), + LocalDate.of(2026, 8, 31), zone, ), range, @@ -107,7 +107,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `yearly mode clamps leap day to february 28 in a non-leap year`() { + fun `yearly mode ends at the previous month end regardless of the anchor day`() { val range = activityRangeForMode( mode = TokenActivityViewMode.YEARLY, anchorDate = LocalDate.of(2024, 2, 29), @@ -117,8 +117,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2023, 2, 28), - LocalDate.of(2024, 2, 29), + LocalDate.of(2023, 2, 1), + LocalDate.of(2024, 1, 31), zone, ), range, From f941a73ff26345598a8f52e58beaa56657f01ea2 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:21:40 +0000 Subject: [PATCH 3/5] fix(tokenstats): widen dense month labels and align monthly window with weekly --- .../tokenstats/ActivityDateRangePolicy.kt | 13 +++--- .../tokenstats/TokenActivitySection.kt | 40 ++++++++++++++----- .../TokenStatsActivityRangePolicyTest.kt | 27 +++++++++++-- 3 files changed, 60 insertions(+), 20 deletions(-) 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 d3b0c4b811..a4abdcaadd 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 @@ -14,9 +14,11 @@ internal fun activityRangeForMode( zone: ZoneId, ): TokenStatsTimeRange? { // Each periodic mode covers the most recent complete period, because future - // time has not happened yet and cannot be counted. Weekly aligns to the same - // weekday as today (e.g. today Saturday -> last Saturday through yesterday, - // exactly seven days); monthly covers the previous natural month; yearly + // time has not happened yet and cannot be counted. Weekly and monthly 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 // covers the trailing twelve natural months (this month last year through // last month). val (startDate, inclusiveEndDate) = when (mode) { @@ -26,8 +28,9 @@ internal fun activityRangeForMode( start to anchorDate.minusDays(1L) } TokenActivityViewMode.MONTHLY -> { - val previousMonth = YearMonth.from(anchorDate).minusMonths(1L) - previousMonth.atDay(1) to previousMonth.atEndOfMonth() + // minusMonths clamps month-end dates (e.g. Mar 31 -> Feb 28/29). + val start = anchorDate.minusMonths(1L) + start to anchorDate.minusDays(1L) } TokenActivityViewMode.YEARLY -> { val currentMonth = YearMonth.from(anchorDate) 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 6927e0e72e..718c1b7d2c 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 @@ -703,14 +703,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 @@ -722,13 +714,39 @@ 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-label charts keep the base width. + val pointWidth = if (style == TokenActivitySeriesStyle.BAR) { + val base = 18.dp + if (monthLabelTexts.size >= 4) { + with(density) { + val widestPx = monthLabelTexts.maxOf { labelPaint.measureText(it) } + maxOf(base, ((widestPx / density.density) + 12f).dp) + } + } else { + base + } + } 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 } } 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 7adf386680..251b9094ca 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 @@ -50,7 +50,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `monthly mode covers the previous natural month`() { + 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), @@ -60,8 +60,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 28), + LocalDate.of(2026, 3, 30), zone, ), range, @@ -69,7 +69,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `monthly mode keeps february 29 when the previous month is a leap february`() { + 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), @@ -79,8 +79,27 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2024, 2, 1), 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, From 6af59142e86ad716a52b3b8b84bb9f56940f305c Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:11:25 +0000 Subject: [PATCH 4/5] fix(tokenstats): align yearly window with weekly and monthly rolling semantics --- .../tokenstats/ActivityDateRangePolicy.kt | 22 +++++++++---------- .../tokenstats/TokenActivitySection.kt | 5 ++++- .../TokenStatsActivityRangePolicyTest.kt | 12 +++++----- 3 files changed, 20 insertions(+), 19 deletions(-) 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 a4abdcaadd..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 @@ -4,7 +4,6 @@ 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.LocalDate -import java.time.YearMonth import java.time.ZoneId internal fun activityRangeForMode( @@ -14,13 +13,12 @@ internal fun activityRangeForMode( zone: ZoneId, ): TokenStatsTimeRange? { // Each periodic mode covers the most recent complete period, because future - // time has not happened yet and cannot be counted. Weekly and monthly 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 - // covers the trailing twelve natural months (this month last year through - // last month). + // 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 -> { @@ -33,10 +31,10 @@ internal fun activityRangeForMode( start to anchorDate.minusDays(1L) } TokenActivityViewMode.YEARLY -> { - val currentMonth = YearMonth.from(anchorDate) - val startMonth = currentMonth.minusMonths(12L) - val endMonth = currentMonth.minusMonths(1L) - startMonth.atDay(1) to endMonth.atEndOfMonth() + // 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 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 718c1b7d2c..412da28803 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 @@ -640,10 +640,13 @@ private fun TokenActivityYearlyChart( 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 = year.startDate, + 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()), 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 251b9094ca..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 @@ -107,7 +107,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `yearly mode covers the trailing twelve natural months`() { + 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), @@ -117,8 +117,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2025, 9, 1), - LocalDate.of(2026, 8, 31), + LocalDate.of(2025, 9, 5), + LocalDate.of(2026, 9, 4), zone, ), range, @@ -126,7 +126,7 @@ class TokenStatsActivityRangePolicyTest { } @Test - fun `yearly mode ends at the previous month end regardless of the anchor day`() { + 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), @@ -136,8 +136,8 @@ class TokenStatsActivityRangePolicyTest { assertEquals( customRangeInclusiveEnd( - LocalDate.of(2023, 2, 1), - LocalDate.of(2024, 1, 31), + LocalDate.of(2023, 2, 28), + LocalDate.of(2024, 2, 28), zone, ), range, From 6db9aa2523365f818bdcb6d50255504da11946b4 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:19:52 +0000 Subject: [PATCH 5/5] fix(tokenstats): widen sparse weekly and monthly bars --- .../tokenstats/TokenActivitySection.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 412da28803..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 @@ -723,16 +723,19 @@ private fun TokenActivityTimeSeriesChart( } // 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-label charts keep the base width. + // 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) { - val base = 18.dp - if (monthLabelTexts.size >= 4) { - with(density) { - val widestPx = monthLabelTexts.maxOf { labelPaint.measureText(it) } - maxOf(base, ((widestPx / density.density) + 12f).dp) + when { + monthLabelTexts.size >= 4 -> { + with(density) { + val widestPx = monthLabelTexts.maxOf { labelPaint.measureText(it) } + maxOf(18.dp, ((widestPx / density.density) + 12f).dp) + } } - } else { - base + points.size <= 7 -> 30.dp + else -> 18.dp } } else { 14.dp