feat(heatmap): 年份选择器新增最近365天窗口(默认)

- RECENT_YEAR(-1) sentinel:最近365天(今天往前364天~今天),默认加载窗口
- computeAllViews 支持窗口模式:每日/每周/累计/统计/分位按窗口计算
- 下拉置顶最近365天+ 分隔线 + 选中标记,与具体年份区分
- 浮层日期跨年自动带年份前缀(formatWeekRangeText 兼容 RECENT)
- setYear 竞态 guard 改 latestRequestedYear(最后点击胜出);RECENT 缓存跨天自动失效重算
- HeatmapAllViewsTest 4 用例,全量 130 用例通过
This commit is contained in:
WaterRain 2026-08-08 00:27:55 +00:00
parent a0cf7fd31c
commit 4ed6aaa3cc
No known key found for this signature in database
7 changed files with 220 additions and 39 deletions

View File

@ -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)}"
}
// ── 辅助类型与函数 ──────────────────────────────────────────

View File

@ -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)

View File

@ -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<Int> = emptyList(), // 可选年份(最早数据年份..今年,降序展示)
val dailyData: List<HeatmapDayData> = 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<UsageRecord>, 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<Long, Long>()
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<HeatmapDayData>(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<HeatmapDayData>(totalDays)
for (d in dailyRaw) {

View File

@ -507,6 +507,7 @@
<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_select_recent">最近 365 天</string>
<string name="heatmap_stats_total">累计 token</string>
<string name="heatmap_stats_peak">峰值 token</string>
<string name="heatmap_stats_current_streak">当前连续</string>

View File

@ -507,6 +507,7 @@
<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_select_recent">最近 365 天</string>
<string name="heatmap_stats_total">累計 Token</string>
<string name="heatmap_stats_peak">峰值 Token</string>
<string name="heatmap_stats_current_streak">當前連續</string>

View File

@ -510,6 +510,7 @@
<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_select_recent">Recent 365 days</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>

View File

@ -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)
}
}