mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar-app): per-surface usage UI, unambiguous title, hidden scrollbar
Mirror the richer analytics payload (quotaStatus, per-account last-active, bySurface) in the Codable models. Add a per-surface usage section (Claude Code, Codex, Droid, CLIProxy) with proportional bars beside the existing spend grid, sparkline, and top models. Rebuild the menu-bar title chain so a lifetime dollar can never sit in the always-on title (it read as live spend): quota% then today spend then account count. Rebuild the accounts rows (default badge, tri-state quota, honest no-data cost, health), pivot to an honest idle hero when no recent data, hide the scroll indicator, and tighten spacing.
This commit is contained in:
@@ -1,35 +1,41 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Usage analytics block: spend rollups, a 7-day cost sparkline, and top models.
|
||||
/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface
|
||||
/// breakdown, and top models.
|
||||
///
|
||||
/// Pivots on `hasRecentData`: when the trailing 30 days carry no spend, the three
|
||||
/// dead Today/7d/30d cells read as "broken", so they collapse into one honest
|
||||
/// "No usage in N days" line with all-time + last-active promoted to the hero.
|
||||
struct BarAnalyticsView: View {
|
||||
let analytics: BarAnalytics
|
||||
|
||||
private var lastActive: String? {
|
||||
BarFormatting.lastActiveLabel(
|
||||
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Usage")
|
||||
|
||||
// Spend rollups, 2 x 2.
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
if analytics.hasRecentData {
|
||||
recentGrid
|
||||
} else {
|
||||
idleHero
|
||||
}
|
||||
|
||||
// 7-day sparkline.
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text("Last 7 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last7d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
sparklineBlock
|
||||
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps the section
|
||||
// compact; the full list is available in the dashboard.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
SectionLabel("By surface")
|
||||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.bySurface.prefix(5)) { surface in
|
||||
SurfaceBar(surface: surface, peak: peakSurface)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 30)
|
||||
}
|
||||
|
||||
// Top models.
|
||||
@@ -43,6 +49,112 @@ struct BarAnalyticsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live spend grid (only when recent windows actually carry data).
|
||||
private var recentGrid: some View {
|
||||
VStack(spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Honest idle state: a single "No usage in N days" line, with all-time spend
|
||||
/// promoted to the hero and the last-active caption underneath.
|
||||
private var idleHero: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(idleHeadline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
StatCell(
|
||||
title: "All-time spend",
|
||||
value: BarFormatting.money(analytics.allTime.cost),
|
||||
accent: true)
|
||||
if let lastActive {
|
||||
Text(lastActive)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var idleHeadline: String {
|
||||
if let d = analytics.daysSinceLastActivity {
|
||||
return "No usage in \(d) days"
|
||||
}
|
||||
return "No usage in 30 days"
|
||||
}
|
||||
|
||||
/// Sparkline over the 30-day series, with an honest placeholder when every day
|
||||
/// is zero (a flat line with no context reads as broken).
|
||||
private var sparklineBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if sparklineIsEmpty {
|
||||
Text(idleHeadline)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
if let lastActive {
|
||||
Text(lastActive).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text("Last 30 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last30d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sparklineIsEmpty: Bool {
|
||||
analytics.byDay.allSatisfy { $0.cost <= 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage-surface row: surface name + proportional accent bar + cost and
|
||||
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
|
||||
private struct SurfaceBar: View {
|
||||
let surface: BarAnalyticsSurface
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(BarTheme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(surface.surface)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text(BarFormatting.count(surface.requests))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(BarFormatting.money(surface.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small labelled stat tile.
|
||||
@@ -63,7 +175,7 @@ private struct StatCell: View {
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 9)
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
@@ -106,6 +218,6 @@ struct SectionLabel: View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,20 @@ struct BarMenuView: View {
|
||||
if viewModel.offline {
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
// Scrollbar is hidden: the panel chrome already constrains height and a
|
||||
// visible scrollbar track adds visual clutter in an always-on-screen widget.
|
||||
// Overflow is still fully scrollable — the indicator is just not shown.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let analytics = viewModel.analytics {
|
||||
BarAnalyticsView(analytics: analytics)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionLabel("Accounts")
|
||||
if let error = viewModel.lastError {
|
||||
ErrorBanner(message: error)
|
||||
}
|
||||
if viewModel.rows.isEmpty {
|
||||
Text("No accounts configured")
|
||||
.font(.caption)
|
||||
@@ -35,9 +41,13 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.padding(12)
|
||||
}
|
||||
.frame(maxHeight: 520)
|
||||
.scrollIndicators(.hidden)
|
||||
// 580 gives room for the full layout (2×2 grid + sparkline + surface section
|
||||
// + top models + accounts) without wasted whitespace on a typical 1-4 account
|
||||
// setup. The scroll still triggers gracefully when content overflows.
|
||||
.frame(maxHeight: 580)
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -118,17 +128,23 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row: colored health dot, name, a chip subline, today's cost, and
|
||||
/// a control menu.
|
||||
/// One account row — the strongest section of the glance.
|
||||
///
|
||||
/// Top line: health dot, name, default/paused/reauth badges. Subline: provider +
|
||||
/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"),
|
||||
/// and a per-account "Last active <date>" caption. Trailing: today's cost (or a
|
||||
/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume
|
||||
/// toggle, and the overflow menu (set-default / solo / tier-lock).
|
||||
struct BarRowView: View {
|
||||
let row: BarSummaryRow
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 9) {
|
||||
HStack(alignment: .top, spacing: 9) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
@@ -136,6 +152,9 @@ struct BarRowView: View {
|
||||
.font(.system(.body, design: .default).weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if row.isDefault {
|
||||
Chip("default", tint: BarTheme.accent)
|
||||
}
|
||||
if row.paused {
|
||||
Chip("paused", tint: .secondary)
|
||||
}
|
||||
@@ -146,46 +165,79 @@ struct BarRowView: View {
|
||||
HStack(spacing: 6) {
|
||||
Chip(row.provider, tint: BarTheme.accent)
|
||||
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
||||
Text(BarFormatting.quotaLabel(row.quotaPercentage))
|
||||
Text(BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(quotaColor)
|
||||
}
|
||||
if let lastActive = BarFormatting.lastActiveLabel(
|
||||
iso: row.lastActivityAt, daysSince: nil)
|
||||
{
|
||||
Text(lastActive)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 4)
|
||||
|
||||
let cost = BarFormatting.costLabel(row.todayCost)
|
||||
if !cost.isEmpty {
|
||||
Text(cost)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Menu {
|
||||
if row.paused {
|
||||
Button("Resume") { viewModel.resume(row) }
|
||||
} else {
|
||||
Button("Pause") { viewModel.pause(row) }
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
costView
|
||||
HStack(spacing: 2) {
|
||||
pauseToggle
|
||||
overflowMenu
|
||||
}
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 28)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted
|
||||
/// "no data" when the value is null (no usage record on a possibly-stale snapshot).
|
||||
@ViewBuilder private var costView: some View {
|
||||
if let cost = row.todayCost {
|
||||
Text(BarFormatting.money(cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("no data")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible primary action: one tap to pause or resume the account.
|
||||
private var pauseToggle: some View {
|
||||
Button {
|
||||
if row.paused { viewModel.resume(row) } else { viewModel.pause(row) }
|
||||
} label: {
|
||||
Image(systemName: row.paused ? "play.circle" : "pause.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(row.paused ? "Resume account" : "Pause account")
|
||||
}
|
||||
|
||||
private var overflowMenu: some View {
|
||||
Menu {
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 24)
|
||||
}
|
||||
|
||||
/// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro)
|
||||
/// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved
|
||||
/// for genuine transient fetch failures, red for accounts needing reauth.
|
||||
private var healthColor: Color {
|
||||
switch row.health {
|
||||
case "error": return .red
|
||||
@@ -193,6 +245,35 @@ struct BarRowView: View {
|
||||
default: return .green
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota label color: muted for "no quota", warning-tinted for "quota ?".
|
||||
private var quotaColor: Color {
|
||||
switch row.quotaStatus {
|
||||
case "unsupported": return .secondary
|
||||
case "error": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline banner surfacing the last failed action so it is visible rather than
|
||||
/// silently swallowed. Success is confirmed by the default/paused badge updating.
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Small pill label used in account sublines.
|
||||
|
||||
@@ -33,7 +33,7 @@ final class BarViewModel: ObservableObject {
|
||||
|
||||
/// Compact status-bar title.
|
||||
var statusTitle: String {
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics)
|
||||
}
|
||||
|
||||
/// Resolve the discovery file and (re)build the client. Marks offline when
|
||||
|
||||
@@ -42,7 +42,10 @@ let summaryJSON = """
|
||||
"tier": "ultra",
|
||||
"paused": false,
|
||||
"quota_percentage": 82.4,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": "2026-06-08T00:00:00Z",
|
||||
"is_default": true,
|
||||
"last_activity_at": "2026-04-29T10:00:00Z",
|
||||
"today_cost": 3.2,
|
||||
"health": "ok",
|
||||
"cached": true,
|
||||
@@ -56,9 +59,12 @@ let summaryJSON = """
|
||||
"tier": null,
|
||||
"paused": true,
|
||||
"quota_percentage": null,
|
||||
"quotaStatus": "error",
|
||||
"next_reset": null,
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "warning",
|
||||
"health": "error",
|
||||
"cached": false,
|
||||
"fetchedAt": null,
|
||||
"needsReauth": true
|
||||
@@ -71,15 +77,35 @@ do {
|
||||
check(rows.count == 2, "decodes two rows")
|
||||
check(rows[0].accountId == "alice@example.com", "maps account_id")
|
||||
check(rows[0].quotaPercentage == 82.4, "maps quota_percentage")
|
||||
check(rows[0].quotaStatus == "ok", "maps quotaStatus (ok)")
|
||||
check(rows[0].isDefault == true, "maps is_default")
|
||||
check(rows[0].lastActivityAt == "2026-04-29T10:00:00Z", "maps last_activity_at")
|
||||
check(rows[0].todayCost == 3.2, "maps today_cost")
|
||||
check(rows[0].id == "agy:alice@example.com", "stable id is provider:account")
|
||||
check(rows[1].quotaPercentage == nil, "null quota decodes to nil")
|
||||
check(rows[1].quotaStatus == "error", "maps quotaStatus (error)")
|
||||
check(rows[1].isDefault == false, "is_default false decodes")
|
||||
check(rows[1].lastActivityAt == nil, "null last_activity_at decodes to nil")
|
||||
check(rows[1].paused == true, "maps paused")
|
||||
check(rows[1].needsReauth == true, "maps needsReauth")
|
||||
check(rows[1].healthDot == "!", "warning -> ! dot")
|
||||
check(rows[1].healthDot == "X", "error -> X dot")
|
||||
check(rows[0].healthDot == "OK", "ok -> OK dot")
|
||||
} catch {
|
||||
check(false, "decoding threw: \(error)")
|
||||
}
|
||||
|
||||
// An "unsupported" provider (ghcp/kiro) decodes with quotaStatus "unsupported"
|
||||
// and health "ok" — the backend's key correctness fix (no permanent orange dot).
|
||||
do {
|
||||
let unsupportedJSON = """
|
||||
[{
|
||||
"account_id": "copilot-kaitranntt", "provider": "ghcp", "displayName": "copilot-kaitranntt",
|
||||
"tier": null, "paused": false, "quota_percentage": null, "quotaStatus": "unsupported",
|
||||
"next_reset": null, "is_default": true, "last_activity_at": null, "today_cost": null,
|
||||
"health": "ok", "cached": false, "fetchedAt": "2026-06-08T16:40:00.000Z", "needsReauth": false
|
||||
}]
|
||||
"""
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(unsupportedJSON.utf8))
|
||||
check(rows[0].quotaStatus == "unsupported", "ghcp decodes quotaStatus unsupported")
|
||||
check(rows[0].health == "ok", "unsupported provider is health ok (no orange dot)")
|
||||
}
|
||||
|
||||
// MARK: BarDiscovery loading
|
||||
@@ -113,29 +139,106 @@ case .failure(let e):
|
||||
|
||||
// MARK: BarFormatting
|
||||
|
||||
check(BarFormatting.quotaLabel(82.4) == "82%", "quota label rounds")
|
||||
check(BarFormatting.quotaLabel(nil) == "--", "nil quota -> --")
|
||||
// Tri-state quota label: never a bare "--".
|
||||
check(BarFormatting.quotaLabel(percentage: 82.4, status: "ok") == "82%", "ok+pct -> NN%")
|
||||
check(
|
||||
BarFormatting.quotaLabel(percentage: nil, status: "unsupported") == "no quota",
|
||||
"unsupported -> 'no quota'")
|
||||
check(
|
||||
BarFormatting.quotaLabel(percentage: nil, status: "error") == "quota ?", "error -> 'quota ?'")
|
||||
check(BarFormatting.costLabel(3.2) == "$3.20", "cost label formats")
|
||||
check(BarFormatting.costLabel(0) == "", "zero cost hidden")
|
||||
check(BarFormatting.costLabel(nil) == "", "nil cost hidden")
|
||||
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
|
||||
let title = BarFormatting.statusTitle(rows: rows)
|
||||
// Active rows only (bob is paused); alice leads. Total cost = 3.2.
|
||||
check(title.contains("agy 82%"), "title shows leading active account + quota")
|
||||
check(title.contains("$3.20"), "title shows total cost")
|
||||
// MARK: statusTitle fallback chain (NEVER a bare "--", NEVER a lifetime figure when today==0)
|
||||
|
||||
// Shared analytics fixtures for the chain.
|
||||
func mkAnalytics(allTimeCost: Double, todayCost: Double = 0) -> BarAnalytics {
|
||||
BarAnalytics(
|
||||
today: .init(cost: todayCost, requests: todayCost > 0 ? 10 : 0),
|
||||
last7d: .init(cost: 0, requests: 0),
|
||||
last30d: .init(cost: 0, requests: 0),
|
||||
allTime: .init(cost: allTimeCost, requests: 5314),
|
||||
byDay: [],
|
||||
topModels: [],
|
||||
topModelsWindow: "all",
|
||||
lastActivityAt: "2026-04-29T10:00:00Z",
|
||||
daysSinceLastActivity: 40,
|
||||
hasRecentData: false,
|
||||
generatedAt: "2026-06-08T13:00:00Z")
|
||||
}
|
||||
|
||||
// leadRow features the account CLOSEST TO EXHAUSTION (lowest remaining %),
|
||||
// not the healthiest. quota_percentage is REMAINING quota.
|
||||
let twoActive = [
|
||||
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, health: "ok"),
|
||||
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 30, health: "ok"),
|
||||
// (1) QUOTA wins: a quota row yields "<provider> NN%".
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
|
||||
let title = BarFormatting.statusTitle(rows: rows, analytics: mkAnalytics(allTimeCost: 2609.1))
|
||||
check(title == "agy 82%", "title (1) quota wins -> 'agy 82%'")
|
||||
}
|
||||
|
||||
// (1) lowest-remaining among quota rows is chosen; unsupported/error skipped.
|
||||
let quotaMix = [
|
||||
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, quotaStatus: "ok"),
|
||||
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 12, quotaStatus: "ok"),
|
||||
BarSummaryRow(accountId: "c", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
]
|
||||
let twoTitle = BarFormatting.statusTitle(rows: twoActive)
|
||||
check(twoTitle.contains("30%"), "title features lowest-remaining (closest to exhaustion)")
|
||||
check(!twoTitle.contains("90%"), "title does not feature the healthiest account")
|
||||
let mixTitle = BarFormatting.statusTitle(rows: quotaMix, analytics: nil)
|
||||
check(mixTitle == "agy 12%", "title features lowest-remaining quota, skips unsupported")
|
||||
check(!mixTitle.contains("--"), "quota-mix title never contains '--'")
|
||||
|
||||
// (2) TODAY COST wins when no quota but analytics.today.cost > 0.
|
||||
// The title reads the fresh analytics aggregate, not per-row today_cost.
|
||||
let todayRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
]
|
||||
let todayAnalytics = mkAnalytics(allTimeCost: 40844, todayCost: 3.2)
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: todayRows, analytics: todayAnalytics) == "$3.20",
|
||||
"title (2) today cost from analytics -> '$3.20'")
|
||||
|
||||
// (2) today cost zero with non-zero all-time must NOT produce a "~$..." lifetime
|
||||
// figure — the all-time step was removed to prevent confusion with live spend.
|
||||
let staleRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported", todayCost: 0),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported", todayCost: nil),
|
||||
]
|
||||
let staleAnalytics = mkAnalytics(allTimeCost: 2609.1, todayCost: 0)
|
||||
let staleTitle = BarFormatting.statusTitle(rows: staleRows, analytics: staleAnalytics)
|
||||
check(!staleTitle.contains("~$"), "today==0 title never shows a '~$' lifetime figure")
|
||||
check(!staleTitle.contains("2.6k"), "today==0 title never shows the all-time dollar amount")
|
||||
check(!staleTitle.contains("--"), "stale title never contains '--'")
|
||||
// With no quota and today==0 and 2 active rows, chain falls to COUNT.
|
||||
check(staleTitle == "CCS 2", "title (3) no quota + today==0 falls to active count 'CCS 2'")
|
||||
|
||||
// (3) COUNT / ATTENTION when no quota and no today spend.
|
||||
let reauthRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "error", needsReauth: true),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported"),
|
||||
]
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: reauthRows, analytics: mkAnalytics(allTimeCost: 0)) == "CCS 1!",
|
||||
"title (3) reauth attention -> 'CCS 1!'")
|
||||
let activeRows = [
|
||||
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
|
||||
BarSummaryRow(accountId: "b", provider: "kiro", paused: true, quotaStatus: "unsupported"),
|
||||
]
|
||||
check(
|
||||
BarFormatting.statusTitle(rows: activeRows, analytics: nil) == "CCS 1",
|
||||
"title (3) active count -> 'CCS 1'")
|
||||
|
||||
// (4) empty rows -> "CCS".
|
||||
check(BarFormatting.statusTitle(rows: [], analytics: nil) == "CCS", "title (4) empty -> 'CCS'")
|
||||
|
||||
// All-empty-quota fixture: title never a bare "--".
|
||||
check(
|
||||
!BarFormatting.statusTitle(rows: activeRows, analytics: nil).contains("--"),
|
||||
"no-quota title never contains a bare '--'")
|
||||
|
||||
// leadRow is deterministic with no quota: the is_default row, not rows.first.
|
||||
let leadRows = [
|
||||
BarSummaryRow(accountId: "z", provider: "ghcp", quotaStatus: "unsupported", isDefault: false),
|
||||
BarSummaryRow(accountId: "a", provider: "kiro", quotaStatus: "unsupported", isDefault: true),
|
||||
]
|
||||
check(BarFormatting.leadRow(leadRows)?.accountId == "a", "leadRow prefers is_default row")
|
||||
|
||||
// MARK: RefreshDebouncer (arms at decision time)
|
||||
|
||||
@@ -204,20 +307,65 @@ check(BarFormatting.money(0) == "$0.00", "money shows zero")
|
||||
check(BarFormatting.money(2609.1) == "$2.6k", "money compacts thousands")
|
||||
check(BarFormatting.count(5314) == "5.3k", "count compacts thousands")
|
||||
|
||||
// Mirrors the stale-snapshot reality: recent windows zero, all-time populated,
|
||||
// hasRecentData false, last-active in late April, 30-entry zero-filled byDay.
|
||||
func buildByDayJSON(count: Int) -> String {
|
||||
(0..<count).map { i in "{\"date\":\"2026-05-\(String(format: "%02d", i + 1))\",\"cost\":0,\"requests\":0}" }
|
||||
.joined(separator: ",")
|
||||
}
|
||||
let analyticsJSON = """
|
||||
{"today":{"cost":0,"requests":0},"last7d":{"cost":0,"requests":0},
|
||||
"last30d":{"cost":0,"requests":0},"allTime":{"cost":2609.1,"requests":5314},
|
||||
"byDay":[{"date":"2026-06-08","cost":0,"requests":0}],
|
||||
"byDay":[\(buildByDayJSON(count: 30))],
|
||||
"topModels":[{"model":"gpt-5.4","cost":1253.65,"requests":1306}],
|
||||
"topModelsWindow":"all","generatedAt":"2026-06-08T13:00:00.000Z"}
|
||||
"topModelsWindow":"all","lastActivityAt":"2026-04-29T10:00:00.000Z",
|
||||
"daysSinceLastActivity":40,"hasRecentData":false,
|
||||
"generatedAt":"2026-06-08T13:00:00.000Z"}
|
||||
""".data(using: .utf8)!
|
||||
do {
|
||||
let a = try JSONDecoder().decode(BarAnalytics.self, from: analyticsJSON)
|
||||
check(a.allTime.requests == 5314, "analytics decodes all-time requests")
|
||||
check(a.topModels.first?.model == "gpt-5.4", "analytics decodes top model")
|
||||
check(a.topModelsWindow == "all", "analytics decodes window")
|
||||
check(a.byDay.count == 30, "analytics decodes 30-day byDay series")
|
||||
check(a.lastActivityAt == "2026-04-29T10:00:00.000Z", "analytics decodes lastActivityAt")
|
||||
check(a.daysSinceLastActivity == 40, "analytics decodes daysSinceLastActivity")
|
||||
check(a.hasRecentData == false, "analytics decodes hasRecentData (false on idle)")
|
||||
// bySurface absent from JSON → defaults to empty array (resilient decoding).
|
||||
check(a.bySurface.isEmpty, "analytics bySurface defaults to [] when absent from payload")
|
||||
}
|
||||
|
||||
// MARK: BarAnalyticsSurface decoding
|
||||
|
||||
// Verify bySurface round-trips correctly with the exact field names the backend
|
||||
// sends: `source`, `surface`, `cost`, `requests` (all camelCase / exact match).
|
||||
let surfaceAnalyticsJSON = """
|
||||
{"today":{"cost":1963.70,"requests":13103},"last7d":{"cost":1963.70,"requests":13103},
|
||||
"last30d":{"cost":35620.0,"requests":70973},"allTime":{"cost":40844.0,"requests":91207},
|
||||
"byDay":[\(buildByDayJSON(count: 30))],
|
||||
"topModels":[],"topModelsWindow":"30d",
|
||||
"hasRecentData":true,"generatedAt":"2026-06-09T00:00:00.000Z",
|
||||
"bySurface":[
|
||||
{"source":"custom-parser","surface":"Claude Code","cost":33656.21,"requests":57870},
|
||||
{"source":"codex-native","surface":"Codex","cost":1963.70,"requests":13103}
|
||||
]}
|
||||
""".data(using: .utf8)!
|
||||
do {
|
||||
let a = try JSONDecoder().decode(BarAnalytics.self, from: surfaceAnalyticsJSON)
|
||||
check(a.bySurface.count == 2, "bySurface decodes 2 surfaces")
|
||||
check(a.bySurface[0].surface == "Claude Code", "bySurface[0].surface decodes")
|
||||
check(a.bySurface[0].source == "custom-parser", "bySurface[0].source decodes")
|
||||
check(a.bySurface[0].cost == 33656.21, "bySurface[0].cost decodes")
|
||||
check(a.bySurface[0].requests == 57870, "bySurface[0].requests decodes")
|
||||
check(a.bySurface[1].surface == "Codex", "bySurface[1].surface decodes")
|
||||
check(a.bySurface[1].cost == 1963.70, "bySurface[1].cost decodes")
|
||||
// Stable identity for SwiftUI ForEach: surface name is the id.
|
||||
check(a.bySurface[0].id == "Claude Code", "bySurface[0].id == surface name")
|
||||
// Today cost from the surface-bearing payload.
|
||||
check(a.today.cost == 1963.70, "surface analytics decodes today.cost")
|
||||
check(a.hasRecentData == true, "surface analytics decodes hasRecentData true")
|
||||
} catch {
|
||||
check(false, "analytics decode threw: \(error)")
|
||||
check(false, "surface analytics decode threw: \(error)")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
|
||||
@@ -4,6 +4,25 @@ import Foundation
|
||||
///
|
||||
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
|
||||
/// values are USD.
|
||||
|
||||
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
|
||||
/// or "Codex". The server sends these ordered descending by cost for the same window
|
||||
/// as topModels, so the array can be rendered as-is.
|
||||
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
|
||||
public let source: String
|
||||
public let surface: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
/// Stable identity for SwiftUI lists: surface name is unique within a window.
|
||||
public var id: String { surface }
|
||||
public init(source: String, surface: String, cost: Double, requests: Int) {
|
||||
self.source = source
|
||||
self.surface = surface
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public struct Window: Codable, Sendable, Equatable {
|
||||
public let cost: Double
|
||||
@@ -42,11 +61,23 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public let last7d: Window
|
||||
public let last30d: Window
|
||||
public let allTime: Window
|
||||
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
|
||||
public let byDay: [Day]
|
||||
public let topModels: [Model]
|
||||
/// "30d" when recent data exists, else "all".
|
||||
public let topModelsWindow: String
|
||||
/// ISO timestamp of the most recent non-failed usage record, null if none.
|
||||
public let lastActivityAt: String?
|
||||
/// Whole local-days since `lastActivityAt`, null if no usable records.
|
||||
public let daysSinceLastActivity: Int?
|
||||
/// True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
/// its empty/stale presentation on this without re-deriving it.
|
||||
public let hasRecentData: Bool
|
||||
public let generatedAt: String
|
||||
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
|
||||
/// descending by cost for the same window as topModels. Empty when the backend
|
||||
/// has no surface breakdown; the UI omits the section in that case.
|
||||
public let bySurface: [BarAnalyticsSurface]
|
||||
|
||||
public init(
|
||||
today: Window,
|
||||
@@ -56,7 +87,11 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
byDay: [Day],
|
||||
topModels: [Model],
|
||||
topModelsWindow: String,
|
||||
generatedAt: String
|
||||
lastActivityAt: String? = nil,
|
||||
daysSinceLastActivity: Int? = nil,
|
||||
hasRecentData: Bool = false,
|
||||
generatedAt: String,
|
||||
bySurface: [BarAnalyticsSurface] = []
|
||||
) {
|
||||
self.today = today
|
||||
self.last7d = last7d
|
||||
@@ -65,6 +100,29 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
self.byDay = byDay
|
||||
self.topModels = topModels
|
||||
self.topModelsWindow = topModelsWindow
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.daysSinceLastActivity = daysSinceLastActivity
|
||||
self.hasRecentData = hasRecentData
|
||||
self.generatedAt = generatedAt
|
||||
self.bySurface = bySurface
|
||||
}
|
||||
|
||||
// Custom decoder: `bySurface` is a new field absent from older snapshots.
|
||||
// Defaulting to [] when the key is missing keeps the app backward-compatible
|
||||
// with any cached or older-backend analytics payload.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
today = try c.decode(Window.self, forKey: .today)
|
||||
last7d = try c.decode(Window.self, forKey: .last7d)
|
||||
last30d = try c.decode(Window.self, forKey: .last30d)
|
||||
allTime = try c.decode(Window.self, forKey: .allTime)
|
||||
byDay = try c.decode([Day].self, forKey: .byDay)
|
||||
topModels = try c.decode([Model].self, forKey: .topModels)
|
||||
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
|
||||
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
|
||||
generatedAt = try c.decode(String.self, forKey: .generatedAt)
|
||||
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,30 @@ import Foundation
|
||||
/// Pure formatting helpers for the status-bar title and dropdown rows.
|
||||
/// No SwiftUI dependency so they are unit-testable on any toolchain.
|
||||
public enum BarFormatting {
|
||||
/// Quota percentage label, e.g. "82%" or "--" when unknown.
|
||||
public static func quotaLabel(_ pct: Double?) -> String {
|
||||
guard let pct else { return "--" }
|
||||
/// Tri-state quota label. Honest about WHY a percentage is missing instead of
|
||||
/// collapsing every case to a bare "--":
|
||||
/// status "ok" + pct → "NN%" (threshold-colored upstream)
|
||||
/// status "unsupported" → "no quota" (provider has no quota API)
|
||||
/// status "error" → "quota ?" (transient fetch failure)
|
||||
/// An "ok" status with a nil percentage (shouldn't happen, but be safe) also
|
||||
/// degrades to "quota ?" rather than "--".
|
||||
public static func quotaLabel(percentage pct: Double?, status: String) -> String {
|
||||
switch status {
|
||||
case "ok":
|
||||
guard let pct else { return "quota ?" }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
case "unsupported":
|
||||
return "no quota"
|
||||
default:
|
||||
return "quota ?"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota title token: only an "ok" row with a real percentage yields a token
|
||||
/// (so "unsupported"/"error" rows can never produce "--" in the menu-bar
|
||||
/// title and the fallback chain falls through instead). Returns nil to skip.
|
||||
public static func quotaTitleToken(percentage pct: Double?, status: String) -> String? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
}
|
||||
|
||||
@@ -31,31 +52,81 @@ public enum BarFormatting {
|
||||
return "\(n)"
|
||||
}
|
||||
|
||||
/// Compact status-bar title. Shows the most-used (lowest remaining quota)
|
||||
/// active account, plus today's total cost when available.
|
||||
/// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows.
|
||||
public static func statusTitle(rows: [BarSummaryRow]) -> String {
|
||||
let active = rows.filter { !$0.paused }
|
||||
guard let lead = leadRow(active.isEmpty ? rows : active) else { return "CCS" }
|
||||
var parts: [String] = []
|
||||
let q = quotaLabel(lead.quotaPercentage)
|
||||
parts.append("\(lead.provider) \(q)")
|
||||
let total = rows.compactMap { $0.todayCost }.reduce(0, +)
|
||||
let cost = costLabel(total)
|
||||
if !cost.isEmpty { parts.append(cost) }
|
||||
return parts.joined(separator: " \u{00B7} ")
|
||||
/// Compact, always-meaningful status-bar title. Evaluates an ordered fallback
|
||||
/// chain left to right; the first step that yields a non-empty token wins. A
|
||||
/// bare "--" is NEVER emitted — every step degrades to the next instead.
|
||||
///
|
||||
/// 1. QUOTA — lowest remaining quota among rows whose quotaStatus=="ok"
|
||||
/// with a real percentage → "<provider> NN%" (e.g. "agy 12%").
|
||||
/// "unsupported"/"error" rows are skipped so they can't show "--".
|
||||
/// 2. TODAY COST — else analytics.today.cost > 0 → "$<today>" (e.g. "$3.20").
|
||||
/// Uses the fresh aggregate from analytics, not per-row today_cost.
|
||||
/// 3. ATTENTION/COUNT — else rows needing reauth → "CCS <n>!"; else active
|
||||
/// (non-paused) count → "CCS <n>", fallback to total count.
|
||||
/// 4. "CCS" — only when there are no rows at all.
|
||||
///
|
||||
/// All-time spend is deliberately EXCLUDED from the title chain: a lifetime dollar
|
||||
/// figure (e.g. "$40.8k") always reads as live spend in the always-on menu bar,
|
||||
/// creating false urgency. It belongs only in the analytics section of the dropdown.
|
||||
public static func statusTitle(rows: [BarSummaryRow], analytics: BarAnalytics?) -> String {
|
||||
if rows.isEmpty { return "CCS" }
|
||||
|
||||
// (1) QUOTA — closest to exhaustion among quota-capable rows.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
|
||||
// (2) TODAY COST — fresh aggregate from analytics (more accurate than summing
|
||||
// per-row today_cost, which may have nulls or stale snapshot values).
|
||||
if let todayCost = analytics?.today.cost, todayCost > 0 {
|
||||
return money(todayCost)
|
||||
}
|
||||
|
||||
// (3) ATTENTION / ACTIVE COUNT.
|
||||
let reauthCount = rows.filter { $0.needsReauth }.count
|
||||
if reauthCount > 0 {
|
||||
return "CCS \(reauthCount)!"
|
||||
}
|
||||
let activeCount = rows.filter { !$0.paused }.count
|
||||
return "CCS \(activeCount > 0 ? activeCount : rows.count)"
|
||||
}
|
||||
|
||||
/// The row to surface in the compact title: the one closest to exhaustion.
|
||||
/// `quota_percentage` is REMAINING quota (higher = more left), so the lead is
|
||||
/// the LOWEST remaining percentage. Rows without a known percentage are not
|
||||
/// chosen unless no row has one.
|
||||
static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
let withPct = rows.filter { $0.quotaPercentage != nil }
|
||||
if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) {
|
||||
return lead
|
||||
/// The "headline" account for the dropdown when no quota exists (which account
|
||||
/// name leads). Deterministic: prefer the default row, else the sole active
|
||||
/// row, else alphabetical by id — never `rows.first` (arbitrary order).
|
||||
public static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
if rows.isEmpty { return nil }
|
||||
if let def = rows.first(where: { $0.isDefault }) { return def }
|
||||
let active = rows.filter { !$0.paused }
|
||||
if active.count == 1 { return active[0] }
|
||||
return rows.min(by: { $0.id < $1.id })
|
||||
}
|
||||
|
||||
/// Human "Last active" caption from an ISO timestamp + a precomputed day-delta.
|
||||
/// "Last active today" / "yesterday" / "Apr 29" — never a raw ISO string.
|
||||
public static func lastActiveLabel(iso: String?, daysSince: Int?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
if let d = daysSince {
|
||||
if d <= 0 { return "Last active today" }
|
||||
if d == 1 { return "Last active yesterday" }
|
||||
}
|
||||
return rows.first
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "MMM d"
|
||||
return "Last active \(fmt.string(from: date))"
|
||||
}
|
||||
|
||||
/// Parse an ISO-8601 timestamp (with or without fractional seconds).
|
||||
static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,17 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let tier: String?
|
||||
public let paused: Bool
|
||||
public let quotaPercentage: Double?
|
||||
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
|
||||
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
|
||||
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
|
||||
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
|
||||
/// conflates the two.
|
||||
public let quotaStatus: String
|
||||
public let nextReset: String?
|
||||
/// True when this is the provider's default account; drives the active/default badge.
|
||||
public let isDefault: Bool
|
||||
/// ISO timestamp this account was last used, null if never/unknown.
|
||||
public let lastActivityAt: String?
|
||||
public let todayCost: Double?
|
||||
public let health: String
|
||||
public let cached: Bool
|
||||
@@ -28,7 +38,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
case tier
|
||||
case paused
|
||||
case quotaPercentage = "quota_percentage"
|
||||
case quotaStatus
|
||||
case nextReset = "next_reset"
|
||||
case isDefault = "is_default"
|
||||
case lastActivityAt = "last_activity_at"
|
||||
case todayCost = "today_cost"
|
||||
case health
|
||||
case cached
|
||||
@@ -43,7 +56,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
tier: String? = nil,
|
||||
paused: Bool = false,
|
||||
quotaPercentage: Double? = nil,
|
||||
quotaStatus: String = "ok",
|
||||
nextReset: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
lastActivityAt: String? = nil,
|
||||
todayCost: Double? = nil,
|
||||
health: String = "ok",
|
||||
cached: Bool = false,
|
||||
@@ -56,7 +72,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
self.tier = tier
|
||||
self.paused = paused
|
||||
self.quotaPercentage = quotaPercentage
|
||||
self.quotaStatus = quotaStatus
|
||||
self.nextReset = nextReset
|
||||
self.isDefault = isDefault
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.todayCost = todayCost
|
||||
self.health = health
|
||||
self.cached = cached
|
||||
|
||||
Reference in New Issue
Block a user