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