mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #1479 from kaitranntt/kai/feat/ccs-bar-analytics-overhaul
feat(bar): merged multi-source analytics, per-surface breakdown, UI overhaul
This commit is contained in:
12 files changed
+1031
-132
No files matched your search
@@ -1,35 +1,41 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Usage analytics block: spend rollups, a 7-day cost sparkline, and top models.
|
||||
/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface
|
||||
/// breakdown, and top models.
|
||||
///
|
||||
/// Pivots on `hasRecentData`: when the trailing 30 days carry no spend, the three
|
||||
/// dead Today/7d/30d cells read as "broken", so they collapse into one honest
|
||||
/// "No usage in N days" line with all-time + last-active promoted to the hero.
|
||||
struct BarAnalyticsView: View {
|
||||
let analytics: BarAnalytics
|
||||
|
||||
private var lastActive: String? {
|
||||
BarFormatting.lastActiveLabel(
|
||||
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Usage")
|
||||
|
||||
// Spend rollups, 2 x 2.
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
if analytics.hasRecentData {
|
||||
recentGrid
|
||||
} else {
|
||||
idleHero
|
||||
}
|
||||
|
||||
// 7-day sparkline.
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text("Last 7 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last7d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
sparklineBlock
|
||||
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps the section
|
||||
// compact; the full list is available in the dashboard.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
SectionLabel("By surface")
|
||||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.bySurface.prefix(5)) { surface in
|
||||
SurfaceBar(surface: surface, peak: peakSurface)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 30)
|
||||
}
|
||||
|
||||
// Top models.
|
||||
@@ -43,6 +49,112 @@ struct BarAnalyticsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live spend grid (only when recent windows actually carry data).
|
||||
private var recentGrid: some View {
|
||||
VStack(spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Honest idle state: a single "No usage in N days" line, with all-time spend
|
||||
/// promoted to the hero and the last-active caption underneath.
|
||||
private var idleHero: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(idleHeadline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
StatCell(
|
||||
title: "All-time spend",
|
||||
value: BarFormatting.money(analytics.allTime.cost),
|
||||
accent: true)
|
||||
if let lastActive {
|
||||
Text(lastActive)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var idleHeadline: String {
|
||||
if let d = analytics.daysSinceLastActivity {
|
||||
return "No usage in \(d) days"
|
||||
}
|
||||
return "No usage in 30 days"
|
||||
}
|
||||
|
||||
/// Sparkline over the 30-day series, with an honest placeholder when every day
|
||||
/// is zero (a flat line with no context reads as broken).
|
||||
private var sparklineBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if sparklineIsEmpty {
|
||||
Text(idleHeadline)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
if let lastActive {
|
||||
Text(lastActive).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text("Last 30 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last30d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sparklineIsEmpty: Bool {
|
||||
analytics.byDay.allSatisfy { $0.cost <= 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage-surface row: surface name + proportional accent bar + cost and
|
||||
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
|
||||
private struct SurfaceBar: View {
|
||||
let surface: BarAnalyticsSurface
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(BarTheme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(surface.surface)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text(BarFormatting.count(surface.requests))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(BarFormatting.money(surface.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small labelled stat tile.
|
||||
@@ -63,7 +175,7 @@ private struct StatCell: View {
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 9)
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
@@ -106,6 +218,6 @@ struct SectionLabel: View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,20 @@ struct BarMenuView: View {
|
||||
if viewModel.offline {
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
// Scrollbar is hidden: the panel chrome already constrains height and a
|
||||
// visible scrollbar track adds visual clutter in an always-on-screen widget.
|
||||
// Overflow is still fully scrollable — the indicator is just not shown.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let analytics = viewModel.analytics {
|
||||
BarAnalyticsView(analytics: analytics)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionLabel("Accounts")
|
||||
if let error = viewModel.lastError {
|
||||
ErrorBanner(message: error)
|
||||
}
|
||||
if viewModel.rows.isEmpty {
|
||||
Text("No accounts configured")
|
||||
.font(.caption)
|
||||
@@ -35,9 +41,13 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.padding(12)
|
||||
}
|
||||
.frame(maxHeight: 520)
|
||||
.scrollIndicators(.hidden)
|
||||
// 580 gives room for the full layout (2×2 grid + sparkline + surface section
|
||||
// + top models + accounts) without wasted whitespace on a typical 1-4 account
|
||||
// setup. The scroll still triggers gracefully when content overflows.
|
||||
.frame(maxHeight: 580)
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -118,17 +128,23 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row: colored health dot, name, a chip subline, today's cost, and
|
||||
/// a control menu.
|
||||
/// One account row — the strongest section of the glance.
|
||||
///
|
||||
/// Top line: health dot, name, default/paused/reauth badges. Subline: provider +
|
||||
/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"),
|
||||
/// and a per-account "Last active <date>" caption. Trailing: today's cost (or a
|
||||
/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume
|
||||
/// toggle, and the overflow menu (set-default / solo / tier-lock).
|
||||
struct BarRowView: View {
|
||||
let row: BarSummaryRow
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 9) {
|
||||
HStack(alignment: .top, spacing: 9) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
@@ -136,6 +152,9 @@ struct BarRowView: View {
|
||||
.font(.system(.body, design: .default).weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if row.isDefault {
|
||||
Chip("default", tint: BarTheme.accent)
|
||||
}
|
||||
if row.paused {
|
||||
Chip("paused", tint: .secondary)
|
||||
}
|
||||
@@ -146,46 +165,79 @@ struct BarRowView: View {
|
||||
HStack(spacing: 6) {
|
||||
Chip(row.provider, tint: BarTheme.accent)
|
||||
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
||||
Text(BarFormatting.quotaLabel(row.quotaPercentage))
|
||||
Text(BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(quotaColor)
|
||||
}
|
||||
if let lastActive = BarFormatting.lastActiveLabel(
|
||||
iso: row.lastActivityAt, daysSince: nil)
|
||||
{
|
||||
Text(lastActive)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 4)
|
||||
|
||||
let cost = BarFormatting.costLabel(row.todayCost)
|
||||
if !cost.isEmpty {
|
||||
Text(cost)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Menu {
|
||||
if row.paused {
|
||||
Button("Resume") { viewModel.resume(row) }
|
||||
} else {
|
||||
Button("Pause") { viewModel.pause(row) }
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
costView
|
||||
HStack(spacing: 2) {
|
||||
pauseToggle
|
||||
overflowMenu
|
||||
}
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 28)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted
|
||||
/// "no data" when the value is null (no usage record on a possibly-stale snapshot).
|
||||
@ViewBuilder private var costView: some View {
|
||||
if let cost = row.todayCost {
|
||||
Text(BarFormatting.money(cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("no data")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible primary action: one tap to pause or resume the account.
|
||||
private var pauseToggle: some View {
|
||||
Button {
|
||||
if row.paused { viewModel.resume(row) } else { viewModel.pause(row) }
|
||||
} label: {
|
||||
Image(systemName: row.paused ? "play.circle" : "pause.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(row.paused ? "Resume account" : "Pause account")
|
||||
}
|
||||
|
||||
private var overflowMenu: some View {
|
||||
Menu {
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 24)
|
||||
}
|
||||
|
||||
/// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro)
|
||||
/// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved
|
||||
/// for genuine transient fetch failures, red for accounts needing reauth.
|
||||
private var healthColor: Color {
|
||||
switch row.health {
|
||||
case "error": return .red
|
||||
@@ -193,6 +245,35 @@ struct BarRowView: View {
|
||||
default: return .green
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota label color: muted for "no quota", warning-tinted for "quota ?".
|
||||
private var quotaColor: Color {
|
||||
switch row.quotaStatus {
|
||||
case "unsupported": return .secondary
|
||||
case "error": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline banner surfacing the last failed action so it is visible rather than
|
||||
/// silently swallowed. Success is confirmed by the default/paused badge updating.
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Small pill label used in account sublines.
|
||||
|
||||
@@ -33,7 +33,7 @@ final class BarViewModel: ObservableObject {
|
||||
|
||||
/// Compact status-bar title.
|
||||
var statusTitle: String {
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics)
|
||||
}
|
||||
|
||||
/// Resolve the discovery file and (re)build the client. Marks offline when
|
||||
|
||||
@@ -42,7 +42,10 @@ let summaryJSON = """
|
||||
"tier": "ultra",
|
||||
"paused": false,
|
||||
"quota_percentage": 82.4,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": "2026-06-08T00:00:00Z",
|
||||
"is_default": true,
|
||||
"last_activity_at": "2026-04-29T10:00:00Z",
|
||||
"today_cost": 3.2,
|
||||
"health": "ok",
|
||||
"cached": true,
|
||||
@@ -56,9 +59,12 @@ let summaryJSON = """
|
||||
"tier": null,
|
||||
"paused": true,
|
||||
"quota_percentage": null,
|
||||
"quotaStatus": "error",
|
||||
"next_reset": null,
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "warning",
|
||||
"health": "error",
|
||||
"cached": false,
|
||||
"fetchedAt": null,
|
||||
"needsReauth": true
|
||||
@@ -71,15 +77,35 @@ do {
|
||||
check(rows.count == 2, "decodes two rows")
|
||||
check(rows[0].accountId == "alice@example.com", "maps account_id")
|
||||
check(rows[0].quotaPercentage == 82.4, "maps quota_percentage")
|
||||
check(rows[0].quotaStatus == "ok", "maps quotaStatus (ok)")
|
||||
check(rows[0].isDefault == true, "maps is_default")
|
||||
check(rows[0].lastActivityAt == "2026-04-29T10:00:00Z", "maps last_activity_at")
|
||||
check(rows[0].todayCost == 3.2, "maps today_cost")
|
||||
check(rows[0].id == "agy:alice@example.com", "stable id is provider:account")
|
||||
check(rows[1].quotaPercentage == nil, "null quota decodes to nil")
|
||||
check(rows[1].quotaStatus == "error", "maps quotaStatus (error)")
|
||||
check(rows[1].isDefault == false, "is_default false decodes")
|
||||
check(rows[1].lastActivityAt == nil, "null last_activity_at decodes to nil")
|
||||
check(rows[1].paused == true, "maps paused")
|
||||
check(rows[1].needsReauth == true, "maps needsReauth")
|
||||
check(rows[1].healthDot == "!", "warning -> ! dot")
|
||||
check(rows[1].healthDot == "X", "error -> X dot")
|
||||
check(rows[0].healthDot == "OK", "ok -> OK dot")
|
||||
} catch {
|
||||
check(false, "decoding threw: \(error)")
|
||||
}
|
||||
|
||||
// An "unsupported" provider (ghcp/kiro) decodes with quotaStatus "unsupported"
|
||||
// and health "ok" — the backend's key correctness fix (no permanent orange dot).
|
||||
do {
|
||||
let unsupportedJSON = """
|
||||
[{
|
||||
"account_id": "copilot-kaitranntt", "provider": "ghcp", "displayName": "copilot-kaitranntt",
|
||||
"tier": null, "paused": false, "quota_percentage": null, "quotaStatus": "unsupported",
|
||||
"next_reset": null, "is_default": true, "last_activity_at": null, "today_cost": null,
|
||||
"health": "ok", "cached": false, "fetchedAt": "2026-06-08T16:40:00.000Z", "needsReauth": false
|
||||
}]
|
||||
"""
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(unsupportedJSON.utf8))
|
||||
check(rows[0].quotaStatus == "unsupported", "ghcp decodes quotaStatus unsupported")
|
||||
check(rows[0].health == "ok", "unsupported provider is health ok (no orange dot)")
|
||||
}
|
||||
|
||||
// MARK: BarDiscovery loading
|
||||
@@ -113,29 +139,106 @@ case .failure(let e):
|
||||
|
||||
// MARK: BarFormatting
|
||||
|
||||
check(BarFormatting.quotaLabel(82.4) == "82%", "quota label rounds")
|
||||
check(BarFormatting.quotaLabel(nil) == "--", "nil quota -> --")
|
||||
// Tri-state quota label: never a bare "--".
|
||||
check(BarFormatting.quotaLabel(percentage: 82.4, status: "ok") == "82%", "ok+pct -> NN%")
|
||||
check(
|
||||
BarFormatting.quotaLabel(percentage: nil, status: "unsupported") == "no quota",
|
||||
"unsupported -> 'no quota'")
|
||||
check(
|
||||
BarFormatting.quotaLabel(percentage: nil, status: "error") == "quota ?", "error -> 'quota ?'")
|
||||
check(BarFormatting.costLabel(3.2) == "$3.20", "cost label formats")
|
||||
check(BarFormatting.costLabel(0) == "", "zero cost hidden")
|
||||
check(BarFormatting.costLabel(nil) == "", "nil cost hidden")
|
||||
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
|
||||
let title = BarFormatting.statusTitle(rows: rows)
|
||||
// Active rows only (bob is paused); alice leads. Total cost = 3.2.
|
||||
check(title.contains("agy 82%"), "title shows leading active account + quota")
|
||||
check(title.contains("$3.20"), "title shows total cost")
|
||||
// MARK: statusTitle fallback chain (NEVER a bare "--", NEVER a lifetime figure when today==0)
|
||||
|
||||
// Shared analytics fixtures for the chain.
|
||||
func mkAnalytics(allTimeCost: Double, todayCost: Double = 0) -> BarAnalytics {
|
||||
BarAnalytics(
|
||||
today: .init(cost: todayCost, requests: todayCost > 0 ? 10 : 0),
|
||||
last7d: .init(cost: 0, requests: 0),
|
||||
last30d: .init(cost: 0, requests: 0),
|
||||
allTime: .init(cost: allTimeCost, requests: 5314),
|
||||
byDay: [],
|
||||
topModels: [],
|
||||
topModelsWindow: "all",
|
||||
lastActivityAt: "2026-04-29T10:00:00Z",
|
||||
daysSinceLastActivity: 40,
|
||||
hasRecentData: false,
|
||||
generatedAt: "2026-06-08T13:00:00Z")
|
||||
}
|
||||
|
||||
// leadRow features the account CLOSEST TO EXHAUSTION (lowest remaining %),
|
||||
// not the healthiest. quota_percentage is REMAINING quota.
|
||||
let twoActive = [
|
||||
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, health: "ok"),
|
||||
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 30, health: "ok"),
|
||||
// (1) QUOTA wins: a quota row yields "<provider> NN%".
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
|
||||
let title = BarFormatting.statusTitle(rows: rows, analytics: mkAnalytics(allTimeCost: 2609.1))
|
||||
check(title == "agy 82%", "title (1) quota wins -> 'agy 82%'")
|
||||
}
|
||||
|
||||
// (1) lowest-remaining among quota rows is chosen; unsupported/error skipped.
|
||||
let quotaMix = [
|
||||
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, quotaStatus: "ok"),
|
||||
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 12, quotaStatus: "ok"),
|
||||
BarSummaryRow(accountId: "c", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
]
|
||||
let twoTitle = BarFormatting.statusTitle(rows: twoActive)
|
||||
check(twoTitle.contains("30%"), "title features lowest-remaining (closest to exhaustion)")
|
||||
check(!twoTitle.contains("90%"), "title does not feature the healthiest account")
|
||||
let mixTitle = BarFormatting.statusTitle(rows: quotaMix, analytics: nil)
|
||||
check(mixTitle == "agy 12%", "title features lowest-remaining quota, skips unsupported")
|
||||
check(!mixTitle.contains("--"), "quota-mix title never contains '--'")
|
||||
|
||||
// (2) TODAY COST wins when no quota but analytics.today.cost > 0.
|
||||
// The title reads the fresh analytics aggregate, not per-row today_cost.
|
||||
let todayRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
]
|
||||
let todayAnalytics = mkAnalytics(allTimeCost: 40844, todayCost: 3.2)
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: todayRows, analytics: todayAnalytics) == "$3.20",
|
||||
"title (2) today cost from analytics -> '$3.20'")
|
||||
|
||||
// (2) today cost zero with non-zero all-time must NOT produce a "~$..." lifetime
|
||||
// figure — the all-time step was removed to prevent confusion with live spend.
|
||||
let staleRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported", todayCost: 0),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported", todayCost: nil),
|
||||
]
|
||||
let staleAnalytics = mkAnalytics(allTimeCost: 2609.1, todayCost: 0)
|
||||
let staleTitle = BarFormatting.statusTitle(rows: staleRows, analytics: staleAnalytics)
|
||||
check(!staleTitle.contains("~$"), "today==0 title never shows a '~$' lifetime figure")
|
||||
check(!staleTitle.contains("2.6k"), "today==0 title never shows the all-time dollar amount")
|
||||
check(!staleTitle.contains("--"), "stale title never contains '--'")
|
||||
// With no quota and today==0 and 2 active rows, chain falls to COUNT.
|
||||
check(staleTitle == "CCS 2", "title (3) no quota + today==0 falls to active count 'CCS 2'")
|
||||
|
||||
// (3) COUNT / ATTENTION when no quota and no today spend.
|
||||
let reauthRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "error", needsReauth: true),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported"),
|
||||
]
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: reauthRows, analytics: mkAnalytics(allTimeCost: 0)) == "CCS 1!",
|
||||
"title (3) reauth attention -> 'CCS 1!'")
|
||||
let activeRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", paused: true, quotaStatus: "unsupported"),
|
||||
]
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: activeRows, analytics: nil) == "CCS 1",
|
||||
"title (3) active count -> 'CCS 1'")
|
||||
|
||||
// (4) empty rows -> "CCS".
|
||||
check(BarFormatting.statusTitle(rows: [], analytics: nil) == "CCS", "title (4) empty -> 'CCS'")
|
||||
|
||||
// All-empty-quota fixture: title never a bare "--".
|
||||
check(
|
||||
!BarFormatting.statusTitle(rows: activeRows, analytics: nil).contains("--"),
|
||||
"no-quota title never contains a bare '--'")
|
||||
|
||||
// leadRow is deterministic with no quota: the is_default row, not rows.first.
|
||||
let leadRows = [
|
||||
BarSummaryRow(accountId: "z", provider: "ghcp", quotaStatus: "unsupported", isDefault: false),
|
||||
BarSummaryRow(accountId: "a", provider: "kiro", quotaStatus: "unsupported", isDefault: true),
|
||||
]
|
||||
check(BarFormatting.leadRow(leadRows)?.accountId == "a", "leadRow prefers is_default row")
|
||||
|
||||
// MARK: RefreshDebouncer (arms at decision time)
|
||||
|
||||
@@ -204,20 +307,65 @@ check(BarFormatting.money(0) == "$0.00", "money shows zero")
|
||||
check(BarFormatting.money(2609.1) == "$2.6k", "money compacts thousands")
|
||||
check(BarFormatting.count(5314) == "5.3k", "count compacts thousands")
|
||||
|
||||
// Mirrors the stale-snapshot reality: recent windows zero, all-time populated,
|
||||
// hasRecentData false, last-active in late April, 30-entry zero-filled byDay.
|
||||
func buildByDayJSON(count: Int) -> String {
|
||||
(0..<count).map { i in "{\"date\":\"2026-05-\(String(format: "%02d", i + 1))\",\"cost\":0,\"requests\":0}" }
|
||||
.joined(separator: ",")
|
||||
}
|
||||
let analyticsJSON = """
|
||||
{"today":{"cost":0,"requests":0},"last7d":{"cost":0,"requests":0},
|
||||
"last30d":{"cost":0,"requests":0},"allTime":{"cost":2609.1,"requests":5314},
|
||||
"byDay":[{"date":"2026-06-08","cost":0,"requests":0}],
|
||||
"byDay":[\(buildByDayJSON(count: 30))],
|
||||
"topModels":[{"model":"gpt-5.4","cost":1253.65,"requests":1306}],
|
||||
"topModelsWindow":"all","generatedAt":"2026-06-08T13:00:00.000Z"}
|
||||
"topModelsWindow":"all","lastActivityAt":"2026-04-29T10:00:00.000Z",
|
||||
"daysSinceLastActivity":40,"hasRecentData":false,
|
||||
"generatedAt":"2026-06-08T13:00:00.000Z"}
|
||||
""".data(using: .utf8)!
|
||||
do {
|
||||
let a = try JSONDecoder().decode(BarAnalytics.self, from: analyticsJSON)
|
||||
check(a.allTime.requests == 5314, "analytics decodes all-time requests")
|
||||
check(a.topModels.first?.model == "gpt-5.4", "analytics decodes top model")
|
||||
check(a.topModelsWindow == "all", "analytics decodes window")
|
||||
check(a.byDay.count == 30, "analytics decodes 30-day byDay series")
|
||||
check(a.lastActivityAt == "2026-04-29T10:00:00.000Z", "analytics decodes lastActivityAt")
|
||||
check(a.daysSinceLastActivity == 40, "analytics decodes daysSinceLastActivity")
|
||||
check(a.hasRecentData == false, "analytics decodes hasRecentData (false on idle)")
|
||||
// bySurface absent from JSON → defaults to empty array (resilient decoding).
|
||||
check(a.bySurface.isEmpty, "analytics bySurface defaults to [] when absent from payload")
|
||||
}
|
||||
|
||||
// MARK: BarAnalyticsSurface decoding
|
||||
|
||||
// Verify bySurface round-trips correctly with the exact field names the backend
|
||||
// sends: `source`, `surface`, `cost`, `requests` (all camelCase / exact match).
|
||||
let surfaceAnalyticsJSON = """
|
||||
{"today":{"cost":1963.70,"requests":13103},"last7d":{"cost":1963.70,"requests":13103},
|
||||
"last30d":{"cost":35620.0,"requests":70973},"allTime":{"cost":40844.0,"requests":91207},
|
||||
"byDay":[\(buildByDayJSON(count: 30))],
|
||||
"topModels":[],"topModelsWindow":"30d",
|
||||
"hasRecentData":true,"generatedAt":"2026-06-09T00:00:00.000Z",
|
||||
"bySurface":[
|
||||
{"source":"custom-parser","surface":"Claude Code","cost":33656.21,"requests":57870},
|
||||
{"source":"codex-native","surface":"Codex","cost":1963.70,"requests":13103}
|
||||
]}
|
||||
""".data(using: .utf8)!
|
||||
do {
|
||||
let a = try JSONDecoder().decode(BarAnalytics.self, from: surfaceAnalyticsJSON)
|
||||
check(a.bySurface.count == 2, "bySurface decodes 2 surfaces")
|
||||
check(a.bySurface[0].surface == "Claude Code", "bySurface[0].surface decodes")
|
||||
check(a.bySurface[0].source == "custom-parser", "bySurface[0].source decodes")
|
||||
check(a.bySurface[0].cost == 33656.21, "bySurface[0].cost decodes")
|
||||
check(a.bySurface[0].requests == 57870, "bySurface[0].requests decodes")
|
||||
check(a.bySurface[1].surface == "Codex", "bySurface[1].surface decodes")
|
||||
check(a.bySurface[1].cost == 1963.70, "bySurface[1].cost decodes")
|
||||
// Stable identity for SwiftUI ForEach: surface name is the id.
|
||||
check(a.bySurface[0].id == "Claude Code", "bySurface[0].id == surface name")
|
||||
// Today cost from the surface-bearing payload.
|
||||
check(a.today.cost == 1963.70, "surface analytics decodes today.cost")
|
||||
check(a.hasRecentData == true, "surface analytics decodes hasRecentData true")
|
||||
} catch {
|
||||
check(false, "analytics decode threw: \(error)")
|
||||
check(false, "surface analytics decode threw: \(error)")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
|
||||
@@ -4,6 +4,25 @@ import Foundation
|
||||
///
|
||||
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
|
||||
/// values are USD.
|
||||
|
||||
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
|
||||
/// or "Codex". The server sends these ordered descending by cost for the same window
|
||||
/// as topModels, so the array can be rendered as-is.
|
||||
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
|
||||
public let source: String
|
||||
public let surface: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
/// Stable identity for SwiftUI lists: surface name is unique within a window.
|
||||
public var id: String { surface }
|
||||
public init(source: String, surface: String, cost: Double, requests: Int) {
|
||||
self.source = source
|
||||
self.surface = surface
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public struct Window: Codable, Sendable, Equatable {
|
||||
public let cost: Double
|
||||
@@ -42,11 +61,23 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public let last7d: Window
|
||||
public let last30d: Window
|
||||
public let allTime: Window
|
||||
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
|
||||
public let byDay: [Day]
|
||||
public let topModels: [Model]
|
||||
/// "30d" when recent data exists, else "all".
|
||||
public let topModelsWindow: String
|
||||
/// ISO timestamp of the most recent non-failed usage record, null if none.
|
||||
public let lastActivityAt: String?
|
||||
/// Whole local-days since `lastActivityAt`, null if no usable records.
|
||||
public let daysSinceLastActivity: Int?
|
||||
/// True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
/// its empty/stale presentation on this without re-deriving it.
|
||||
public let hasRecentData: Bool
|
||||
public let generatedAt: String
|
||||
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
|
||||
/// descending by cost for the same window as topModels. Empty when the backend
|
||||
/// has no surface breakdown; the UI omits the section in that case.
|
||||
public let bySurface: [BarAnalyticsSurface]
|
||||
|
||||
public init(
|
||||
today: Window,
|
||||
@@ -56,7 +87,11 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
byDay: [Day],
|
||||
topModels: [Model],
|
||||
topModelsWindow: String,
|
||||
generatedAt: String
|
||||
lastActivityAt: String? = nil,
|
||||
daysSinceLastActivity: Int? = nil,
|
||||
hasRecentData: Bool = false,
|
||||
generatedAt: String,
|
||||
bySurface: [BarAnalyticsSurface] = []
|
||||
) {
|
||||
self.today = today
|
||||
self.last7d = last7d
|
||||
@@ -65,6 +100,29 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
self.byDay = byDay
|
||||
self.topModels = topModels
|
||||
self.topModelsWindow = topModelsWindow
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.daysSinceLastActivity = daysSinceLastActivity
|
||||
self.hasRecentData = hasRecentData
|
||||
self.generatedAt = generatedAt
|
||||
self.bySurface = bySurface
|
||||
}
|
||||
|
||||
// Custom decoder: `bySurface` is a new field absent from older snapshots.
|
||||
// Defaulting to [] when the key is missing keeps the app backward-compatible
|
||||
// with any cached or older-backend analytics payload.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
today = try c.decode(Window.self, forKey: .today)
|
||||
last7d = try c.decode(Window.self, forKey: .last7d)
|
||||
last30d = try c.decode(Window.self, forKey: .last30d)
|
||||
allTime = try c.decode(Window.self, forKey: .allTime)
|
||||
byDay = try c.decode([Day].self, forKey: .byDay)
|
||||
topModels = try c.decode([Model].self, forKey: .topModels)
|
||||
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
|
||||
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
|
||||
generatedAt = try c.decode(String.self, forKey: .generatedAt)
|
||||
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,30 @@ import Foundation
|
||||
/// Pure formatting helpers for the status-bar title and dropdown rows.
|
||||
/// No SwiftUI dependency so they are unit-testable on any toolchain.
|
||||
public enum BarFormatting {
|
||||
/// Quota percentage label, e.g. "82%" or "--" when unknown.
|
||||
public static func quotaLabel(_ pct: Double?) -> String {
|
||||
guard let pct else { return "--" }
|
||||
/// Tri-state quota label. Honest about WHY a percentage is missing instead of
|
||||
/// collapsing every case to a bare "--":
|
||||
/// status "ok" + pct → "NN%" (threshold-colored upstream)
|
||||
/// status "unsupported" → "no quota" (provider has no quota API)
|
||||
/// status "error" → "quota ?" (transient fetch failure)
|
||||
/// An "ok" status with a nil percentage (shouldn't happen, but be safe) also
|
||||
/// degrades to "quota ?" rather than "--".
|
||||
public static func quotaLabel(percentage pct: Double?, status: String) -> String {
|
||||
switch status {
|
||||
case "ok":
|
||||
guard let pct else { return "quota ?" }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
case "unsupported":
|
||||
return "no quota"
|
||||
default:
|
||||
return "quota ?"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota title token: only an "ok" row with a real percentage yields a token
|
||||
/// (so "unsupported"/"error" rows can never produce "--" in the menu-bar
|
||||
/// title and the fallback chain falls through instead). Returns nil to skip.
|
||||
public static func quotaTitleToken(percentage pct: Double?, status: String) -> String? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
}
|
||||
|
||||
@@ -31,31 +52,81 @@ public enum BarFormatting {
|
||||
return "\(n)"
|
||||
}
|
||||
|
||||
/// Compact status-bar title. Shows the most-used (lowest remaining quota)
|
||||
/// active account, plus today's total cost when available.
|
||||
/// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows.
|
||||
public static func statusTitle(rows: [BarSummaryRow]) -> String {
|
||||
let active = rows.filter { !$0.paused }
|
||||
guard let lead = leadRow(active.isEmpty ? rows : active) else { return "CCS" }
|
||||
var parts: [String] = []
|
||||
let q = quotaLabel(lead.quotaPercentage)
|
||||
parts.append("\(lead.provider) \(q)")
|
||||
let total = rows.compactMap { $0.todayCost }.reduce(0, +)
|
||||
let cost = costLabel(total)
|
||||
if !cost.isEmpty { parts.append(cost) }
|
||||
return parts.joined(separator: " \u{00B7} ")
|
||||
/// Compact, always-meaningful status-bar title. Evaluates an ordered fallback
|
||||
/// chain left to right; the first step that yields a non-empty token wins. A
|
||||
/// bare "--" is NEVER emitted — every step degrades to the next instead.
|
||||
///
|
||||
/// 1. QUOTA — lowest remaining quota among rows whose quotaStatus=="ok"
|
||||
/// with a real percentage → "<provider> NN%" (e.g. "agy 12%").
|
||||
/// "unsupported"/"error" rows are skipped so they can't show "--".
|
||||
/// 2. TODAY COST — else analytics.today.cost > 0 → "$<today>" (e.g. "$3.20").
|
||||
/// Uses the fresh aggregate from analytics, not per-row today_cost.
|
||||
/// 3. ATTENTION/COUNT — else rows needing reauth → "CCS <n>!"; else active
|
||||
/// (non-paused) count → "CCS <n>", fallback to total count.
|
||||
/// 4. "CCS" — only when there are no rows at all.
|
||||
///
|
||||
/// All-time spend is deliberately EXCLUDED from the title chain: a lifetime dollar
|
||||
/// figure (e.g. "$40.8k") always reads as live spend in the always-on menu bar,
|
||||
/// creating false urgency. It belongs only in the analytics section of the dropdown.
|
||||
public static func statusTitle(rows: [BarSummaryRow], analytics: BarAnalytics?) -> String {
|
||||
if rows.isEmpty { return "CCS" }
|
||||
|
||||
// (1) QUOTA — closest to exhaustion among quota-capable rows.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
|
||||
// (2) TODAY COST — fresh aggregate from analytics (more accurate than summing
|
||||
// per-row today_cost, which may have nulls or stale snapshot values).
|
||||
if let todayCost = analytics?.today.cost, todayCost > 0 {
|
||||
return money(todayCost)
|
||||
}
|
||||
|
||||
// (3) ATTENTION / ACTIVE COUNT.
|
||||
let reauthCount = rows.filter { $0.needsReauth }.count
|
||||
if reauthCount > 0 {
|
||||
return "CCS \(reauthCount)!"
|
||||
}
|
||||
let activeCount = rows.filter { !$0.paused }.count
|
||||
return "CCS \(activeCount > 0 ? activeCount : rows.count)"
|
||||
}
|
||||
|
||||
/// The row to surface in the compact title: the one closest to exhaustion.
|
||||
/// `quota_percentage` is REMAINING quota (higher = more left), so the lead is
|
||||
/// the LOWEST remaining percentage. Rows without a known percentage are not
|
||||
/// chosen unless no row has one.
|
||||
static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
let withPct = rows.filter { $0.quotaPercentage != nil }
|
||||
if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) {
|
||||
return lead
|
||||
/// The "headline" account for the dropdown when no quota exists (which account
|
||||
/// name leads). Deterministic: prefer the default row, else the sole active
|
||||
/// row, else alphabetical by id — never `rows.first` (arbitrary order).
|
||||
public static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
if rows.isEmpty { return nil }
|
||||
if let def = rows.first(where: { $0.isDefault }) { return def }
|
||||
let active = rows.filter { !$0.paused }
|
||||
if active.count == 1 { return active[0] }
|
||||
return rows.min(by: { $0.id < $1.id })
|
||||
}
|
||||
|
||||
/// Human "Last active" caption from an ISO timestamp + a precomputed day-delta.
|
||||
/// "Last active today" / "yesterday" / "Apr 29" — never a raw ISO string.
|
||||
public static func lastActiveLabel(iso: String?, daysSince: Int?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
if let d = daysSince {
|
||||
if d <= 0 { return "Last active today" }
|
||||
if d == 1 { return "Last active yesterday" }
|
||||
}
|
||||
return rows.first
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "MMM d"
|
||||
return "Last active \(fmt.string(from: date))"
|
||||
}
|
||||
|
||||
/// Parse an ISO-8601 timestamp (with or without fractional seconds).
|
||||
static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,17 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let tier: String?
|
||||
public let paused: Bool
|
||||
public let quotaPercentage: Double?
|
||||
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
|
||||
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
|
||||
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
|
||||
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
|
||||
/// conflates the two.
|
||||
public let quotaStatus: String
|
||||
public let nextReset: String?
|
||||
/// True when this is the provider's default account; drives the active/default badge.
|
||||
public let isDefault: Bool
|
||||
/// ISO timestamp this account was last used, null if never/unknown.
|
||||
public let lastActivityAt: String?
|
||||
public let todayCost: Double?
|
||||
public let health: String
|
||||
public let cached: Bool
|
||||
@@ -28,7 +38,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
case tier
|
||||
case paused
|
||||
case quotaPercentage = "quota_percentage"
|
||||
case quotaStatus
|
||||
case nextReset = "next_reset"
|
||||
case isDefault = "is_default"
|
||||
case lastActivityAt = "last_activity_at"
|
||||
case todayCost = "today_cost"
|
||||
case health
|
||||
case cached
|
||||
@@ -43,7 +56,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
tier: String? = nil,
|
||||
paused: Bool = false,
|
||||
quotaPercentage: Double? = nil,
|
||||
quotaStatus: String = "ok",
|
||||
nextReset: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
lastActivityAt: String? = nil,
|
||||
todayCost: Double? = nil,
|
||||
health: String = "ok",
|
||||
cached: Bool = false,
|
||||
@@ -56,7 +72,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
self.tier = tier
|
||||
self.paused = paused
|
||||
self.quotaPercentage = quotaPercentage
|
||||
self.quotaStatus = quotaStatus
|
||||
self.nextReset = nextReset
|
||||
self.isDefault = isDefault
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.todayCost = todayCost
|
||||
self.health = health
|
||||
self.cached = cached
|
||||
|
||||
@@ -835,11 +835,15 @@ export async function fetchAccountQuota(
|
||||
if (provider !== 'agy') {
|
||||
const error = `Quota not supported for provider: ${provider}`;
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
// Stable machine code so callers branch on a code, not the human string.
|
||||
// This is "no quota API for this provider", which is healthy — distinct
|
||||
// from a transient fetch failure or an expired token.
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'quota_not_supported',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import type { AccountInfo } from '../../cliproxy/accounts/types';
|
||||
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
|
||||
import type { HealthReport } from '../health-service';
|
||||
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
|
||||
import { computeBarAnalytics } from '../usage/bar-analytics';
|
||||
import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics';
|
||||
import type { DailyUsage, HourlyUsage } from '../usage/types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -41,8 +42,21 @@ export interface BarSummaryRow {
|
||||
paused: boolean;
|
||||
/** Best-guess quota remaining percentage (0-100), null on error */
|
||||
quota_percentage: number | null;
|
||||
/**
|
||||
* Tri-state quota availability for this account:
|
||||
* 'ok' — provider has a quota API and the fetch succeeded
|
||||
* 'unsupported' — provider has no quota API at all (e.g. ghcp, kiro)
|
||||
* 'error' — provider should report quota but the fetch failed/timed out/needs reauth
|
||||
* The UI uses this to render "no quota" (unsupported) vs "quota ?" (error)
|
||||
* instead of a bare "--" that conflates the two.
|
||||
*/
|
||||
quotaStatus: 'ok' | 'unsupported' | 'error';
|
||||
/** ISO timestamp of next quota reset, null if unknown */
|
||||
next_reset: string | null;
|
||||
/** Whether this is the provider's default account (drives the active/default badge) */
|
||||
is_default: boolean;
|
||||
/** ISO timestamp this account was last used, null if never/unknown */
|
||||
last_activity_at: string | null;
|
||||
/** Today's attributed cost in USD, null if unavailable */
|
||||
today_cost: number | null;
|
||||
/** Health status derived from overall system health */
|
||||
@@ -77,6 +91,13 @@ export interface BarRouterDeps {
|
||||
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
|
||||
/** Load persisted CLIProxy usage details (from snapshot cache) */
|
||||
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
|
||||
/**
|
||||
* Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy).
|
||||
* Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend.
|
||||
*/
|
||||
loadDailyUsage: () => Promise<DailyUsage[]>;
|
||||
/** Load merged hourly usage — the source of request counts (daily lacks them). */
|
||||
loadHourlyUsage: () => Promise<HourlyUsage[]>;
|
||||
/**
|
||||
* Optional, retained only for test back-compat. NOT used by the request path:
|
||||
* the bar derives per-account health from each quota result. The real system
|
||||
@@ -146,6 +167,25 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
// Per-account health derivation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Tri-state quota availability derived from the QuotaResult.
|
||||
*
|
||||
* We branch ONLY on the result's stable errorCode, never on a provider
|
||||
* registry: ghcp is listed in MANAGED_QUOTA_PROVIDERS, yet fetchAccountQuota
|
||||
* returns unsupported for every provider !== 'agy'. The result's
|
||||
* 'quota_not_supported' code is the only honest signal that a provider has no
|
||||
* quota API, so we use it here.
|
||||
*
|
||||
* success → 'ok'
|
||||
* errorCode 'quota_not_supported' → 'unsupported' (no quota API; healthy)
|
||||
* anything else (null/timeout/reauth/other failure) → 'error'
|
||||
*/
|
||||
function deriveQuotaStatus(quota: QuotaResult | null): 'ok' | 'unsupported' | 'error' {
|
||||
if (quota?.success === true) return 'ok';
|
||||
if (quota && quota.errorCode === 'quota_not_supported') return 'unsupported';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Health for a single account, derived from its own quota-fetch result.
|
||||
*
|
||||
@@ -153,12 +193,21 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
* system audit. (The system audit is also unsafe here: it shells out via a
|
||||
* synchronous execSync that would block the event loop on the request path.)
|
||||
*
|
||||
* needsReauth → 'error' (token expired; user action required)
|
||||
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
|
||||
* success → 'ok'
|
||||
* A provider with no quota API (quotaStatus 'unsupported', e.g. ghcp/kiro) is
|
||||
* healthy — it must not show a permanent warning dot. 'warning' is reserved for
|
||||
* genuine transient fetch failures, 'error' for accounts needing reauth.
|
||||
*
|
||||
* needsReauth → 'error' (token expired; user action required)
|
||||
* quota unsupported → 'ok' (no quota API is not a fault)
|
||||
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
|
||||
* success → 'ok'
|
||||
*/
|
||||
function deriveHealth(quota: QuotaResult | null): 'ok' | 'warning' | 'error' {
|
||||
function deriveHealth(
|
||||
quota: QuotaResult | null,
|
||||
quotaStatus: 'ok' | 'unsupported' | 'error'
|
||||
): 'ok' | 'warning' | 'error' {
|
||||
if (quota?.needsReauth) return 'error';
|
||||
if (quotaStatus === 'unsupported') return 'ok';
|
||||
if (!quota || !quota.success) return 'warning';
|
||||
return 'ok';
|
||||
}
|
||||
@@ -267,12 +316,17 @@ function buildRow(
|
||||
): BarSummaryRow {
|
||||
const { quota, cached, fetchedAt } = fetchResult;
|
||||
const costKey = resolveCostKey(account);
|
||||
const health = deriveHealth(quota);
|
||||
const quotaStatus = deriveQuotaStatus(quota);
|
||||
const health = deriveHealth(quota, quotaStatus);
|
||||
const isDefault = account.isDefault ?? false;
|
||||
const lastActivityAt = account.lastUsedAt ?? null;
|
||||
|
||||
// Fix #11: when multiple accounts share the same cost-key (e.g. two codex accounts with
|
||||
// the same email), we cannot attribute the combined cost to either individual account.
|
||||
// Report null (unknowable) rather than the inflated shared total.
|
||||
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? 0);
|
||||
// When multiple accounts share the same cost-key (e.g. two codex accounts with
|
||||
// the same email), we cannot attribute the combined cost to either individual
|
||||
// account, so it is null=unknowable. A missing key on a single-owner account is
|
||||
// ALSO null=unknown (no usage record on a possibly-stale snapshot), distinct from
|
||||
// a genuine 0 spend — the UI renders "no data" vs "$0.00" honestly.
|
||||
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? null);
|
||||
|
||||
if (!quota || !quota.success) {
|
||||
// Degraded row: preserve identity fields, null out quota data
|
||||
@@ -283,7 +337,10 @@ function buildRow(
|
||||
tier: account.tier ?? null,
|
||||
paused: account.paused ?? false,
|
||||
quota_percentage: null,
|
||||
quotaStatus,
|
||||
next_reset: null,
|
||||
is_default: isDefault,
|
||||
last_activity_at: lastActivityAt,
|
||||
today_cost: todayCost,
|
||||
health,
|
||||
cached,
|
||||
@@ -299,7 +356,10 @@ function buildRow(
|
||||
tier: quota.tier ?? account.tier ?? null,
|
||||
paused: account.paused ?? false,
|
||||
quota_percentage: extractQuotaPercentage(quota),
|
||||
quotaStatus,
|
||||
next_reset: extractNextReset(quota),
|
||||
is_default: isDefault,
|
||||
last_activity_at: lastActivityAt,
|
||||
today_cost: todayCost,
|
||||
health,
|
||||
cached,
|
||||
@@ -425,14 +485,20 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
/**
|
||||
* GET /analytics
|
||||
*
|
||||
* Rolls up the persisted usage snapshot into today / 7-day / 30-day spend, a
|
||||
* 7-day sparkline, and the top models. Read-only and cheap (the snapshot is
|
||||
* already on disk); bounded so a slow read can't stall the menu.
|
||||
* Rolls up the merged, multi-source usage (Claude Code, Codex, Droid, CLIProxy)
|
||||
* into today / 7-day / 30-day spend, a 30-day sparkline, top models, and a
|
||||
* per-surface breakdown. Reads the dashboard's stale-while-revalidate caches so
|
||||
* recent activity shows even when the CLIProxy snapshot is frozen by a restart.
|
||||
* Both loads are bounded so a slow read can't stall the menu; on miss the
|
||||
* windows degrade to empty rather than failing the payload.
|
||||
*/
|
||||
router.get('/analytics', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
|
||||
const analytics = computeBarAnalytics(details ?? [], new Date());
|
||||
const [daily, hourly] = await Promise.all([
|
||||
withTimeout(deps.loadDailyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as DailyUsage[]),
|
||||
withTimeout(deps.loadHourlyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as HourlyUsage[]),
|
||||
]);
|
||||
const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date());
|
||||
res.json(analytics);
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /analytics error:', (err as Error).message);
|
||||
@@ -456,6 +522,7 @@ import {
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
||||
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
||||
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
||||
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
|
||||
|
||||
/** Production bar router — wired to real dependencies */
|
||||
const barRouter: Router = createBarRouter({
|
||||
@@ -466,6 +533,8 @@ const barRouter: Router = createBarRouter({
|
||||
fetchAccountQuota,
|
||||
getTodayCostByAccount,
|
||||
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
||||
loadDailyUsage: () => getCachedDailyData(),
|
||||
loadHourlyUsage: () => getCachedHourlyData(),
|
||||
});
|
||||
|
||||
export default barRouter;
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||
import type { DailyUsage, HourlyUsage } from './types';
|
||||
|
||||
/** A single day's roll-up (local-day granularity). */
|
||||
export interface BarAnalyticsDay {
|
||||
@@ -20,6 +21,21 @@ export interface BarAnalyticsDay {
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One usage surface's contribution to spend over the active window.
|
||||
* A "surface" is the tool/origin a request came from (Claude Code, Codex, the
|
||||
* CLIProxy router, Droid, …) — the dimension the menu bar uses to answer
|
||||
* "where is my usage actually going".
|
||||
*/
|
||||
export interface BarAnalyticsSurface {
|
||||
/** Raw pipeline source key (custom-parser | codex-native | cliproxy | droid-native | …). */
|
||||
source: string;
|
||||
/** Human label shown in the bar (Claude Code, Codex, CLIProxy, Droid, …). */
|
||||
surface: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** Aggregate spend over a rolling window. */
|
||||
export interface BarAnalyticsWindow {
|
||||
cost: number;
|
||||
@@ -40,17 +56,34 @@ export interface BarAnalytics {
|
||||
last30d: BarAnalyticsWindow;
|
||||
/** Lifetime totals across every record in the snapshot. */
|
||||
allTime: BarAnalyticsWindow;
|
||||
/** Oldest → newest, exactly 7 entries (zero-filled), for the sparkline. */
|
||||
/** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */
|
||||
byDay: BarAnalyticsDay[];
|
||||
/** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */
|
||||
topModels: BarAnalyticsModel[];
|
||||
/** Which window `topModels` covers — the most recent one that has data. */
|
||||
topModelsWindow: '30d' | 'all';
|
||||
/**
|
||||
* Spend/requests broken down by usage surface (tool/origin), for the same
|
||||
* window as `topModels`. Descending by cost. Empty when no source is known
|
||||
* (e.g. the legacy snapshot-only path that carries no surface dimension).
|
||||
*/
|
||||
bySurface: BarAnalyticsSurface[];
|
||||
/** ISO timestamp of the most recent non-failed usage record, null if none. */
|
||||
lastActivityAt: string | null;
|
||||
/** Whole local-days since `lastActivityAt`, null if no usable records. */
|
||||
daysSinceLastActivity: number | null;
|
||||
/**
|
||||
* True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
* its empty/stale presentation on this without re-deriving it.
|
||||
*/
|
||||
hasRecentData: boolean;
|
||||
/** ISO timestamp the payload was generated. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
const SPARKLINE_DAYS = 7;
|
||||
// 30-day trailing window: gives a non-empty sparkline shape even when the last
|
||||
// 7 days are zero, so a stale-but-real history doesn't read as a broken chart.
|
||||
const SPARKLINE_DAYS = 30;
|
||||
const TOP_MODELS_LIMIT = 5;
|
||||
|
||||
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
|
||||
@@ -108,6 +141,10 @@ export function computeBarAnalytics(
|
||||
}
|
||||
};
|
||||
|
||||
// Epoch ms of the most recent non-failed record, tracked inside the single
|
||||
// loop the function already runs (O(1) extra per iteration, no new I/O).
|
||||
let lastActivityMs = -Infinity;
|
||||
|
||||
for (const detail of details) {
|
||||
if (detail.failed) continue;
|
||||
const ts = new Date(detail.timestamp);
|
||||
@@ -116,6 +153,8 @@ export function computeBarAnalytics(
|
||||
const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, …
|
||||
if (delta < 0) continue; // ignore future-dated noise
|
||||
|
||||
if (ts.getTime() > lastActivityMs) lastActivityMs = ts.getTime();
|
||||
|
||||
const cost = Number.isFinite(detail.cost) ? detail.cost : 0;
|
||||
const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0;
|
||||
|
||||
@@ -127,9 +166,12 @@ export function computeBarAnalytics(
|
||||
today.cost += cost;
|
||||
today.requests += requests;
|
||||
}
|
||||
// Window math stays 7-day; only the sparkline bucket fill widens to 30.
|
||||
if (delta < 7) {
|
||||
last7d.cost += cost;
|
||||
last7d.requests += requests;
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(localDayKey(ts));
|
||||
if (bucket) {
|
||||
bucket.cost += cost;
|
||||
@@ -143,6 +185,11 @@ export function computeBarAnalytics(
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityAt =
|
||||
lastActivityMs === -Infinity ? null : new Date(lastActivityMs).toISOString();
|
||||
const daysSinceLastActivity =
|
||||
lastActivityAt === null ? null : dayDelta(now, new Date(lastActivityAt));
|
||||
|
||||
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
|
||||
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
|
||||
const sourceMap = recentHasData ? model30d : modelAll;
|
||||
@@ -159,6 +206,177 @@ export function computeBarAnalytics(
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
topModelsWindow: recentHasData ? '30d' : 'all',
|
||||
// The snapshot-detail path has no surface attribution; the daily path does.
|
||||
bySurface: [],
|
||||
lastActivityAt,
|
||||
daysSinceLastActivity,
|
||||
hasRecentData: recentHasData,
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Maps a raw usage-pipeline `source` to the label shown in the bar. Unknown
|
||||
// sources fall through to their raw key so a new collector is never silently
|
||||
// dropped — it just shows un-prettified until added here.
|
||||
const SURFACE_LABELS: Record<string, string> = {
|
||||
'custom-parser': 'Claude Code',
|
||||
'codex-native': 'Codex',
|
||||
'droid-native': 'Droid',
|
||||
cliproxy: 'CLIProxy',
|
||||
};
|
||||
|
||||
/** Human-friendly surface name for a raw usage `source` key. */
|
||||
function surfaceLabel(source: string): string {
|
||||
if (SURFACE_LABELS[source]) return SURFACE_LABELS[source];
|
||||
return source || 'Other';
|
||||
}
|
||||
|
||||
/** Local-midnight Date from a YYYY-MM-DD day key (calendar-day anchored). */
|
||||
function dateFromDayKey(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map((n) => parseInt(n, 10));
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the merged, multi-source usage aggregates into the bar analytics payload.
|
||||
*
|
||||
* Unlike `computeBarAnalytics` (which reads only the CLIProxy snapshot — frozen
|
||||
* whenever the proxy restarts), this consumes the same merged daily/hourly data
|
||||
* the dashboard uses, so recent activity from Claude Code, Codex, Droid, and the
|
||||
* CLIProxy router all show up. `daily` carries cost+models+source; `hourly`
|
||||
* carries the request counts (daily aggregates don't), so the two are combined:
|
||||
* cost/models/surface-cost from daily, request counts from hourly.
|
||||
*/
|
||||
export function computeBarAnalyticsFromDaily(
|
||||
daily: DailyUsage[],
|
||||
hourly: HourlyUsage[],
|
||||
now: Date
|
||||
): BarAnalytics {
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 });
|
||||
}
|
||||
|
||||
const model30d = new Map<string, BarAnalyticsModel>();
|
||||
const modelAll = new Map<string, BarAnalyticsModel>();
|
||||
const bumpModel = (map: Map<string, BarAnalyticsModel>, model: string, cost: number): void => {
|
||||
const existing = map.get(model);
|
||||
if (existing) existing.cost += cost;
|
||||
else map.set(model, { model, cost, requests: 0 });
|
||||
};
|
||||
|
||||
const surface30d = new Map<string, BarAnalyticsSurface>();
|
||||
const surfaceAll = new Map<string, BarAnalyticsSurface>();
|
||||
const bumpSurface = (
|
||||
map: Map<string, BarAnalyticsSurface>,
|
||||
source: string,
|
||||
cost: number,
|
||||
requests: number
|
||||
): void => {
|
||||
const existing = map.get(source);
|
||||
if (existing) {
|
||||
existing.cost += cost;
|
||||
existing.requests += requests;
|
||||
} else {
|
||||
map.set(source, { source, surface: surfaceLabel(source), cost, requests });
|
||||
}
|
||||
};
|
||||
|
||||
// Latest local-day with real activity (cost or requests), across both passes.
|
||||
let lastActivityKey: string | null = null;
|
||||
const touchActivity = (dayKey: string): void => {
|
||||
if (lastActivityKey === null || dayKey > lastActivityKey) lastActivityKey = dayKey;
|
||||
};
|
||||
|
||||
// Pass 1 — daily: cost, per-model spend, per-surface spend, sparkline cost.
|
||||
for (const d of daily) {
|
||||
if (!d || !d.date) continue;
|
||||
const delta = dayDelta(now, dateFromDayKey(d.date));
|
||||
if (delta < 0) continue; // ignore future-dated noise
|
||||
const cost = Number.isFinite(d.totalCost) ? d.totalCost : Number.isFinite(d.cost) ? d.cost : 0;
|
||||
const source = d.source || '';
|
||||
|
||||
allTime.cost += cost;
|
||||
bumpSurface(surfaceAll, source, cost, 0);
|
||||
for (const mb of d.modelBreakdowns || []) {
|
||||
bumpModel(modelAll, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
|
||||
}
|
||||
if (cost > 0) touchActivity(d.date);
|
||||
|
||||
if (delta === 0) today.cost += cost;
|
||||
if (delta < 7) last7d.cost += cost;
|
||||
if (delta < 30) {
|
||||
last30d.cost += cost;
|
||||
bumpSurface(surface30d, source, cost, 0);
|
||||
for (const mb of d.modelBreakdowns || []) {
|
||||
bumpModel(model30d, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
|
||||
}
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(d.date);
|
||||
if (bucket) bucket.cost += cost;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — hourly: request counts (daily aggregates don't carry them).
|
||||
for (const h of hourly) {
|
||||
if (!h || !h.hour) continue;
|
||||
const dayKey = h.hour.slice(0, 10);
|
||||
const delta = dayDelta(now, dateFromDayKey(dayKey));
|
||||
if (delta < 0) continue;
|
||||
const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0;
|
||||
if (requests <= 0) continue;
|
||||
const source = h.source || '';
|
||||
|
||||
allTime.requests += requests;
|
||||
bumpSurface(surfaceAll, source, 0, requests);
|
||||
touchActivity(dayKey);
|
||||
|
||||
if (delta === 0) today.requests += requests;
|
||||
if (delta < 7) last7d.requests += requests;
|
||||
if (delta < 30) {
|
||||
last30d.requests += requests;
|
||||
bumpSurface(surface30d, source, 0, requests);
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(dayKey);
|
||||
if (bucket) bucket.requests += requests;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
|
||||
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
|
||||
const topModels = Array.from((recentHasData ? model30d : modelAll).values())
|
||||
.filter((m) => m.cost > 0 || m.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT);
|
||||
const bySurface = Array.from((recentHasData ? surface30d : surfaceAll).values())
|
||||
.filter((s) => s.cost > 0 || s.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
|
||||
const lastActivityAt = lastActivityKey ? dateFromDayKey(lastActivityKey).toISOString() : null;
|
||||
const daysSinceLastActivity = lastActivityKey
|
||||
? dayDelta(now, dateFromDayKey(lastActivityKey))
|
||||
: null;
|
||||
|
||||
return {
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
topModelsWindow: recentHasData ? '30d' : 'all',
|
||||
bySurface,
|
||||
lastActivityAt,
|
||||
daysSinceLastActivity,
|
||||
hasRecentData: recentHasData,
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -29,9 +29,13 @@ describe('computeBarAnalytics', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(a.today.cost).toBe(0);
|
||||
expect(a.allTime.cost).toBe(0);
|
||||
expect(a.byDay).toHaveLength(7);
|
||||
expect(a.byDay).toHaveLength(30);
|
||||
expect(a.topModels).toHaveLength(0);
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
// No usable records → no last-activity signal, not stale-but-present.
|
||||
expect(a.lastActivityAt).toBeNull();
|
||||
expect(a.daysSinceLastActivity).toBeNull();
|
||||
expect(a.hasRecentData).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls today / 7d / 30d / allTime into the right windows', () => {
|
||||
@@ -60,15 +64,48 @@ describe('computeBarAnalytics', () => {
|
||||
expect(a.allTime.cost).toBe(1);
|
||||
});
|
||||
|
||||
it('zero-fills the 7-day sparkline in chronological order', () => {
|
||||
it('zero-fills the 30-day sparkline in chronological order', () => {
|
||||
const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW);
|
||||
expect(a.byDay).toHaveLength(7);
|
||||
expect(a.byDay).toHaveLength(30);
|
||||
// oldest first, newest last
|
||||
expect(a.byDay[0].date < a.byDay[6].date).toBe(true);
|
||||
expect(a.byDay[0].date < a.byDay[29].date).toBe(true);
|
||||
const hit = a.byDay.find((d) => d.cost > 0);
|
||||
expect(hit?.cost).toBe(4);
|
||||
});
|
||||
|
||||
it('populates sparkline days 8..30 from records older than the 7-day window', () => {
|
||||
// A record 20 days ago is outside last7d but inside the 30-day sparkline:
|
||||
// the bucket must fill so the chart isn't flat when only old data exists.
|
||||
const a = computeBarAnalytics([detail({ timestamp: daysAgo(20), cost: 6 })], NOW);
|
||||
expect(a.last7d.cost).toBe(0); // 7-day window math unchanged
|
||||
const hit = a.byDay.find((d) => d.cost > 0);
|
||||
expect(hit?.cost).toBe(6);
|
||||
});
|
||||
|
||||
it('reports last-activity and hasRecentData from the freshest non-failed record', () => {
|
||||
const recent = daysAgo(1);
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: daysAgo(5), cost: 1 }),
|
||||
detail({ timestamp: recent, cost: 2 }),
|
||||
// failed record must NOT count as activity even though it's newer
|
||||
detail({ timestamp: daysAgo(0), cost: 9, failed: true }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.lastActivityAt).toBe(recent);
|
||||
expect(a.daysSinceLastActivity).toBe(1);
|
||||
expect(a.hasRecentData).toBe(true);
|
||||
});
|
||||
|
||||
it('reports hasRecentData false and last-activity from old data when the 30-day window is idle', () => {
|
||||
const old = daysAgo(45);
|
||||
const a = computeBarAnalytics([detail({ timestamp: old, cost: 5 })], NOW);
|
||||
expect(a.hasRecentData).toBe(false);
|
||||
expect(a.lastActivityAt).toBe(old);
|
||||
expect(a.daysSinceLastActivity).toBe(45);
|
||||
});
|
||||
|
||||
it('ranks top models by spend and labels the window 30d when recent data exists', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
|
||||
@@ -21,7 +21,10 @@ interface BarSummaryRow {
|
||||
tier: string | null;
|
||||
paused: boolean;
|
||||
quota_percentage: number | null;
|
||||
quotaStatus: 'ok' | 'unsupported' | 'error';
|
||||
next_reset: string | null;
|
||||
is_default: boolean;
|
||||
last_activity_at: string | null;
|
||||
today_cost: number | null;
|
||||
health: 'ok' | 'warning' | 'error';
|
||||
cached: boolean;
|
||||
@@ -71,6 +74,7 @@ function makeQuotaResult(
|
||||
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
|
||||
needsReauth: boolean;
|
||||
error: string;
|
||||
errorCode: string;
|
||||
lastUpdated: number;
|
||||
}> = {}
|
||||
) {
|
||||
@@ -82,6 +86,7 @@ function makeQuotaResult(
|
||||
lastUpdated: overrides.lastUpdated ?? Date.now(),
|
||||
needsReauth: overrides.needsReauth ?? false,
|
||||
...(overrides.error !== undefined && { error: overrides.error }),
|
||||
...(overrides.errorCode !== undefined && { errorCode: overrides.errorCode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +171,8 @@ describe('GET /api/bar/summary', () => {
|
||||
},
|
||||
getTodayCostByAccount: (_details: unknown[]) => mockCostByAccount,
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => mockHealthReport,
|
||||
});
|
||||
|
||||
@@ -221,6 +228,10 @@ describe('GET /api/bar/summary', () => {
|
||||
expect(typeof row.health).toBe('string');
|
||||
expect(['ok', 'warning', 'error']).toContain(row.health);
|
||||
expect(typeof row.needsReauth).toBe('boolean');
|
||||
// New contract fields
|
||||
expect(['ok', 'unsupported', 'error']).toContain(row.quotaStatus);
|
||||
expect(typeof row.is_default).toBe('boolean');
|
||||
expect(row.last_activity_at === null || typeof row.last_activity_at === 'string').toBe(true);
|
||||
});
|
||||
|
||||
it('maps account metadata into the row correctly', async () => {
|
||||
@@ -246,6 +257,10 @@ describe('GET /api/bar/summary', () => {
|
||||
expect(row.paused).toBe(false);
|
||||
expect(row.quota_percentage).toBe(60);
|
||||
expect(row.next_reset).toBe('2026-06-08T00:00:00Z');
|
||||
// is_default mirrors account.isDefault (factory default true); successful
|
||||
// quota fetch → quotaStatus 'ok'.
|
||||
expect(row.is_default).toBe(true);
|
||||
expect(row.quotaStatus).toBe('ok');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -388,15 +403,16 @@ describe('GET /api/bar/summary', () => {
|
||||
expect(row.today_cost).toBeCloseTo(1.23);
|
||||
});
|
||||
|
||||
it('today_cost is 0 or null for accounts with no cost record', async () => {
|
||||
it('today_cost is null (unknown) for accounts with no cost record', async () => {
|
||||
mockAccounts = [makeAccountInfo({ id: 'nocost@example.com', provider: 'agy' })];
|
||||
mockCostByAccount = {}; // no entry for this account
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
const row = body[0];
|
||||
|
||||
// Should be 0 or null — either is acceptable, not a hard error
|
||||
expect(row.today_cost === 0 || row.today_cost === null).toBe(true);
|
||||
// A missing cost-key means "no usage data" (possibly stale snapshot), which is
|
||||
// null=unknown — distinct from a genuine 0 spend. The UI renders "no data".
|
||||
expect(row.today_cost).toBeNull();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -435,6 +451,61 @@ describe('GET /api/bar/summary', () => {
|
||||
expect(body[0].health).toBe('error');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// quotaStatus tri-state (unsupported vs error vs ok) and its health mapping
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('quotaStatus is "ok" and health "ok" on a successful quota fetch', async () => {
|
||||
mockQuotaResult = makeQuotaResult({ success: true });
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].quotaStatus).toBe('ok');
|
||||
expect(body[0].health).toBe('ok');
|
||||
});
|
||||
|
||||
it('provider without a quota API (errorCode quota_not_supported) → quotaStatus "unsupported" and health "ok"', async () => {
|
||||
// Mirrors fetchAccountQuota for any provider !== "agy" (e.g. ghcp/kiro):
|
||||
// success false with the stable quota_not_supported code. This must read as
|
||||
// healthy (no permanent orange dot), not a transient warning.
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
models: [],
|
||||
error: 'Quota not supported for provider: ghcp',
|
||||
errorCode: 'quota_not_supported',
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].quotaStatus).toBe('unsupported');
|
||||
expect(body[0].health).toBe('ok');
|
||||
expect(body[0].quota_percentage).toBeNull();
|
||||
});
|
||||
|
||||
it('transient fetch failure (no errorCode, no reauth) → quotaStatus "error" and health "warning"', async () => {
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
needsReauth: false,
|
||||
models: [],
|
||||
error: 'temporary network blip',
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].quotaStatus).toBe('error');
|
||||
expect(body[0].health).toBe('warning');
|
||||
});
|
||||
|
||||
it('needsReauth → quotaStatus "error" and health "error"', async () => {
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
models: [],
|
||||
error: 'token expired',
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].quotaStatus).toBe('error');
|
||||
expect(body[0].health).toBe('error');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// displayName
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -526,6 +597,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => costMap,
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
});
|
||||
|
||||
@@ -553,7 +626,7 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
expect(row.today_cost).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => {
|
||||
it('attributes cost correctly for account with no email (kiro/ghcp type): cost is null (no record)', async () => {
|
||||
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
@@ -591,6 +664,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => costMap2,
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
});
|
||||
|
||||
@@ -610,7 +685,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl2, '/api/bar/summary');
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].today_cost).toBe(0);
|
||||
// No matching cost-key → null=unknown (not a misleading $0.00).
|
||||
expect(body[0].today_cost).toBeNull();
|
||||
|
||||
await new Promise<void>((resolve) => server2.close(() => resolve()));
|
||||
});
|
||||
@@ -659,6 +735,8 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
|
||||
},
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
});
|
||||
|
||||
@@ -746,6 +824,8 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
|
||||
},
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
});
|
||||
|
||||
@@ -817,6 +897,8 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => costMap,
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user