From 0d8f68372dd38337faafed35dc381422cfa02ae3 Mon Sep 17 00:00:00 2001 From: ysicing Date: Sat, 3 Oct 2026 20:24:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-usage):=20=E6=8C=89=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=B1=95=E7=A4=BA=E5=B0=8F=E6=97=B6=E4=B8=8E?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 37 +-- .../AIUsage/ClaudeLocalUsageProvider.swift | 8 +- .../AIUsage/CodexLocalUsageProvider.swift | 8 +- .../Sources/AIUsage/LocalUsageReport.swift | 23 +- .../Sources/AIUsage/UsageActivity.swift | 93 +++++--- .../Sources/Localization/Resources/ar.tsv | 7 +- .../Sources/Localization/Resources/de.tsv | 7 +- .../Sources/Localization/Resources/es.tsv | 7 +- .../Sources/Localization/Resources/fr.tsv | 7 +- .../Sources/Localization/Resources/ja.tsv | 7 +- .../Sources/Localization/Resources/ko.tsv | 7 +- .../Localization/Resources/zh-Hant.tsv | 7 +- .../Sources/Localization/Translations.swift | 7 +- .../Sources/XStatsUI/Panel/AIUsagePage.swift | 223 ++++++++++-------- .../AIUsageTests/ClaudeLocalUsageTests.swift | 15 ++ .../AIUsageTests/CodexLocalUsageTests.swift | 74 ++++++ .../AIUsageTests/UsageActivityTests.swift | 103 ++++---- .../LanguageSupportTests.swift | 1 + 18 files changed, 416 insertions(+), 225 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3f9e62..9c7115e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/Packages/XStatsKit/Sources/AIUsage/ClaudeLocalUsageProvider.swift b/Packages/XStatsKit/Sources/AIUsage/ClaudeLocalUsageProvider.swift index 816d9c7..269fb5b 100644 --- a/Packages/XStatsKit/Sources/AIUsage/ClaudeLocalUsageProvider.swift +++ b/Packages/XStatsKit/Sources/AIUsage/ClaudeLocalUsageProvider.swift @@ -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 != "" 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) } diff --git a/Packages/XStatsKit/Sources/AIUsage/CodexLocalUsageProvider.swift b/Packages/XStatsKit/Sources/AIUsage/CodexLocalUsageProvider.swift index 764b64f..99f7b17 100644 --- a/Packages/XStatsKit/Sources/AIUsage/CodexLocalUsageProvider.swift +++ b/Packages/XStatsKit/Sources/AIUsage/CodexLocalUsageProvider.swift @@ -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? { diff --git a/Packages/XStatsKit/Sources/AIUsage/LocalUsageReport.swift b/Packages/XStatsKit/Sources/AIUsage/LocalUsageReport.swift index 4f886ab..f0f8021 100644 --- a/Packages/XStatsKit/Sources/AIUsage/LocalUsageReport.swift +++ b/Packages/XStatsKit/Sources/AIUsage/LocalUsageReport.swift @@ -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 } // 必须定序:解析状态存在字典里,冷扫描和从检查点恢复的迭代顺序不同, diff --git a/Packages/XStatsKit/Sources/AIUsage/UsageActivity.swift b/Packages/XStatsKit/Sources/AIUsage/UsageActivity.swift index cbf1227..0011f69 100644 --- a/Packages/XStatsKit/Sources/AIUsage/UsageActivity.swift +++ b/Packages/XStatsKit/Sources/AIUsage/UsageActivity.swift @@ -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 - } } diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index f316a48..e0e57e6 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -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 +暂无小时明细,请刷新用量。 تفاصيل الساعات غير متاحة. حدّث الاستخدام. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index fd979b0..662c830 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index c48af43..f364281 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 965ba58..8c6ef71 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 18d45af..05b0b2a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -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 +暂无小时明细,请刷新用量。 時間別の詳細がありません。使用量を更新してください。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 82a8aca..12c540e 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -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 +暂无小时明细,请刷新用量。 시간별 세부 정보가 없습니다. 사용량을 새로 고치세요. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index cb8d948..23fd9ba 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -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 +暂无小时明细,请刷新用量。 暫無小時明細,請重新整理用量。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 70a4097..d254e57 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift index 733fb17..6ecc5e7 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift @@ -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() - } - } } } } diff --git a/Packages/XStatsKit/Tests/AIUsageTests/ClaudeLocalUsageTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/ClaudeLocalUsageTests.swift index 2f7f2f7..163eaac 100644 --- a/Packages/XStatsKit/Tests/AIUsageTests/ClaudeLocalUsageTests.swift +++ b/Packages/XStatsKit/Tests/AIUsageTests/ClaudeLocalUsageTests.swift @@ -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) diff --git a/Packages/XStatsKit/Tests/AIUsageTests/CodexLocalUsageTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/CodexLocalUsageTests.swift index 61beac4..b9fa183 100644 --- a/Packages/XStatsKit/Tests/AIUsageTests/CodexLocalUsageTests.swift +++ b/Packages/XStatsKit/Tests/AIUsageTests/CodexLocalUsageTests.swift @@ -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) } diff --git a/Packages/XStatsKit/Tests/AIUsageTests/UsageActivityTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/UsageActivityTests.swift index 4df2028..87c7a04 100644 --- a/Packages/XStatsKit/Tests/AIUsageTests/UsageActivityTests.swift +++ b/Packages/XStatsKit/Tests/AIUsageTests/UsageActivityTests.swift @@ -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) } } diff --git a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift index a1b318f..aa331e4 100644 --- a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift +++ b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift @@ -87,6 +87,7 @@ struct LanguageSupportTests { #expect(catalog.sourceKeys == Translations.shared.sourceKeys) for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言", "额度显示", "数字单位", "货币", "估算费用", + "30 天", "按小时统计", "按天统计", "暂无小时明细,请刷新用量。", "AI 用量与额度共用此间隔;新数据到达后更新菜单栏。", "价格来源:models.dev;人民币按缓存汇率换算。", "按模型基础 API 单价估算,不含阶梯加价,非订阅账单。",