diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index ae45daf8..028f5060 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -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) } } diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index d9cdb32e..9b4bac8e 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -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 " 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. diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 4f46353a..72132f98 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 7c619536..ceafb2b8 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -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 " 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.. 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 → " NN%" (e.g. "agy 12%"). + /// "unsupported"/"error" rows are skipped so they can't show "--". + /// 2. TODAY COST — else analytics.today.cost > 0 → "$" (e.g. "$3.20"). + /// Uses the fresh aggregate from analytics, not per-row today_cost. + /// 3. ATTENTION/COUNT — else rows needing reauth → "CCS !"; else active + /// (non-paused) count → "CCS ", 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) } } diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift index 58bc3d4e..a1ae7166 100644 --- a/macos-bar/Sources/CCSBarCore/BarSummary.swift +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -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 diff --git a/src/cliproxy/quota/quota-fetcher.ts b/src/cliproxy/quota/quota-fetcher.ts index 4a3699b7..bc6e92c5 100644 --- a/src/cliproxy/quota/quota-fetcher.ts +++ b/src/cliproxy/quota/quota-fetcher.ts @@ -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', }; } diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index bd9b00ad..82df9feb 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -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; /** Load persisted CLIProxy usage details (from snapshot cache) */ loadCliproxyDetails: () => Promise; + /** + * Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy). + * Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend. + */ + loadDailyUsage: () => Promise; + /** Load merged hourly usage — the source of request counts (daily lacks them). */ + loadHourlyUsage: () => Promise; /** * 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(p: Promise, ms: number): Promise { // 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(p: Promise, ms: number): Promise { * 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 => { 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; diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts index 80186906..f93015c3 100644 --- a/src/web-server/usage/bar-analytics.ts +++ b/src/web-server/usage/bar-analytics.ts @@ -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 = { + '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(); + 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(); + const modelAll = new Map(); + const bumpModel = (map: Map, 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(); + const surfaceAll = new Map(); + const bumpSurface = ( + map: Map, + 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(), }; } diff --git a/tests/unit/web-server/bar-analytics.test.ts b/tests/unit/web-server/bar-analytics.test.ts index daedbcae..442f404a 100644 --- a/tests/unit/web-server/bar-analytics.test.ts +++ b/tests/unit/web-server/bar-analytics.test.ts @@ -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( [ diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 845510e2..4629b951 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -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(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(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(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(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(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(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((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(), });