feat(heatmap): 年度统计卡片(累计/峰值/当前连续/最长连续)
- computeStats 单次循环计算四项指标,存于 HeatmapUiState.stats - 视图切换器下方一行 4 卡片,按所选年份跟随变化 - i18n 三语言(含 plurals 单复数);loading 占位显示 - - 对抗审查修复:surfaceVariant 品牌色、en plurals、loading 占位 - 新增 HeatmapStatsTest 单测(4 用例全绿)
This commit is contained in:
parent
bb1dd97874
commit
03424e6184
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HeatmapWeekData> = emptyList(),
|
||||
val cumulativeData: List<HeatmapDayData> = 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<HeatmapDayData>): 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()
|
||||
|
||||
@ -506,4 +506,11 @@
|
||||
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string>
|
||||
<string name="heatmap_select_year">选择年份</string>
|
||||
<string name="heatmap_stats_total">累计 token</string>
|
||||
<string name="heatmap_stats_peak">峰值 token</string>
|
||||
<string name="heatmap_stats_current_streak">当前连续</string>
|
||||
<string name="heatmap_stats_max_streak">最长连续</string>
|
||||
<plurals name="heatmap_stats_days">
|
||||
<item quantity="other">%1$d 天</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@ -506,4 +506,11 @@
|
||||
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string>
|
||||
<string name="heatmap_select_year">選擇年份</string>
|
||||
<string name="heatmap_stats_total">累計 Token</string>
|
||||
<string name="heatmap_stats_peak">峰值 Token</string>
|
||||
<string name="heatmap_stats_current_streak">當前連續</string>
|
||||
<string name="heatmap_stats_max_streak">最長連續</string>
|
||||
<plurals name="heatmap_stats_days">
|
||||
<item quantity="other">%1$d 天</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@ -509,4 +509,12 @@
|
||||
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token activity heatmap, %1$d weeks</string>
|
||||
<string name="heatmap_select_year">Select year</string>
|
||||
<string name="heatmap_stats_total">Total tokens</string>
|
||||
<string name="heatmap_stats_peak">Peak tokens</string>
|
||||
<string name="heatmap_stats_current_streak">Current streak</string>
|
||||
<string name="heatmap_stats_max_streak">Longest streak</string>
|
||||
<plurals name="heatmap_stats_days">
|
||||
<item quantity="one">%1$d day</item>
|
||||
<item quantity="other">%1$d days</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@ -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<HeatmapDayData> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -127,4 +127,15 @@
|
||||
|
||||
- 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动
|
||||
- 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据)
|
||||
- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」)
|
||||
- 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 天」
|
||||
Loading…
Reference in New Issue
Block a user