diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt index 2258285..8bc860e 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt @@ -165,9 +165,11 @@ internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int, loc * 将周数据格式化为范围文本:"12月28日-12月31日"(不含"使用了X token", * 句子由调用方用 stringResource 按语言拼接)。 * - * - 末周只显示到该周落在所选年内的有效日期([dataEndTs],数据最后一天), + * - 末周只显示到该周落在所选窗口内的有效日期([dataEndTs],数据最后一天), * 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟 * - 首列跨年(start 在上一年)时,end 与 start 不同年 → end 强制带年份前缀,避免范围倒挂 + * - [RECENT_YEAR](最近 365 天窗口)时按周自身年份判断:start 不带前缀, + * end 仅在与 start 跨年时带前缀(窗口可能跨年,如 "1月5日-2026年1月3日") */ internal fun formatWeekRangeText( week: HeatmapWeekData, @@ -177,20 +179,22 @@ internal fun formatWeekRangeText( locale: Locale = Locale.getDefault(), ): String { val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC - val start = formatDateChinese(week.weekStartTs, useUtc8, selectedYear, locale) + val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate() + // 最近模式按周自身年份判断前缀;年份模式用所选年作上下文(与既有行为一致) + val startContext = if (selectedYear == RECENT_YEAR) startDate.year else selectedYear + val start = formatDateChinese(week.weekStartTs, useUtc8, startContext, locale) val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L)) if (locale.language == "zh") { - val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate() val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate() val end = if (endDate.year != startDate.year) { "${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日" } else { - formatDateChinese(endTs, useUtc8, selectedYear, locale) + formatDateChinese(endTs, useUtc8, startContext, locale) } return "$start-$end" } - // 非中文:formatDateChinese 已按 yearContext 决定是否带年份 - return "$start-${formatDateChinese(endTs, useUtc8, selectedYear, locale)}" + // 非中文:end 用 start 的年份作上下文(同 startDate.year 时无前缀;跨年时带年份) + return "$start-${formatDateChinese(endTs, useUtc8, startContext, locale)}" } // ── 辅助类型与函数 ────────────────────────────────────────── diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt index 2630b1b..0ad8c9f 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -26,12 +26,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -64,6 +66,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -174,25 +177,63 @@ fun HeatmapScreen( Text(stringResource(R.string.heatmap_title)) if (state.availableYears.isNotEmpty()) { Spacer(Modifier.width(12.dp)) - // 年份下拉选择器 + // 年份下拉选择器:默认"最近 365 天"(置顶),下方为具体年份 + val selectRangeLabel = if (state.selectedYear == RECENT_YEAR) { + stringResource(R.string.heatmap_select_recent) + } else { + "${state.selectedYear}" + } Text( - text = "${state.selectedYear} ▾", + text = "$selectRangeLabel ▾", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { yearMenuExpanded = true } .padding(horizontal = 6.dp, vertical = 4.dp) - .semantics { contentDescription = "$selectYearDesc: ${state.selectedYear}" } + .semantics { contentDescription = "$selectYearDesc: $selectRangeLabel" } ) DropdownMenu( expanded = yearMenuExpanded, onDismissRequest = { yearMenuExpanded = false } ) { + // "最近 365 天"(默认选项,与具体年份区分:置顶 + 分隔线) + DropdownMenuItem( + text = { Text(stringResource(R.string.heatmap_select_recent)) }, + trailingIcon = { + if (state.selectedYear == RECENT_YEAR) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(18.dp).semantics { selected = true }, + ) + } + }, + onClick = { + yearMenuExpanded = false + viewModel.setYear(RECENT_YEAR) + // 切换范围时关闭浮层 + selectedDay = null + selectedWeek = null + popupAnchor = null + } + ) + HorizontalDivider() state.availableYears.sortedDescending().forEach { year -> DropdownMenuItem( text = { Text("$year") }, + trailingIcon = { + if (state.selectedYear == year) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(18.dp).semantics { selected = true }, + ) + } + }, onClick = { yearMenuExpanded = false viewModel.setYear(year) diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt index e3dcf53..04c7e45 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt @@ -28,6 +28,9 @@ import javax.inject.Provider /** 热力图视图模式 */ enum class HeatmapViewMode { DAILY, WEEKLY, CUMULATIVE } +/** 年份选择器中的"最近"窗口(最近 365 天)sentinel:selectedYear == RECENT_YEAR 表示最近模式 */ +internal const val RECENT_YEAR = -1 + /** 每日 / 累计视图的单格数据 */ data class HeatmapDayData( val dayTs: Long, // UTC 日期时间戳(即天分桶的时间戳) @@ -67,7 +70,7 @@ data class HeatmapInsights( data class HeatmapUiState( val loading: Boolean = true, val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY, - val selectedYear: Int = 0, // 当前选中年份(0=尚未加载) + val selectedYear: Int = 0, // 当前选中范围:0=尚未加载,RECENT_YEAR(-1)=最近365天,>0=具体年份 val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用) val availableYears: List = emptyList(), // 可选年份(最早数据年份..今年,降序展示) val dailyData: List = emptyList(), @@ -192,15 +195,24 @@ class HeatmapViewModel @Inject constructor( _uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) } } - /** 切换年份:用缓存的记录按所选自然年重新聚合三视图 */ + /** 切换窗口范围(年份或最近365天):用缓存的记录按所选范围重新聚合三视图 */ fun setYear(year: Int) { - if (year == _uiState.value.selectedYear || records.isEmpty()) return + // guard 用 latestRequestedYear(最后一次请求意图)而非已提交的 selectedYear: + // 避免"在途切换未提交时重复点击导致最后一次意图被丢弃"的竞态 + if (year == latestRequestedYear || records.isEmpty()) return latestRequestedYear = year viewModelScope.launch { - val result = yearResults[year] ?: withContext(Dispatchers.Default) { - computeAllViews(records, useUtc8, year).also { yearResults[year] = it } + val cached = yearResults[year] + // 最近 365 天窗口以计算时刻的 todayTs 为锚:页面跨天常驻时缓存已过期(窗口末日在昨天),需重算 + val stale = cached != null && year == RECENT_YEAR && cached.dailyData.lastOrNull()?.dayTs != todayTsOf() + val result = if (cached == null || stale) { + withContext(Dispatchers.Default) { + computeAllViews(records, useUtc8, year).also { yearResults[year] = it } + } + } else { + cached } - // 期间用户又切了别的年份:丢弃过期结果,避免旧协程覆盖新选择 + // 期间用户又切了别的范围:丢弃过期结果,避免旧协程覆盖新选择 if (year != latestRequestedYear) return@launch val mode = _uiState.value.viewMode val levels = when (mode) { @@ -221,6 +233,12 @@ class HeatmapViewModel @Inject constructor( } } + /** 当前时区下的今天自然日零点(缓存新鲜度校验用) */ + private fun todayTsOf(): Long { + val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC + return startOfDayTs(System.currentTimeMillis(), zone) + } + // ── 内部逻辑 ──────────────────────────────────────────── private suspend fun workspaceId(): String? { @@ -246,14 +264,14 @@ class HeatmapViewModel @Inject constructor( all to computeAvailableYears(all, zone, currentYear) } records = fetched - latestRequestedYear = currentYear + latestRequestedYear = RECENT_YEAR // 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms) val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) } - // 所有重操作放在 Dispatchers.Default 上 + // 所有重操作放在 Dispatchers.Default 上;默认窗口 = 最近 365 天(RECENT_YEAR) val result = withContext(Dispatchers.Default) { - computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it } + computeAllViews(fetched, useUtc8, RECENT_YEAR).also { yearResults[RECENT_YEAR] = it } } // 重操作完成后按当前视图模式写入对应的分位阈值(加载期间可能已切换视图) @@ -266,7 +284,7 @@ class HeatmapViewModel @Inject constructor( _uiState.update { it.copy( loading = false, - selectedYear = currentYear, + selectedYear = RECENT_YEAR, currentYear = currentYear, availableYears = availableYears, useUtc8 = useUtc8, @@ -296,17 +314,19 @@ class HeatmapViewModel @Inject constructor( private const val DAY_MS = 86_400_000L /** - * 按指定自然年从全部记录计算三种视图数据 + 颜色等级阈值。 + * 按指定窗口从全部记录计算三种视图数据 + 颜色等级阈值。 * - * - 今年:范围 = 1月1日 ~ 今天(滚动 365 天窗口被年份选择器取代) - * - 往年:范围 = 1月1日 ~ 12月31日(完整自然年) - * - 周视图:周日对齐,第一列为该年 1月1日 所在周(可能含去年 12 月空位), - * 最后一列为结束日所在周(今年=今天所在周;往年可能跨到次年 1 月初), - * 列数按年份浮动(约 52~54 列),不丢数据 - * - 三种视图的分位数均按所选年份独立计算(颜色随年份数据变化是预期行为) + * 窗口类型由 [year] 决定: + * - [RECENT_YEAR](-1):最近 365 天(今天往前 364 天 ~ 今天,含今天共 365 天),默认窗口 + * - 今年(year == 当前自然年):1月1日 ~ 今天 + * - 往年:1月1日 ~ 12月31日(完整自然年) + * - 周视图:周日对齐,第一列为窗口首日所在周(可能含窗口前空位), + * 最后一列为结束日所在周(最近/今年=今天所在周;往年可能跨到次年 1 月初), + * 列数按窗口浮动(约 52~54 列),不丢数据 + * - 三种视图的分位数均按所选窗口独立计算(颜色随窗口数据变化是预期行为) * * @param useUtc8 true=按 UTC+8 零点分桶(与全局图表设置一致),false=按 UTC 零点分桶 - * @param year 目标自然年 + * @param year 目标自然年,或 [RECENT_YEAR] 表示最近 365 天 */ internal fun computeAllViews(records: List, useUtc8: Boolean = false, year: Int): AllViewsResult { val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC @@ -314,31 +334,36 @@ class HeatmapViewModel @Inject constructor( val todayTs = startOfDayTs(now, zone) val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year val isCurrentYear = year == currentYear + val isRecent = year == RECENT_YEAR - // 1. 年份边界(该时区下的自然年) - val yearStartTs = Instant.parse("${year}-01-01T00:00:00Z") - .atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() - val yearEndTs = if (isCurrentYear) { + // 1. 窗口边界(该时区下的自然日零点) + val windowStartTs = if (isRecent) { + todayTs - 364 * DAY_MS + } else { + Instant.parse("${year}-01-01T00:00:00Z") + .atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() + } + val windowEndTs = if (isRecent || isCurrentYear) { todayTs } else { Instant.parse("${year}-12-31T00:00:00Z") .atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() } - val totalDays = ((yearEndTs - yearStartTs) / DAY_MS).toInt() + 1 + val totalDays = ((windowEndTs - windowStartTs) / DAY_MS).toInt() + 1 // 2. 按天分桶聚合 token 数(按所选时区的自然日零点切日) val dayTokenMap = HashMap() for (r in records) { val dayTs = startOfDayTs(r.timeCreated, zone) - if (dayTs < yearStartTs || dayTs > yearEndTs) continue + if (dayTs < windowStartTs || dayTs > windowEndTs) continue val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens dayTokenMap.merge(dayTs, tokens, Long::plus) } - // 3. 构建完整的每日数据列表(1月1日 ~ 结束日) + // 3. 构建完整的每日数据列表(窗口首日 ~ 结束日) val dailyRaw = ArrayList(totalDays) for (i in 0 until totalDays) { - val ts = yearStartTs + i * DAY_MS + val ts = windowStartTs + i * DAY_MS val tokens = dayTokenMap[ts] ?: 0L dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0)) } @@ -347,12 +372,12 @@ class HeatmapViewModel @Inject constructor( val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens }) val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) } - // 5. 每周视图:周日对齐,第一列=1月1日所在周,最后一列=结束日所在周 - val firstWeekStart = startOfWeekTs(yearStartTs, zone) - val lastWeekStart = startOfWeekTs(yearEndTs, zone) + // 5. 每周视图:周日对齐,第一列=窗口首日所在周,最后一列=结束日所在周 + val firstWeekStart = startOfWeekTs(windowStartTs, zone) + val lastWeekStart = startOfWeekTs(windowEndTs, zone) val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart) - // 6. 累计视图:从 1月1日 到结束日的累计总和 + // 6. 累计视图:从窗口首日到结束日的累计总和 var cumulativeSum = 0L val cumulativeRaw = ArrayList(totalDays) for (d in dailyRaw) { diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 4834964..eda4077 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -507,6 +507,7 @@ Token 活动热力图,%1$d 天数据 Token 活动热力图,%1$d 周数据 选择年份 + 最近 365 天 累计 token 峰值 token 当前连续 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 844cc36..edd67f1 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -507,6 +507,7 @@ Token 活動熱力圖,%1$d 天資料 Token 活動熱力圖,%1$d 週資料 選擇年份 + 最近 365 天 累計 Token 峰值 Token 當前連續 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3954dbf..79e9473 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -510,6 +510,7 @@ Token activity heatmap, %1$d days Token activity heatmap, %1$d weeks Select year + Recent 365 days Total tokens Peak tokens Current streak diff --git a/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapAllViewsTest.kt b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapAllViewsTest.kt new file mode 100644 index 0000000..5cb2848 --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapAllViewsTest.kt @@ -0,0 +1,108 @@ +package com.rainy.token.ui.heatmap + +import com.rainy.token.data.local.UsageRecord +import java.time.Instant +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [HeatmapViewModel.Companion.computeAllViews](最近 365 天窗口与年份模式)。 + * + * All tests are pure JVM — no Android framework, no network. + */ +class HeatmapAllViewsTest { + + private fun rec(ts: Long, tokens: Long = 100L): UsageRecord = UsageRecord( + id = "r$ts", + workspaceId = "w", + timeCreated = ts, + timeUpdated = ts, + model = "m", + provider = "p", + inputTokens = tokens, + outputTokens = 0, + reasoningTokens = 0, + cacheReadTokens = 0, + cost = 0, + keyId = "k", + sessionId = "s", + ) + + private fun dayTsOf(ts: Long, useUtc8: Boolean): Long { + val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC + return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() + } + + private fun startOfWeekTs(ts: Long): Long { + val zone = ZoneOffset.UTC + val dow = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value % 7 // Sunday=7 → 0 + return ts - dow * 86_400_000L + } + + // ── 最近 365 天窗口 ── + + @Test + fun `recent window covers 365 days ending today`() { + val now = System.currentTimeMillis() + val today = dayTsOf(now, useUtc8 = false) + // 窗口内:今天、昨天;窗口外:366 天前(不应计入) + val records = listOf( + rec(today, 10), + rec(today - 86_400_000L, 20), + rec(today - 366L * 86_400_000L, 999), + ) + val result = HeatmapViewModel.computeAllViews(records, useUtc8 = false, year = RECENT_YEAR) + + assertEquals(365, result.dailyData.size) + assertEquals(today - 364L * 86_400_000L, result.dailyData.first().dayTs) + assertEquals(today, result.dailyData.last().dayTs) + // 窗口外记录不计入:总 token = 10 + 20 + assertEquals(30L, result.dailyData.sumOf { it.tokens }) + } + + @Test + fun `recent cumulative ends at window total`() { + val now = System.currentTimeMillis() + val today = dayTsOf(now, useUtc8 = false) + val records = listOf( + rec(today, 10), + rec(today - 86_400_000L, 20), + ) + val result = HeatmapViewModel.computeAllViews(records, useUtc8 = false, year = RECENT_YEAR) + + assertEquals(30L, result.cumulativeData.last().tokens) + assertEquals(365, result.cumulativeData.size) + // 累计视图首日 = 窗口首日(自身 token,非 0) + assertEquals(0L, result.cumulativeData.first().tokens) + } + + @Test + fun `recent week view aligned to window weeks`() { + val now = System.currentTimeMillis() + val today = dayTsOf(now, useUtc8 = false) + val firstWeekStart = startOfWeekTs(today - 364L * 86_400_000L) + val lastWeekStart = startOfWeekTs(today) + val expectedWeeks = ((lastWeekStart - firstWeekStart) / 86_400_000L).toInt() / 7 + 1 + + val result = HeatmapViewModel.computeAllViews(emptyList(), useUtc8 = false, year = RECENT_YEAR) + + assertEquals(expectedWeeks, result.weeklyData.size) + assertEquals(firstWeekStart, result.weeklyData.first().weekStartTs) + assertEquals(lastWeekStart, result.weeklyData.last().weekStartTs) + } + + // ── 年份模式回归 ── + + @Test + fun `year mode unaffected by recent window`() { + val rec2024 = rec(Instant.parse("2024-06-15T00:00:00Z").toEpochMilli(), 5) + val rec2025 = rec(Instant.parse("2025-06-15T00:00:00Z").toEpochMilli(), 7) + + val result = HeatmapViewModel.computeAllViews(listOf(rec2024, rec2025), useUtc8 = false, year = 2024) + + assertEquals(366, result.dailyData.size) // 2024 闰年完整自然年 + assertEquals(5L, result.dailyData.sumOf { it.tokens }) // 2025 年记录不计入 + assertEquals(5L, result.cumulativeData.last().tokens) + } +} \ No newline at end of file