feat(ai-usage): 按时间范围展示小时与每日用量

This commit is contained in:
ysicing committed 2026-10-03 20:24:19 +08:00
1 parent e33fa55154
commit 0d8f68372d
18 files changed
+416 -225

No files matched your search

+21 -16
View File
@@ -261,18 +261,23 @@ only the suffix; truncation, replacement, parser namespace or time-zone changes
An unterminated final line is previewed separately from committed state, so completing it cannot
double-count usage. No raw log lines, prompts or credentials are persisted. Directory metadata is
still enumerated on each scheduled refresh; only currently discovered files contribute to totals.
The dashboard defaults to the last 365 local-calendar days by model, and shows input/output tokens, cache hit
rate, record count, daily totals and model ranking. The menu bar shows today's token total.
The dashboard defaults to today and retains the last 365 local-calendar days by model. It shows
input/output tokens, cache hit rate, record count, period totals and model ranking. The menu bar shows today's token total.
Cached input is a subset of input; reasoning is a subset of output, so neither is added twice.
Rows are aggregated by day and model before leaving the provider, and ordered by day then model, so
`ModelTokenUsage.id` stays unique and an unchanged corpus produces an equal report.
`ModelTokenUsage.id` stays unique and an unchanged corpus produces an equal report. Each daily row
also retains a bounded histogram of hour-start timestamps to token counts (23–25 entries per local
day). Only same-day/model aggregation merges these histograms; period totals and model rankings
sum numeric fields without constructing a year-long hourly map. Parser namespace v3 rebuilds old
checkpoints once to recover hours, then resumes normal unchanged-file and suffix-only reuse.
Duplicate session IDs and unchanged cumulative snapshots are excluded. Child replayed history
only seeds the cumulative baseline until the first `task_started`, which always follows that
history; `started_at` is not compared against the session creation time, because a subagent's
`started_at` is its parent turn's start and recent Codex builds omit the field entirely.
Unknown models remain unknown.
Local logs cannot reliably identify the paying account or usage on other devices. No cost,
subscription limits or HTTP success rates are inferred from them. The separate quota
Local logs cannot reliably identify the paying account or usage on other devices. Public base API
prices may estimate token costs, explicitly excluding tiered surcharges and subscription billing.
Subscription limits and HTTP success rates are not inferred from local logs. The separate quota
snapshot prefers the installed Codex CLI's local app-server `account/rateLimits/read`; the CLI
owns token refresh and XStats receives only rate-limit data. If that path is unavailable, XStats
queries Codex `wham/usage` using its cached CLI OAuth token. Claude uses its CLI OAuth token for
@@ -322,17 +327,17 @@ are enabled. Token totals use compact notation with
exact hover/VoiceOver values. Cache details are
disclosed on demand. AI usage settings open in a separate window so users can copy account details
from another app without closing the form. Unsaved Sub2API fields stay in memory for the current
app session across page changes; passwords are not written to preferences. Since source data is aggregated
by day, all three activity modes cover the same trailing 365 days: daily uses one cell per day;
weekly uses a seven-cell-high bar per calendar week; cumulative sums usage from the window start
through the selected week and uses the same bar grid. Zero weeks have no lit cells. Partial weeks
include only days inside the window through today. The mode switch lives in the Token activity
header, not in a date-range toolbar. Summary and model ranking follow the mode: daily means today,
weekly means the current calendar week, and cumulative means the current calendar month (all through
now, using the local calendar). The heatmap independently keeps its 365-day window. Pointer hover
immediately shows exact daily/weekly/cumulative counts above the chart; the same label is available
to accessibility. The narrow popover lays cells out at their design size and scrolls the year grid
horizontally, initially at the recent end; the wide panel shrinks cells to fit instead.
app session across page changes; passwords are not written to preferences.
The activity selector offers Today, 7 days, 30 days and Cumulative. Today shows hourly bars;
the trailing 7/30 local-calendar days (including today) show daily bars. Cumulative shows a daily
heatmap over the retained 365-day window. Summary, cost estimate and model ranking use the same
source/model filters and time window as the chart. Hour buckets span actual 60-minute intervals from local midnight, with a shorter final bucket on
fractional DST days, so parser and chart agree even across half-hour daylight-saving transitions. Missing legacy hourly details
are reported, never assigned to midnight. Empty buckets remain zero and do not add to the totals.
The heatmap legend explicitly labels the daily peak, separately from the cumulative summary.
Pointer hover and accessibility expose exact bucket counts. Compact popovers scroll the year grid
horizontally, initially at the recent end; the wide panel shrinks cells to fit. Charts have no timers
or continuous animation and use existing usage-refresh updates.
Numbers and dates follow the app language rather than the system locale.
Pointer selection fades only the selected control background (180 ms); keyboard selection and
background data refresh do not animate the data layout. Reduced motion uses the existing quick fade.
@@ -7,7 +7,7 @@ import CoreFoundation
/// Claude Code 的输入不包含缓存字段;归一化后与 Codex 共用“含缓存输入”的统计口径。
public actor ClaudeLocalUsageProvider: AIUsageProvider {
/// 解析口径变化时提升版本号;旧命名空间的缓存行会在下一次扫描时被清掉。
private static let source = "claude-v2"
private static let source = "claude-v3"
public nonisolated let id = AIProviderID.claude
private let roots: [URL]
private let databaseURL: URL
@@ -124,8 +124,10 @@ enum ClaudeLocalLogParser {
let rawModel = (message["model"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard rawModel != "<synthetic>" else { return nil }
let model = rawModel.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown"
let row = ModelTokenUsage(day: calendar.startOfDay(for: time), model: model,
input: input + read + created, cached: read, output: output, records: 1, cacheCreated: created)
let day = calendar.startOfDay(for: time)
let row = ModelTokenUsage(day: day, model: model,
input: input + read + created, cached: read, output: output, records: 1, cacheCreated: created,
hourlyTokens: [UsageTimeSeries.hourStart(for: time, dayStart: day): input + read + created + output])
return Event(id: id, row: row, final: message["stop_reason"] is String, timestamp: time)
}
@@ -7,7 +7,7 @@ import CoreFoundation
/// 只读取会话的模型和 Token 元数据,不读取 auth.json,不发送网络请求。
public actor CodexLocalUsageProvider: AIUsageProvider {
/// 解析口径变化时提升版本号;旧命名空间的缓存行会在下一次扫描时被清掉。
private static let source = "codex-v2"
private static let source = "codex-v3"
public nonisolated let id = AIProviderID.codex
private let root: URL
private let databaseURL: URL
@@ -142,7 +142,11 @@ struct CodexLocalLogParser: Codable {
let key = "\(day.timeIntervalSince1970):\(model)"
var row = rows[key] ?? ModelTokenUsage(day: day, model: model)
row.input += usage.input; row.cached += min(usage.cached, usage.input)
row.output += usage.output; row.records += 1; rows[key] = row
row.output += usage.output; row.records += 1
let hour = UsageTimeSeries.hourStart(for: timestamp, dayStart: day)
if row.hourlyTokens == nil { row.hourlyTokens = [:] }
row.hourlyTokens?[hour, default: 0] += usage.input + usage.output
rows[key] = row
}
private func modelName(_ object: [String: Any]) -> String? {
@@ -10,14 +10,17 @@ public struct ModelTokenUsage: Equatable, Sendable, Identifiable, Codable {
public var cached: Int = 0
public var cacheCreated: Int = 0
public var output: Int = 0
/// 同一天的小时 Token 分布;nil 表示旧数据没有小时明细,不能当作午夜用量。
public var hourlyTokens: [Date: Int]?
public var records: Int = 0
public var total: Int { input + output }
public var id: String { "\(day.timeIntervalSince1970):\(model)" }
public init(day: Date, model: String, input: Int = 0, cached: Int = 0, output: Int = 0, records: Int = 0, cacheCreated: Int = 0) {
public init(day: Date, model: String, input: Int = 0, cached: Int = 0, output: Int = 0, records: Int = 0, cacheCreated: Int = 0, hourlyTokens: [Date: Int]? = nil) {
self.day = day; self.model = model; self.input = input
self.cached = cached; self.output = output; self.records = records
self.cacheCreated = cacheCreated
self.hourlyTokens = hourlyTokens
}
public mutating func add(_ other: Self) {
@@ -45,14 +48,10 @@ public struct LocalUsageReport: Equatable, Sendable {
rows.reduce(into: ModelTokenUsage(day: .distantPast, model: "")) { $0.add($1) }
}
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计与活动图共用最近 365 天。
/// 摘要、费用、排行和图表共用滚动窗口,包含今天;累计仅指保留的最近 365 天。
public func summary(mode: UsageActivityMode, model: String? = nil, now: Date = Date(),
calendar: Calendar = .current) -> [ModelTokenUsage] {
// 累计摘要、费用和排行必须覆盖图表同一窗口,不能仅从本月月初开始。
if mode == .cumulative { return selected(days: 365, model: model, now: now, calendar: calendar) }
let component: Calendar.Component = mode == .daily ? .day : .weekOfYear
let start = calendar.dateInterval(of: component, for: now)?.start ?? calendar.startOfDay(for: now)
return rows.filter { $0.day >= start && $0.day <= now && (model == nil || $0.model == model) }
selected(days: mode.days, model: model, now: now, calendar: calendar)
}
/// 同一天同一模型会来自多个会话文件,而 `ModelTokenUsage.id` 正以“日期 + 模型”为键,
@@ -62,6 +61,16 @@ public struct LocalUsageReport: Equatable, Sendable {
for row in rows {
var value = merged[row.id] ?? ModelTokenUsage(day: row.day, model: row.model)
value.add(row)
// 小时明细只在同一天/同模型间合并;任一旧行缺失明细时,不伪造完整分布。
if let previous = merged[row.id] {
if let priorHours = previous.hourlyTokens, let hours = row.hourlyTokens {
value.hourlyTokens = priorHours.merging(hours, uniquingKeysWith: +)
} else {
value.hourlyTokens = nil
}
} else {
value.hourlyTokens = row.hourlyTokens
}
merged[row.id] = value
}
// 必须定序:解析状态存在字典里,冷扫描和从检查点恢复的迭代顺序不同,
@@ -4,69 +4,98 @@
import Foundation
public enum UsageActivityMode: String, CaseIterable, Sendable {
case daily, weekly, cumulative
case daily, weekly, monthly, cumulative
public var days: Int {
switch self {
case .daily: 1
case .weekly: 7
case .monthly: 30
case .cumulative: 365
}
}
}
/// 三种图形共用同一年度窗口和筛选结果,累计值从窗口起点累加到所选周。
/// 短周期柱状图。小时以绝对时间标识,夏令时切换日自然产生 23 或 25 个桶。
public struct UsageTimeSeries: Sendable {
public struct Bucket: Identifiable, Sendable {
public let start: Date
public let end: Date
public let total: Int
public var id: Date { start }
}
public let buckets: [Bucket]
public let hasMissingHourlyData: Bool
/// 从本地零点起按实际 60 分钟分桶;半小时夏令时地区的最后一个桶可能不足一小时。
/// 解析与绘图必须采用同一起点,不能混用可能重叠的 Calendar hour interval。
static func hourStart(for timestamp: Date, dayStart: Date) -> Date {
dayStart.addingTimeInterval(floor(timestamp.timeIntervalSince(dayStart) / 3600) * 3600)
}
public init(rows: [ModelTokenUsage], mode: UsageActivityMode, now: Date = Date(), calendar: Calendar = .current) {
let today = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -(mode.days - 1), to: today)!
let end = calendar.date(byAdding: .day, value: 1, to: today)!
let component: Calendar.Component = mode == .daily ? .hour : .day
var values: [Date: Int] = [:]
var missing = false
for row in rows where row.day >= start && row.day <= now {
if mode == .daily {
guard let hours = row.hourlyTokens else { missing = missing || row.total > 0; continue }
for (hour, tokens) in hours where hour >= start && hour <= now {
values[hour, default: 0] += tokens
}
} else {
values[calendar.startOfDay(for: row.day), default: 0] += row.total
}
}
var buckets: [Bucket] = []
var cursor = start
while cursor < end {
let next = min(end, calendar.date(byAdding: component, value: 1, to: cursor)!)
buckets.append(Bucket(start: cursor, end: next, total: values[cursor] ?? 0))
cursor = next
}
self.buckets = buckets
hasMissingHourlyData = missing
}
}
/// 累计视图展示最近 365 个本地日期;每格是当天用量,不把累计快照再次相加。
public struct UsageActivity: Sendable {
public struct Week: Identifiable, Sendable {
public let start: Date
public let end: Date
public let days: [Date?]
public let total: Int
public let cumulative: Int
public var id: Date { start }
}
public let start: Date
public let end: Date
public let daily: [Date: Int]
public let weeks: [Week]
public var peak: Int { daily.values.max() ?? 0 }
public var total: Int { daily.values.reduce(0, +) }
public init(rows: [ModelTokenUsage], now: Date = Date(), calendar: Calendar = .current) {
let end = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -364, to: end)!
self.start = start; self.end = end
var daily: [Date: Int] = [:]
for row in rows where row.day >= start && row.day <= end {
for row in rows where row.day >= start && row.day <= now {
daily[calendar.startOfDay(for: row.day), default: 0] += row.total
}
self.daily = daily
let leading = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
let firstWeek = calendar.date(byAdding: .day, value: -leading, to: start)!
var weeks: [Week] = []
var cumulative = 0
for column in 0..<((leading + 365 + 6) / 7) {
let weekStart = calendar.date(byAdding: .day, value: column * 7, to: firstWeek)!
let dates: [Date?] = (0..<7).map { index in
let date = calendar.date(byAdding: .day, value: index, to: weekStart)!
return date >= start && date <= end ? date : nil
}
let total = dates.compactMap { $0 }.reduce(0) { $0 + (daily[$1] ?? 0) }
cumulative += total
weeks.append(Week(start: max(start, weekStart), end: dates.compactMap { $0 }.last ?? end,
days: dates, total: total, cumulative: cumulative))
weeks.append(Week(start: max(start, weekStart), days: dates))
}
self.weeks = weeks
}
public func peak(for mode: UsageActivityMode) -> Int {
switch mode {
case .daily: daily.values.max() ?? 0
case .weekly: weeks.map(\.total).max() ?? 0
case .cumulative: weeks.last?.cumulative ?? 0
}
}
public static func filledCells(value: Int, peak: Int) -> Int {
guard value > 0, peak > 0 else { return 0 }
return min(7, max(1, Int(ceil(Double(value) / Double(peak) * 7))))
}
/// 周用量可能被单周峰值拉开数量级,用平方根提升低用量周的辨识度;累计保持线性递进。
public static func colorIntensity(value: Int, peak: Int, mode: UsageActivityMode) -> Double {
guard value > 0, peak > 0 else { return 0 }
let fraction = min(1, Double(value) / Double(peak))
let strength = mode == .weekly ? sqrt(fraction) : fraction
return 0.25 + 0.75 * strength
}
}
@@ -1300,8 +1300,6 @@ Token 活动 نشاط الرموز
每日 يومي
每周 أسبوعي
累计 تراكمي
{} 当周:{} Tokens أسبوع {}: {} Tokens
截至 {} 当周累计:{} Tokens الإجمالي حتى أسبوع {}: {} Tokens
Apple 智能 Apple Intelligence
日历 التقويم
月历 التقويم الشهري
@@ -1636,3 +1634,8 @@ GitHub 项目 مشروع GitHub
汇率更新:{} تحديث سعر الصرف: {}
价格来源:models.dev;人民币按缓存汇率换算。 مصدر الأسعار: models.dev؛ تُحوَّل المبالغ إلى اليوان الصيني بسعر الصرف المخزّن مؤقتًا.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 استخدام الذكاء الاصطناعي والحصة يشتركان في هذا الفاصل؛ يُحدَّث شريط القوائم عند وصول بيانات جديدة.
30 天 30 يومًا
按小时统计 الاستخدام بالساعة
按天统计 الاستخدام اليومي
单日最高:{} Tokens أعلى استخدام يومي: {} Tokens
暂无小时明细,请刷新用量。 تفاصيل الساعات غير متاحة. حدّث الاستخدام.
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1300,8 +1300,6 @@ Token 活动 Token-Aktivität
每日 Täglich
每周 Wöchentlich
累计 Kumuliert
{} 当周:{} Tokens Woche vom {}: {} Tokens
截至 {} 当周累计:{} Tokens Bis zur Woche vom {}: {} Tokens kumuliert
Apple 智能 Apple Intelligence
日历 Kalender
月历 Monatskalender
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub-Projekt
汇率更新:{} Wechselkursaktualisierung: {}
价格来源:models.dev;人民币按缓存汇率换算。 Preisquelle: models.dev; Yuan-Beträge werden mit dem gespeicherten Wechselkurs umgerechnet.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutzung und Kontingent teilen dieses Intervall; Die Menüleiste wird aktualisiert, sobald neue Daten vorliegen.
30 天 30 Tage
按小时统计 Stündliche Nutzung
按天统计 Tägliche Nutzung
单日最高:{} Tokens Tagesmaximum: {} Tokens
暂无小时明细,请刷新用量。 Keine Stundendaten verfügbar. Nutzung aktualisieren.
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1300,8 +1300,6 @@ Token 活动 Actividad de tokens
每日 Diario
每周 Semanal
累计 Acumulado
{} 当周:{} Tokens Semana del {}: {} Tokens
截至 {} 当周累计:{} Tokens Acumulado hasta la semana del {}: {} Tokens
Apple 智能 Apple Intelligence
日历 Calendario
月历 Calendario mensual
@@ -1636,3 +1634,8 @@ GitHub 项目 Proyecto en GitHub
汇率更新:{} Tipo de cambio actualizado: {}
价格来源:models.dev;人民币按缓存汇率换算。 Fuente de precios: models.dev; Los importes en yuanes se convierten con el tipo de cambio almacenado.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso de IA y la cuota comparten este intervalo; La barra de menús se actualiza cuando llegan nuevos datos.
30 天 30 días
按小时统计 Uso por hora
按天统计 Uso diario
单日最高:{} Tokens Máximo diario: {} Tokens
暂无小时明细,请刷新用量。 No hay detalles por hora. Actualiza el uso.
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1300,8 +1300,6 @@ Token 活动 Activité des tokens
每日 Par jour
每周 Par semaine
累计 Cumulé
{} 当周:{} Tokens Semaine du {} : {} Tokens
截至 {} 当周累计:{} Tokens Cumul jusqu’à la semaine du {} : {} Tokens
Apple 智能 Apple Intelligence
日历 Calendrier
月历 Calendrier mensuel
@@ -1636,3 +1634,8 @@ GitHub 项目 Projet GitHub
汇率更新:{} Taux de change mis à jour : {}
价格来源:models.dev;人民币按缓存汇率换算。 Source des tarifs : models.dev; Les montants en yuans sont convertis au taux de change en cache.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’utilisation de l’IA et le quota partagent cet intervalle; La barre des menus est mise à jour à l’arrivée de nouvelles données.
30 天 30 jours
按小时统计 Utilisation horaire
按天统计 Utilisation quotidienne
单日最高:{} Tokens Maximum quotidien : {} Tokens
暂无小时明细,请刷新用量。 Détails horaires indisponibles. Actualisez l’utilisation.
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1300,8 +1300,6 @@ Token 活动 トークン活動
每日 日別
每周 週別
累计 累計
{} 当周:{} Tokens {} の週:{} Tokens
截至 {} 当周累计:{} Tokens {} の週までの累計:{} Tokens
Apple 智能 Apple Intelligence
日历 カレンダー
月历 月間カレンダー
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub プロジェクト
汇率更新:{} 為替レート更新:{}
价格来源:models.dev;人民币按缓存汇率换算。 料金情報元:models.dev;人民元はキャッシュされた為替レートで換算します。
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使用量と割り当てでこの間隔を共用;新しいデータの取得後にメニューバーを更新します。
30 天 30 日
按小时统计 時間別の使用量
按天统计 日別の使用量
单日最高:{} Tokens 1 日の最大:{} Tokens
暂无小时明细,请刷新用量。 時間別の詳細がありません。使用量を更新してください。
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1300,8 +1300,6 @@ Token 活动 토큰 활동
每日 일별
每周 주별
累计 누적
{} 当周:{} Tokens {} 주: {} Tokens
截至 {} 当周累计:{} Tokens {} 주까지 누적: {} Tokens
Apple 智能 Apple Intelligence
日历 달력
月历 월간 달력
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub 프로젝트
汇率更新:{} 환율 업데이트: {}
价格来源:models.dev;人民币按缓存汇率换算。 가격 출처: models.dev; 중국 위안 금액은 캐시된 환율로 환산합니다.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사용량과 할당량에 동일한 간격 적용; 새 데이터가 도착하면 메뉴 막대가 업데이트됩니다.
30 天 30일
按小时统计 시간별 사용량
按天统计 일별 사용량
单日最高:{} Tokens 일일 최대: {} Tokens
暂无小时明细,请刷新用量。 시간별 세부 정보가 없습니다. 사용량을 새로 고치세요.
1 # Copyright (C) 2026 ysicing
1300 每日
1301 每周
1302 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1303 Apple 智能
1304 日历
1305 月历
1634 汇率更新:{}
1635 价格来源:models.dev;人民币按缓存汇率换算。
1636 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1637 30 天
1638 按小时统计
1639 按天统计
1640 单日最高:{} Tokens
1641 暂无小时明细,请刷新用量。
@@ -1301,8 +1301,6 @@ Token 活动 Token 活動
每日 每日
每周 每週
累计 累計
{} 当周:{} Tokens {} 當週:{} Tokens
截至 {} 当周累计:{} Tokens 截至 {} 當週累計:{} Tokens
Apple 智能 Apple Intelligence
日历 日曆
月历 月曆
@@ -1637,3 +1635,8 @@ GitHub 项目 GitHub 專案
汇率更新:{} 匯率更新:{}
价格来源:models.dev;人民币按缓存汇率换算。 價格來源:models.dev;人民幣按快取匯率換算。
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用量與額度共用此間隔;新資料到達後更新選單列。
30 天 30 天
按小时统计 按小時統計
按天统计 按天統計
单日最高:{} Tokens 單日最高:{} Tokens
暂无小时明细,请刷新用量。 暫無小時明細,請重新整理用量。
1 # Copyright (c) 2026 GiantAccel, LLC
1301 每日
1302 每周
1303 累计
{} 当周:{} Tokens
截至 {} 当周累计:{} Tokens
1304 Apple 智能
1305 日历
1306 月历
1635 汇率更新:{}
1636 价格来源:models.dev;人民币按缓存汇率换算。
1637 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
1638 30 天
1639 按小时统计
1640 按天统计
1641 单日最高:{} Tokens
1642 暂无小时明细,请刷新用量。
@@ -77,10 +77,13 @@ Token 活动 Token activity
每日 Daily
每周 Weekly
累计 Cumulative
{} 当周:{} Tokens Week of {}: {} Tokens
截至 {} 当周累计:{} Tokens Through the week of {}: {} Tokens cumulative
缓存创建 Token Cache creation tokens
AI 用量 AI Usage
30 天 30 days
按小时统计 Hourly usage
按天统计 Daily usage
单日最高:{} Tokens Daily peak: {} Tokens
暂无小时明细,请刷新用量。 Hourly details unavailable. Refresh usage.
额度显示 Quota display
数字单位 Number units
万 / 亿 10K / 100M
@@ -56,6 +56,7 @@ private struct LocalUsageContent: View {
}
var body: some View {
let now = Date()
VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) {
toolbar
if model.settings.aiUsageEnabled && !enabledProviders.isEmpty {
@@ -83,13 +84,13 @@ private struct LocalUsageContent: View {
}
} else if model.settings.aiUsageShowsLocalUsage {
if let report = model.aiUsage.localReport(for: provider) {
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel)
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel)
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel, now: now)
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel, now: now)
let total = LocalUsageReport.total(rows)
UsageSummary(total: total, compact: compact,
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel,
costRows: rows)
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
UsageActivityPanel(rows: activityRows, compact: compact, now: now, mode: $activityMode)
modelRanking(report: report, rows: rows, total: total)
status(report)
} else {
@@ -566,79 +567,137 @@ private struct UsageModelRow: View {
}
}
/// 活动图始终展示最近 365 天;模式同时切换摘要和排行的本日/本周/同窗口累计口径。
private struct UsageHeatmap: View {
/// 摘要和图表共用筛选范围;短周期看柱状分布,累计看年度每日热力图。
private struct UsageActivityPanel: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let rows: [ModelTokenUsage]
let compact: Bool
let now: Date
@Binding var mode: UsageActivityMode
@State private var hovered: String?
var body: some View {
let activity = UsageActivity(rows: rows)
let peak = activity.peak(for: mode)
Card(spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s2) {
Text(tr("Token 活动")).dsFont(.sm, weight: .semibold)
Spacer(minLength: DS.Space.s2)
UsageChoices(selection: $mode, options: [
(.daily, tr("每日")), (.weekly, tr("每周")), (.cumulative, tr("累计"))
], label: tr("Token 活动"))
.frame(width: compact ? 175 : 200)
if !compact { choices.frame(width: 280) }
}
Text(hovered ?? " ")
if compact { choices }
Text(hovered ?? tr(mode == .daily ? "按小时统计" : "按天统计"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1).minimumScaleFactor(0.75)
.padding(.horizontal, DS.Space.s2)
.padding(.vertical, DS.Space.s1 / 2)
.background(hovered == nil ? Color.clear : DS.Palette.track, in: Capsule())
.frame(maxWidth: .infinity, alignment: .trailing)
.frame(height: DS.Space.s6)
.accessibilityHidden(hovered == nil)
if compact {
// 窄弹窗里格子按设计尺寸铺开、溢出视口再横向滚动;
// 若把网格宽度钉在面板宽度上,格子会被压到刚好塞满,滚动就永远不会发生。
ScrollView(.horizontal) {
ActivityGrid(activity: activity, mode: mode, hovered: $hovered, side: DS.Space.s3)
.frame(height: gridHeight)
if mode == .cumulative {
let activity = UsageActivity(rows: rows, now: now)
if compact {
ScrollView(.horizontal) {
ActivityGrid(activity: activity, hovered: $hovered, side: DS.Space.s3)
.frame(height: gridHeight)
}.defaultScrollAnchor(.trailing)
} else {
ActivityGrid(activity: activity, hovered: $hovered).frame(height: gridHeight)
}
.defaultScrollAnchor(.trailing)
Text(UsageNumber.day(activity.start) + " – " + UsageNumber.day(activity.end))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HStack {
Text(tr("单日最高:\(UsageNumber.short(activity.peak, westernUnits: westernUnits)) Tokens"))
.help(UsageNumber.exact(activity.peak) + " Tokens")
Spacer(minLength: DS.Space.s1)
ForEach(0..<5) { level in
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(level == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4))
.frame(width: DS.Space.s3, height: DS.Space.s3)
}
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
} else {
ActivityGrid(activity: activity, mode: mode, hovered: $hovered)
.frame(height: gridHeight)
}
HStack {
if !compact {
Text(UsageNumber.day(activity.start) + " – " +
activity.end.formatted(.dateTime.month().day().locale(L10n.locale)))
Spacer()
let series = UsageTimeSeries(rows: rows, mode: mode, now: now)
if series.hasMissingHourlyData {
Text(tr("暂无小时明细,请刷新用量。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, minHeight: 140)
} else {
UsageBarChart(buckets: series.buckets, hourly: mode == .daily, hovered: $hovered)
}
Text("0")
HStack(spacing: DS.Space.s1 / 2) {
ForEach(0..<5) { level in
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(level == 0 ? DS.Palette.track : DS.Palette.primary.opacity(mode == .daily
? Double(level) / 4 : UsageActivity.colorIntensity(value: level, peak: 4, mode: mode)))
.frame(width: DS.Space.s3, height: DS.Space.s3)
}
}.accessibilityHidden(true)
Text(UsageNumber.short(peak, westernUnits: westernUnits) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
}
.onChange(of: mode) { _, _ in hovered = nil }
.onChange(of: rows) { _, _ in hovered = nil }
}
private var choices: some View {
UsageChoices(selection: $mode, options: [(.daily, tr("今天")), (.weekly, tr("7 天")),
(.monthly, tr("30 天")), (.cumulative, tr("累计"))],
label: tr("Token 活动"))
}
private var gridHeight: CGFloat { 7 * DS.Space.s3 + 6 * (DS.Space.s1 / 2) + DS.Space.s6 }
}
private struct UsageBarChart: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let buckets: [UsageTimeSeries.Bucket]
let hourly: Bool
@Binding var hovered: String?
var body: some View {
let peak = max(1, buckets.map(\.total).max() ?? 0)
VStack(spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s2) {
VStack(alignment: .trailing) {
Text(UsageNumber.short(peak, westernUnits: westernUnits))
Spacer()
Text(UsageNumber.short(peak / 2, westernUnits: westernUnits))
Spacer()
Text("0")
}.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.frame(width: 48, alignment: .trailing).accessibilityHidden(true)
GeometryReader { geometry in
ZStack {
VStack { HairlineDivider(); Spacer(); HairlineDivider(); Spacer(); HairlineDivider() }
.accessibilityHidden(true)
HStack(alignment: .bottom, spacing: DS.Space.s1 / 2) {
ForEach(buckets) { bucket in
let label = bucket.start.formatted(hourly
? .dateTime.month().day().hour().minute().timeZone().locale(L10n.locale)
: .dateTime.month().day().locale(L10n.locale))
+ " · " + UsageNumber.exact(bucket.total) + " Tokens"
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(DS.Palette.primary.opacity(0.8))
.frame(maxWidth: 24)
.frame(height: bucket.total == 0 ? 0 : max(2, geometry.size.height * Double(bucket.total) / Double(peak)))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.contentShape(Rectangle())
.onHover { active in hovered = active ? label : nil }
.accessibilityElement(children: .ignore).accessibilityLabel(label)
}
}
}
}
}.frame(height: 140)
GeometryReader { geometry in
let indices = buckets.count == 7 ? [0, 2, 4, 6]
: Array(Set([0, buckets.count / 4, buckets.count / 2, buckets.count * 3 / 4, max(0, buckets.count - 1)])).sorted()
ForEach(indices, id: \.self) { index in
if buckets.indices.contains(index) {
Text(buckets[index].start.formatted(hourly
? .dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).locale(L10n.locale)
: .dateTime.month(.twoDigits).day(.twoDigits).locale(L10n.locale)))
.position(x: min(geometry.size.width - 20, max(20, geometry.size.width * (Double(index) + 0.5) / Double(buckets.count))), y: 9)
}
}
}.frame(height: 20).padding(.leading, 48 + DS.Space.s2)
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).accessibilityHidden(true)
}
// 时间轴固定从左到右,阿拉伯语标签仍按其文字方向排版。
.environment(\.layoutDirection, .leftToRight)
}
}
private struct ActivityGrid: View {
let activity: UsageActivity
let mode: UsageActivityMode
@Binding var hovered: String?
/// 指定边长时按固有尺寸铺开(窄弹窗横向滚动);为 nil 时收缩到可用宽度(宽面板整年铺满)。
var side: CGFloat?
private var gap: CGFloat { DS.Space.s1 / 2 }
var body: some View {
@@ -646,67 +705,39 @@ private struct ActivityGrid: View {
grid(side: side)
} else {
GeometryReader { geometry in
let columns = activity.weeks.count
grid(side: min(DS.Space.s3,
max(1, (geometry.size.width - CGFloat(columns - 1) * gap) / CGFloat(max(1, columns)))))
max(1, (geometry.size.width - CGFloat(activity.weeks.count - 1) * gap)
/ CGFloat(max(1, activity.weeks.count)))))
}
}
}
private func grid(side: CGFloat) -> some View {
HStack(alignment: .top, spacing: gap) {
let peak = max(1, activity.peak)
return HStack(alignment: .top, spacing: gap) {
ForEach(activity.weeks) { week in
ActivityWeek(week: week, activity: activity, mode: mode, side: side, hovered: $hovered)
}
}
}
}
private struct ActivityWeek: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let week: UsageActivity.Week
let activity: UsageActivity
let mode: UsageActivityMode
let side: CGFloat
@Binding var hovered: String?
private var weekLabel: String {
let date = UsageNumber.day(week.start)
return mode == .weekly
? tr("\(date) 当周:\(UsageNumber.short(week.total, westernUnits: westernUnits)) Tokens")
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative, westernUnits: westernUnits)) Tokens")
}
var body: some View {
let peak = activity.peak(for: mode)
let value = mode == .weekly ? week.total : week.cumulative
let filled = UsageActivity.filledCells(value: value, peak: peak)
let intensity = UsageActivity.colorIntensity(value: value, peak: peak, mode: mode)
VStack(spacing: DS.Space.s1 / 2) {
ForEach(0..<7, id: \.self) { row in
if mode == .daily {
if let date = week.days[row] {
let count = activity.daily[date] ?? 0
let level = min(4, max(1, Int(ceil(Double(count) / Double(max(1, peak)) * 4))))
ActivityCell(side: side,
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
label: UsageNumber.day(date) + " · " + UsageNumber.short(count, westernUnits: westernUnits) + " Tokens",
hovered: $hovered)
} else {
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
if let date = week.days[row] {
let count = activity.daily[date] ?? 0
let level = min(4, max(1, Int(ceil(Double(count) / Double(peak) * 4))))
ActivityCell(side: side,
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
label: UsageNumber.day(date) + " · " + UsageNumber.exact(count) + " Tokens",
hovered: $hovered)
} else {
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
}
}
} else {
ActivityCell(side: side, color: row >= 7 - filled ? DS.Palette.primary.opacity(intensity) : DS.Palette.track,
label: weekLabel, hovered: $hovered)
Color.clear.frame(width: side, height: DS.Space.s4)
.overlay(alignment: .leading) {
if let month = week.days.compactMap({ $0 }).first(where: { Calendar.current.component(.day, from: $0) == 1 }) {
Text(month.formatted(.dateTime.month(.abbreviated).locale(L10n.locale)))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize()
}
}
}
}
Color.clear.frame(width: side, height: DS.Space.s4)
.overlay(alignment: .leading) {
if let month = week.days.compactMap({ $0 }).first(where: { Calendar.current.component(.day, from: $0) == 1 }) {
Text(month.formatted(.dateTime.month(.abbreviated).locale(L10n.locale)))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize()
}
}
}
}
}
@@ -12,6 +12,21 @@ import Testing
""".utf8)
}
@Test func hourlyBucketsUseFinalMessageTimestampWithoutDoubleCounting() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let partial = try #require(ClaudeLocalLogParser.parse(record(output: 1), calendar: calendar))
let data = Data(String(decoding: record(output: 20, final: true), as: UTF8.self)
.replacingOccurrences(of: "10:00:00Z", with: "11:15:00Z").utf8)
let final = try #require(ClaudeLocalLogParser.parse(data, calendar: calendar))
var events: [String: ClaudeLocalLogParser.Event] = [:]
for event in [partial, final, partial, final] { ClaudeLocalLogParser.merge(event, into: &events) }
let rows = LocalUsageReport.aggregated(events.values.map(\.row))
let hour = ISO8601DateFormatter().date(from: "2026-09-22T11:00:00Z")!
#expect(rows.first?.hourlyTokens == [hour: 130])
#expect(LocalUsageReport.total(rows).total == 130)
}
@Test func cacheIsAddedToAnthropicInputExactlyOnce() throws {
let event = try #require(ClaudeLocalLogParser.parse(record()))
#expect(event.row.input == 110)
@@ -13,6 +13,25 @@ import Testing
"{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":\(input),\"cached_input_tokens\":\(cached),\"output_tokens\":\(output)}}}"
}
@Test func hourlyBucketsKeepDeltasDeduplicationAndCacheRestoration() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
var parser = CodexLocalLogParser(calendar: calendar)
parser.consume(line(usage(100, 20), time: "2026-09-22T10:15:00Z"))
parser.consume(line(usage(100, 20), time: "2026-09-22T11:00:00Z"))
parser = try JSONDecoder().decode(CodexLocalLogParser.self, from: JSONEncoder().encode(parser))
parser.consume(line(usage(150, 30), time: "2026-09-22T11:15:00Z"))
let row = try #require(parser.rows.values.first)
let hour10 = ISO8601DateFormatter().date(from: "2026-09-22T10:00:00Z")!
let hour11 = hour10.addingTimeInterval(3600)
#expect(row.hourlyTokens == [hour10: 120, hour11: 60])
#expect(row.total == 180)
let merged = LocalUsageReport.aggregated([row, row])
#expect(merged.count == 1)
#expect(merged.first?.hourlyTokens == [hour10: 240, hour11: 120])
#expect(LocalUsageReport.total(merged).hourlyTokens == nil)
}
@Test func cumulativeDeltasDeduplicateAndFollowModels() {
var parser = CodexLocalLogParser()
parser.consume(line(#"{"model":"model-a"}"#, type: "turn_context"))
@@ -74,6 +93,61 @@ import Testing
#expect(LocalUsageReport.total(Array(parser.rows.values)).total == 12)
}
@Test func legacyCheckpointRebuildsHourlyDetailsOnce() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root.appendingPathComponent("sessions"), withIntermediateDirectories: true)
let url = root.appendingPathComponent("sessions/a.jsonl")
let time = ISO8601DateFormatter().string(from: Date())
var content = line(usage(100, 20), time: time)
var legacy = CodexLocalLogParser()
legacy.consume(content)
for key in legacy.rows.keys { legacy.rows[key]?.hourlyTokens = nil }
content.append(10)
try content.write(to: url)
let database = root.appendingPathComponent("usage-cache.sqlite")
let store = try UsageScanStore(url: database)
_ = try store.scan(url: url, source: "codex-v2", initial: legacy) { _, _ in }
let provider = CodexLocalUsageProvider(root: root)
let first = try #require(try await provider.fetch().localUsage)
#expect(first.rows.first?.hourlyTokens?.values.reduce(0, +) == 120)
#expect(try await CodexLocalUsageProvider(root: root).fetch().localUsage == first)
#expect(try await provider.fetch().localUsage == first)
}
@Test func repeatedClockHourKeepsTwoAbsoluteBuckets() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
var parser = CodexLocalLogParser(calendar: calendar)
parser.consume(line(usage(100, 20), time: "2026-11-01T08:30:00Z"))
parser.consume(line(usage(150, 30), time: "2026-11-01T09:30:00Z"))
let hours = try #require(parser.rows.values.first?.hourlyTokens)
#expect(hours.count == 2)
#expect(hours.values.reduce(0, +) == 180)
#expect(hours.keys.allSatisfy { calendar.component(.hour, from: $0) == 1 })
}
@Test(arguments: ["2026-10-04T12:00:00Z", "2026-04-05T12:00:00Z"])
func halfHourDSTKeepsEveryParsedTokenInTheChart(_ timestamp: String) throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Australia/Lord_Howe"))
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
let start = calendar.startOfDay(for: now)
var parser = CodexLocalLogParser(calendar: calendar)
var time = start
var input = 0
while time <= now {
input += 10
parser.consume(line(usage(input, 0), time: ISO8601DateFormatter().string(from: time)))
time = time.addingTimeInterval(1800)
}
let rows = LocalUsageReport.aggregated(parser.rows.values)
let series = UsageTimeSeries(rows: rows, mode: .daily, now: now, calendar: calendar)
#expect(series.buckets.reduce(0) { $0 + $1.total } == LocalUsageReport.total(rows).total)
let hours = Set(rows.flatMap { Array(($0.hourlyTokens ?? [:]).keys) })
#expect(hours.isSubset(of: Set(series.buckets.map(\.start))))
}
@Test func archiveDedupAndChangedFilesRefresh() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
@@ -6,26 +6,30 @@ import Testing
@testable import AIUsage
@Suite struct UsageActivityTests {
@Test func summaryFollowsTodayThisWeekAndAnnualCumulative() {
@Test func summariesUseTodayAndRollingSevenThirtyAnd365Days() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
calendar.firstWeekday = 2
let formatter = ISO8601DateFormatter()
let now = formatter.date(from: "2026-09-22T12:00:00Z")!
func row(_ date: String, _ value: Int, model: String = "a") -> ModelTokenUsage {
ModelTokenUsage(day: formatter.date(from: date + "T00:00:00Z")!, model: model, input: value)
let now = try #require(ISO8601DateFormatter().date(from: "2026-01-02T12:00:00Z"))
let today = calendar.startOfDay(for: now)
let rows = [-1, 0, 6, 7, 29, 30, 364, 365].map { ago in
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!, model: "a", input: 100)
}
let report = LocalUsageReport(rows: rows, fileCount: 1)
for (mode, total) in [(UsageActivityMode.daily, 100), (.weekly, 200), (.monthly, 400), (.cumulative, 600)] {
let summary = report.summary(mode: mode, now: now, calendar: calendar)
#expect(LocalUsageReport.total(summary).total == total)
#expect(report.summary(mode: mode, model: "missing", now: now, calendar: calendar).isEmpty)
if mode == .weekly || mode == .monthly {
let series = UsageTimeSeries(rows: rows, mode: mode, now: now, calendar: calendar)
#expect(series.buckets.count == mode.days)
#expect(series.buckets.reduce(0) { $0 + $1.total } == total)
#expect(series.buckets.last?.start == today)
}
}
let report = LocalUsageReport(rows: [row("2026-09-22", 1), row("2026-09-21", 10),
row("2026-09-20", 100), row("2026-09-01", 1000), row("2026-08-31", 10000),
row("2026-09-23", 99999), row("2026-09-22", 2, model: "b")], fileCount: 1)
#expect(LocalUsageReport.total(report.summary(mode: .daily, now: now, calendar: calendar)).total == 3)
#expect(LocalUsageReport.total(report.summary(mode: .weekly, now: now, calendar: calendar)).total == 13)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 11113)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 11111)
#expect(LocalUsageReport.total(report.selected(days: 365, now: now, calendar: calendar)).total == 11113)
}
@Test(arguments: ["2026-01-01T12:00:00Z", "2026-03-01T12:00:00Z"])
func cumulativeSummaryMatchesChartAcrossYearAndMonthBoundaries(_ timestamp: String) throws {
func cumulativeSummaryMatchesDailyHeatmapAcrossBoundaries(_ timestamp: String) throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
@@ -42,51 +46,44 @@ import Testing
let activity = UsageActivity(rows: report.selected(days: 365, model: model, now: now, calendar: calendar),
now: now, calendar: calendar)
let total = LocalUsageReport.total(summary).total
#expect(total == activity.peak(for: .cumulative))
#expect(total == activity.total)
#expect(total == (model == nil ? 960 : model == "a" ? 330 : 630))
#expect(activity.peak == (model == nil ? 320 : model == "a" ? 110 : 210))
#expect(activity.weeks.flatMap(\.days).compactMap { $0 }.count == 365)
#expect(LocalUsageReport.models(summary).reduce(0) { $0 + $1.total } == total)
}
}
@Test func weeklyAndCumulativeColorsDistinguishUsageWithoutLightingZero() {
#expect(UsageActivity.colorIntensity(value: 0, peak: 100, mode: .weekly) == 0)
#expect(UsageActivity.colorIntensity(value: 100, peak: 100, mode: .cumulative) == 1)
let values = [1, 10, 25, 50, 100]
for mode in [UsageActivityMode.weekly, .cumulative] {
let colors = values.map { UsageActivity.colorIntensity(value: $0, peak: 100, mode: mode) }
#expect(colors == colors.sorted())
#expect(Set(colors).count == values.count)
}
#expect(UsageActivity.colorIntensity(value: 10, peak: 100, mode: .weekly)
> UsageActivity.colorIntensity(value: 10, peak: 100, mode: .cumulative))
}
@Test func modesShareYearAndWeeklyAccumulationIsNotRepeatedPerDay() throws {
@Test(arguments: [("2026-03-08T20:00:00Z", 23), ("2026-11-01T20:00:00Z", 25), ("2026-10-03T20:00:00Z", 24)])
func hourlyChartHonorsDayLengthAndRepeatedHours(_ timestamp: String, _ count: Int) throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
calendar.firstWeekday = 2
let now = ISO8601DateFormatter().date(from: "2026-09-22T12:00:00Z")!
let today = calendar.startOfDay(for: now)
func row(_ ago: Int, _ count: Int) -> ModelTokenUsage {
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!, model: "test", input: count)
}
let activity = UsageActivity(rows: [row(0, 20), row(1, 10), row(2, 100), row(365, 999), row(-1, 999)], now: now, calendar: calendar)
#expect(activity.weeks.flatMap(\.days).compactMap { $0 }.count == 365)
#expect(activity.weeks.last?.total == 30)
#expect(activity.weeks.last?.cumulative == 130)
#expect(activity.weeks.last?.end == today)
#expect(activity.peak(for: .daily) == 100)
#expect(activity.peak(for: .weekly) == 100)
#expect(activity.peak(for: .cumulative) == 130)
#expect(activity.weeks.reduce(0) { $0 + $1.total } == 130)
let accumulated = activity.weeks.map(\.cumulative)
#expect(accumulated == accumulated.sorted())
calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
let empty = UsageTimeSeries(rows: [], mode: .daily, now: now, calendar: calendar)
#expect(empty.buckets.count == count)
#expect(Set(empty.buckets.map(\.id)).count == count)
let hour1 = empty.buckets[1].start
let hour2 = empty.buckets[2].start
let row = ModelTokenUsage(day: calendar.startOfDay(for: now), model: "a", input: 100, output: 20,
hourlyTokens: [hour1: 50, hour2: 70])
let series = UsageTimeSeries(rows: [row], mode: .daily, now: now, calendar: calendar)
#expect(series.buckets[1].total == 50)
#expect(series.buckets[2].total == 70)
#expect(series.buckets.reduce(0) { $0 + $1.total } == row.total)
#expect(!series.hasMissingHourlyData)
}
@Test func emptyAndSmallBarsHaveHonestCellCounts() {
#expect(UsageActivity.filledCells(value: 0, peak: 100) == 0)
#expect(UsageActivity.filledCells(value: 1, peak: 100) == 1)
#expect(UsageActivity.filledCells(value: 50, peak: 100) == 4)
#expect(UsageActivity.filledCells(value: 100, peak: 100) == 7)
#expect(UsageActivity(rows: []).peak(for: .cumulative) == 0)
@Test func legacyDataDoesNotInventMidnightUsage() throws {
let now = Date()
let old = ModelTokenUsage(day: Calendar.current.startOfDay(for: now), model: "a", input: 100)
var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any])
json.removeValue(forKey: "hourlyTokens")
let restored = try JSONDecoder().decode(ModelTokenUsage.self, from: JSONSerialization.data(withJSONObject: json))
let series = UsageTimeSeries(rows: [restored], mode: .daily, now: now)
#expect(series.hasMissingHourlyData)
#expect(series.buckets.allSatisfy { $0.total == 0 })
let hourly = ModelTokenUsage(day: old.day, model: "a", input: 100, hourlyTokens: [old.day: 100])
#expect(LocalUsageReport.aggregated([old, hourly]).first?.hourlyTokens == nil)
#expect(LocalUsageReport.aggregated([hourly, old]).first?.hourlyTokens == nil)
}
}
@@ -87,6 +87,7 @@ struct LanguageSupportTests {
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
"额度显示", "数字单位", "货币", "估算费用",
"30 天", "按小时统计", "按天统计", "暂无小时明细,请刷新用量。",
"AI 用量与额度共用此间隔;新数据到达后更新菜单栏。",
"价格来源:models.dev;人民币按缓存汇率换算。",
"按模型基础 API 单价估算,不含阶梯加价,非订阅账单。",