From 03424e618448eca476c7e0274353813fa7776380 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:53:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(heatmap):=20=E5=B9=B4=E5=BA=A6=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=8D=A1=E7=89=87=EF=BC=88=E7=B4=AF=E8=AE=A1/?= =?UTF-8?q?=E5=B3=B0=E5=80=BC/=E5=BD=93=E5=89=8D=E8=BF=9E=E7=BB=AD/?= =?UTF-8?q?=E6=9C=80=E9=95=BF=E8=BF=9E=E7=BB=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeStats 单次循环计算四项指标,存于 HeatmapUiState.stats - 视图切换器下方一行 4 卡片,按所选年份跟随变化 - i18n 三语言(含 plurals 单复数);loading 占位显示 - - 对抗审查修复:surfaceVariant 品牌色、en plurals、loading 占位 - 新增 HeatmapStatsTest 单测(4 用例全绿) --- .../rainy/token/ui/heatmap/HeatmapScreen.kt | 75 +++++++++++++++++++ .../token/ui/heatmap/HeatmapViewModel.kt | 36 +++++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 7 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 7 ++ app/src/main/res/values/strings.xml | 8 ++ .../token/ui/heatmap/HeatmapStatsTest.kt | 64 ++++++++++++++++ docs/heatmap-plan.md | 13 +++- 7 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt 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 b49caf0..cce442d 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 @@ -2,12 +2,14 @@ package com.rainy.token.ui.heatmap import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -40,10 +42,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration 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.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -177,6 +181,45 @@ fun HeatmapScreen( } } + // ── 年度统计(按所选年份,切换年份跟随变化)── + // loading 期间显示占位,避免全 0 造成"无数据"假象 + val dash = "–" + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatItem( + label = stringResource(R.string.heatmap_stats_total), + value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens), + modifier = Modifier.weight(1f), + ) + StatItem( + label = stringResource(R.string.heatmap_stats_peak), + value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens), + modifier = Modifier.weight(1f), + ) + StatItem( + label = stringResource(R.string.heatmap_stats_current_streak), + value = if (state.loading) { + dash + } else { + pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak) + }, + modifier = Modifier.weight(1f), + ) + StatItem( + label = stringResource(R.string.heatmap_stats_max_streak), + value = if (state.loading) { + dash + } else { + pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak) + }, + modifier = Modifier.weight(1f), + ) + } + // ── 热力图 Canvas(带 Crossfade 淡入淡出动画)── Crossfade( targetState = state.viewMode, @@ -288,3 +331,35 @@ fun HeatmapScreen( } } } + +// ── 年度统计卡片(累计/峰值/当前连续/最长连续)── +@Composable +private fun StatItem( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} 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 0c373fa..3f3af85 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 @@ -43,6 +43,14 @@ data class HeatmapWeekData( val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例) ) +/** 年度统计指标(按所选年份的每日数据计算,切换年份跟随变化) */ +data class HeatmapStats( + val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和) + val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0) + val currentStreak: Int = 0, // 当前连续天数(从数据最后一天往前数连续 >0 的天数;最后一天为 0 则=0) + val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数) +) + /** UI 状态 */ data class HeatmapUiState( val loading: Boolean = true, @@ -54,6 +62,7 @@ data class HeatmapUiState( val weeklyData: List = emptyList(), val cumulativeData: List = emptyList(), val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max] + val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续) val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC ) { override fun equals(other: Any?): Boolean { @@ -68,6 +77,7 @@ data class HeatmapUiState( weeklyData == other.weeklyData && cumulativeData == other.cumulativeData && colorLevels.contentEquals(other.colorLevels) && + stats == other.stats && useUtc8 == other.useUtc8 } @@ -81,6 +91,7 @@ data class HeatmapUiState( result = 31 * result + weeklyData.hashCode() result = 31 * result + cumulativeData.hashCode() result = 31 * result + colorLevels.contentHashCode() + result = 31 * result + stats.hashCode() result = 31 * result + useUtc8.hashCode() return result } @@ -189,6 +200,7 @@ class HeatmapViewModel @Inject constructor( weeklyData = result.weeklyData, cumulativeData = result.cumulativeData, colorLevels = levels.toIntArray(), + stats = computeStats(result.dailyData), ) } } @@ -244,6 +256,7 @@ class HeatmapViewModel @Inject constructor( weeklyData = result.weeklyData, cumulativeData = result.cumulativeData, colorLevels = levels.toIntArray(), + stats = computeStats(result.dailyData), ) } } catch (e: Exception) { @@ -398,6 +411,29 @@ class HeatmapViewModel @Inject constructor( } } + /** + * 计算年度统计指标(基于所选年份的完整日序列,dailyRaw 含 0 token 的天)。 + * + * - totalTokens:全部天之和 + * - peakTokens:单日最大 token(当年无数据=0) + * - currentStreak:从最后一天往前数连续 >0 的天数(最后一天为 0 则=0) + * - maxStreak:当年内连续 >0 的最大天数 + */ + internal fun computeStats(dailyData: List): HeatmapStats { + var total = 0L + var peak = 0L + var currentStreak = 0 + var maxStreak = 0 + for (d in dailyData) { + total += d.tokens + if (d.tokens > peak) peak = d.tokens + if (d.tokens > 0L) currentStreak++ else currentStreak = 0 + if (currentStreak > maxStreak) maxStreak = currentStreak + } + // 循环结束后 currentStreak = 以最后一天结尾的连续天数(最后一天为 0 时为 0)= 当前连续天数 + return HeatmapStats(total, peak, currentStreak, maxStreak) + } + /** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */ private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long { return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() 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 c9385ce..bb7541a 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -506,4 +506,11 @@ Token 活动热力图,%1$d 天数据 Token 活动热力图,%1$d 周数据 选择年份 + 累计 token + 峰值 token + 当前连续 + 最长连续 + + %1$d 天 + \ No newline at end of file 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 80b4db2..9891354 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -506,4 +506,11 @@ Token 活動熱力圖,%1$d 天資料 Token 活動熱力圖,%1$d 週資料 選擇年份 + 累計 Token + 峰值 Token + 當前連續 + 最長連續 + + %1$d 天 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1066810..f0c16d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -509,4 +509,12 @@ Token activity heatmap, %1$d days Token activity heatmap, %1$d weeks Select year + Total tokens + Peak tokens + Current streak + Longest streak + + %1$d day + %1$d days + diff --git a/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt new file mode 100644 index 0000000..3c92bef --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt @@ -0,0 +1,64 @@ +package com.rainy.token.ui.heatmap + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [HeatmapViewModel.Companion.computeStats]. + * + * All tests are pure JVM — no Android framework, no network. + */ +class HeatmapStatsTest { + + private fun days(vararg tokens: Long): List = + tokens.mapIndexed { i, t -> HeatmapDayData(dayTs = i.toLong(), tokens = t, level = 0) } + + // ── 累计 token ── + + @Test + fun `total sums all days`() { + assertEquals(17L, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).totalTokens) + assertEquals(0L, HeatmapViewModel.computeStats(days(0, 0, 0)).totalTokens) + assertEquals(0L, HeatmapViewModel.computeStats(emptyList()).totalTokens) + } + + // ── 峰值 token ── + + @Test + fun `peak is max single day`() { + assertEquals(7L, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).peakTokens) + assertEquals(0L, HeatmapViewModel.computeStats(days(0, 0, 0)).peakTokens) + assertEquals(100L, HeatmapViewModel.computeStats(days(100)).peakTokens) + } + + // ── 当前连续天数 ── + + @Test + fun `current streak counts from last day backward`() { + // 末尾是 2(>0),往前数只有它自己(前一个 0 断档)→ 1 + assertEquals(1, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).currentStreak) + // 末尾断档 → 0 + assertEquals(0, HeatmapViewModel.computeStats(days(5, 3, 0)).currentStreak) + // 全零 → 0 + assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).currentStreak) + // 全连续 → 全部天数 + assertEquals(3, HeatmapViewModel.computeStats(days(2, 3, 4)).currentStreak) + // 单天 → 1 + assertEquals(1, HeatmapViewModel.computeStats(days(100)).currentStreak) + // 空列表 → 0 + assertEquals(0, HeatmapViewModel.computeStats(emptyList()).currentStreak) + } + + // ── 最长连续天数 ── + + @Test + fun `max streak is longest run`() { + // 最长连续段是 (3,7) 共 2 天 + assertEquals(2, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).maxStreak) + assertEquals(2, HeatmapViewModel.computeStats(days(5, 3, 0)).maxStreak) + assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).maxStreak) + assertEquals(3, HeatmapViewModel.computeStats(days(2, 3, 4)).maxStreak) + assertEquals(1, HeatmapViewModel.computeStats(days(100)).maxStreak) + assertEquals(0, HeatmapViewModel.computeStats(emptyList()).maxStreak) + } +} diff --git a/docs/heatmap-plan.md b/docs/heatmap-plan.md index 332e167..2d70f1c 100644 --- a/docs/heatmap-plan.md +++ b/docs/heatmap-plan.md @@ -127,4 +127,15 @@ - 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动 - 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据) -- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」) \ No newline at end of file +- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」) + +## 十三、年度统计(2026-08 新增) + +- 视图切换器下方一行 4 个统计卡片,**按所选年份**计算(切换年份跟随变化),与图表口径一致 + - **累计 token**:当年全部天之和(= 累计视图最后一天的累计值) + - **峰值 token**:当年单日最大 token(无数据=0) + - **当前连续**:从数据最后一天往前数连续 >0 的天数(最后一天为 0 则=0) + - **最长连续**:当年内连续 >0 的最大天数 +- 实现:HeatmapViewModel 的 `computeStats(dailyData)`(companion 内,internal),结果存于 `HeatmapUiState.stats`;加载与切换年份时随 dailyData 一起更新 +- 连续天数的"有 token"判定:当日 token > 0(0 token 的天视为断档);数据为 1月1日~结束日的完整日序列,含 0 token 的天 +- Token 数值显示沿用「十、Token 数显示格式」(亿/万中文读法);天数显示为「N 天」 \ No newline at end of file