Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -189,4 +195,35 @@ abstract class TokenUsageDao {
providerModels: List<String>,
allModels: Boolean,
): List<TokenUsageActivityDayRow>

@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<String>,
allModels: Boolean,
): List<TokenUsageActivityHourRow>
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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
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,
val dayTotals: Map<LocalDate, Long>,
val hourTotals: Map<LocalDateTime, Long> = emptyMap(),
)

data class TokenActivityDay(val date: LocalDate, val tokens: Long, val level: Int)
Expand All @@ -21,6 +23,28 @@ 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 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,
Expand All @@ -31,23 +55,27 @@ data class TokenActivityStats(
data class TokenActivityRangeData(
val daily: List<TokenActivityDay>,
val weekly: List<TokenActivityWeek>,
val monthly: List<TokenActivityMonth>,
val yearly: List<TokenActivityYear>,
val hourly: List<TokenActivityHour>,
val cumulative: List<TokenActivityDay>,
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,
): 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<LocalDate, Long>,
hourTotals: Map<LocalDateTime, Long>,
start: LocalDate,
end: LocalDate,
): TokenActivityRangeData {
Expand All @@ -67,26 +95,92 @@ 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<YearMonth, Long>()
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<Int, Long>()
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],
)
}

// 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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 每日图表需要覆盖实际允许的多日日期范围

首次进入时 loadInternal 默认加载 DAILY,但 defaultDateRange 仍为最近 30 天;这里对超过两天的范围返回空 hourly,而 DAILY 的 TokenActivityHourlyChart 无条件读取 data.hourly。因此即使有使用记录,首次进入也只看到空图。日期选择器在 DAILY 模式选择三天以上也能稳定触发,saveCustomRange 不会改变模式。请协调默认范围、模式和自定义日期的渲染规则,保证所有可选范围都产生可显示的数据,不能只修首次默认日期。

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<TokenActivityDay>): TokenActivityStats {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,47 @@ 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,
historyStartDate: LocalDate?,
zone: ZoneId,
): TokenStatsTimeRange? {
val startDate = when (mode) {
TokenActivityViewMode.DAILY -> anchorDate
// Each periodic mode covers the most recent complete period, because future
// 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 -> {
// Keep the range aligned with TokenActivityAggregator's Sunday-first weeks.
anchorDate.minusDays((anchorDate.dayOfWeek.value % 7).toLong())
val start = anchorDate.minusDays(7L)
start to anchorDate.minusDays(1L)
}
TokenActivityViewMode.MONTHLY -> {
// minusMonths clamps month-end dates (e.g. Mar 31 -> Feb 28/29).
val start = anchorDate.minusMonths(1L)
start to anchorDate.minusDays(1L)
}
TokenActivityViewMode.YEARLY -> {
// 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
start to anchorDate
}
TokenActivityViewMode.CUMULATIVE -> historyStartDate ?: return null
}
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(),
)
}
Loading