feat(bar-app): subscription-first cockpit with detailed quota card

Lead the dropdown with subscriptions: a dedicated card shows the binding
window as the hero (gauge, remaining %, reset countdown, and a burn-rate
'left at this pace' line), a secondary window, and the Opus/Sonnet split for
Max, with a stale marker for older Codex data. Demote spend/usage to a compact
strip and surface cross-tool headroom. Add pure burn-rate / time-to-exhaustion
and binding-window math in Core. Kill the menu scroll indicator for real
(NSScrollView hider) so content stays aligned. Spend-cap alerts default off
(pay-per-use, opt-in).
This commit is contained in:
Tam Nhu Tran
2026-06-09 14:40:03 -04:00
parent e96967c224
commit 0e0b912187
10 changed files with 996 additions and 126 deletions
@@ -1,14 +1,23 @@
import SwiftUI
import CCSBarCore
/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface
/// breakdown, and top models.
/// Demoted spend strip + surface/model breakdown.
///
/// 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.
/// Spend is informational pool context, NEVER the headline so the loud 2×2
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
/// subordinate, below the pool accounts.
///
/// When the trailing 30 days carry no spend the strip reads the honest idle line
/// ("No usage in N days") instead of three dead "$0.00" cells.
struct BarAnalyticsView: View {
let analytics: BarAnalytics
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
/// top-models detail placed below the pool accounts.
enum Section { case spend, breakdown }
var section: Section = .spend
private var lastActive: String? {
BarFormatting.lastActiveLabel(
@@ -16,20 +25,19 @@ struct BarAnalyticsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SectionLabel("Usage")
if analytics.hasRecentData {
recentGrid
} else {
idleHero
}
sparklineBlock
switch section {
case .spend:
spendStrip
case .breakdown:
breakdown
}
}
/// By-surface + top-models detail, tightened and subordinate.
@ViewBuilder private var breakdown: some View {
VStack(alignment: .leading, spacing: 6) {
// 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.
// the backend supplies at least one surface entry. Top 5 keeps it compact.
if !analytics.bySurface.isEmpty {
SectionLabel("By surface")
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
@@ -50,71 +58,44 @@ 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)
}
}
/// True when there's any surface/model detail worth a divider + section.
var hasBreakdown: Bool {
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
}
/// 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)
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 3) {
SectionLabel("Spend")
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.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)
if !sparklineIsEmpty {
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 16)
}
} else {
Text(idleCaption)
.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"
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
private var spendCaption: String {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
}
/// 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)
}
}
/// Honest idle caption when there's no recent spend, folding in last-active.
private var idleCaption: String {
let headline =
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
return headline
}
private var sparklineIsEmpty: Bool {
@@ -157,30 +138,6 @@ private struct SurfaceBar: View {
}
}
/// A small labelled stat tile.
private struct StatCell: View {
let title: String
let value: String
var accent: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.system(.callout, design: .rounded).weight(.semibold))
.foregroundStyle(accent ? BarTheme.accent : .primary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 5)
.padding(.horizontal, 9)
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
}
}
/// One top-model row: name + spend with a proportional accent bar behind.
private struct ModelBar: View {
let model: BarAnalytics.Model
@@ -0,0 +1,54 @@
import Foundation
import CCSBarCore
/// App-side date phrasing for the subscription card. Kept out of Core so the
/// shared formatting contract there stays untouched. Parses ISO-8601 the same
/// way Core does (with and without fractional seconds) so timestamp handling
/// matches the rest of the bar.
enum BarCardFormatting {
/// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds
/// component. Mirrors Core's parser, which is module-internal there.
private 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)
}
/// Compact reset form for the Opus/Sonnet split + secondary lines:
/// <24h "2h 18m"
/// <7d weekday, e.g. "Fri"
/// >=7d "Jun 14"
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
static func shortReset(iso: String?, now: Date) -> String? {
guard let iso, let date = isoDate(iso) else { return nil }
let secs = date.timeIntervalSince(now)
if secs <= 0 { return "due" }
if secs < 24 * 3600 {
let total = Int(secs / 60)
let h = total / 60
let m = total % 60
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
if secs < 7 * 24 * 3600 {
fmt.dateFormat = "EEE" // weekday, e.g. "Fri"
} else {
fmt.dateFormat = "MMM d" // e.g. "Jun 14"
}
return fmt.string(from: date)
}
/// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42".
/// Returns nil for a missing/unparseable timestamp.
static func clockTime(iso: String?) -> String? {
guard let iso, let date = isoDate(iso) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
}
}
+97 -25
View File
@@ -21,17 +21,18 @@ 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.
// The scroll indicator is suppressed (.never, not just .hidden) AND the
// enclosing NSScrollView's scroller is hard-disabled via ScrollerHider:
// inside a MenuBarExtra popover the SwiftUI preference alone is sometimes
// ignored and a scroller track steals width + misaligns content. With the
// reorder + collapsed spend strip the important rows fit without scrolling
// for the common 1-4 subscription setup; the scroll only engages for
// genuine pool/model overflow.
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if let analytics = viewModel.analytics {
BarAnalyticsView(analytics: analytics)
}
// In-dropdown alert list: surfaces the conditions the engine flagged
// this poll so users who deny system notifications still see them.
VStack(alignment: .leading, spacing: 8) {
// (1) ALERTS first urgent quota crossings surface above everything.
// Spend-cap alerts are opt-in OFF by default, so by default only
// quota/reauth/cooldown conditions appear here.
if !viewModel.activeAlerts.isEmpty {
VStack(alignment: .leading, spacing: 6) {
SectionLabel("Alerts")
@@ -41,14 +42,35 @@ struct BarMenuView: View {
}
}
// (2) SUBSCRIPTIONS the dominant section, opens here.
accountsSection
// (3) SPEND demoted to a thin informational strip below the cockpit.
if let analytics = viewModel.analytics {
Divider()
BarAnalyticsView(analytics: analytics, section: .spend)
}
// (4) POOL ACCOUNTS compact generic rows, subordinate.
poolSection
// (5) BY-SURFACE / TOP MODELS tightened detail, below the pool.
if let analytics = viewModel.analytics,
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
{
BarAnalyticsView(analytics: analytics, section: .breakdown)
}
// Zero-size AppKit bridge that disables the popover's NSScrollView
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
ScrollerHider().frame(width: 0, height: 0)
}
.padding(12)
}
.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.
.scrollIndicators(.never)
// 580 fits the full reordered layout (subscription cards + spend strip +
// pool rows + surface/models) without wasted whitespace for a typical
// 1-4 account setup. Scroll engages gracefully only on real overflow.
.frame(maxHeight: 580)
}
@@ -62,11 +84,12 @@ struct BarMenuView: View {
}
}
/// Accounts list. Native first-party subscriptions (Claude Code / Codex) render
/// in a top "Subscriptions" group so the user's own plan quota reads apart from
/// the rotating CLIProxy "Pool accounts". The split is suppressed when there are
/// no subscriptions (or no pool), so the established single "Accounts" header is
/// kept for the common CLIProxy-only setup.
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
/// (closest to empty on top) so the window the user is about to run out of
/// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below,
/// subordinate. The two-section split is suppressed when only one kind is
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
@ViewBuilder private var accountsSection: some View {
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
VStack(alignment: .leading, spacing: 6) {
@@ -78,17 +101,29 @@ struct BarMenuView: View {
Text("No accounts configured")
.font(.caption)
.foregroundStyle(.secondary)
} else if parts.subscriptions.isEmpty || parts.pool.isEmpty {
// Only one kind present: keep the single established header.
} else if parts.subscriptions.isEmpty {
// CLIProxy-only setup: keep the single established header + generic rows.
SectionLabel("Accounts")
ForEach(viewModel.rows) { row in
ForEach(parts.pool) { row in
BarRowView(row: row, viewModel: viewModel)
}
} else {
SectionLabel("Subscriptions")
ForEach(parts.subscriptions) { row in
BarRowView(row: row, viewModel: viewModel)
subscriptionsHeader(parts.subscriptions)
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
BarSubscriptionCard(row: row)
}
}
}
}
/// CLIProxy pool accounts as compact generic rows subordinate, rendered below
/// the spend strip. Suppressed entirely when there are no pool accounts, or
/// when there are no subscriptions (the CLIProxy-only path renders pool rows
/// under the single "Accounts" header in `accountsSection` instead).
@ViewBuilder private var poolSection: some View {
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
VStack(alignment: .leading, spacing: 6) {
SectionLabel("Pool accounts")
ForEach(parts.pool) { row in
BarRowView(row: row, viewModel: viewModel)
@@ -97,6 +132,43 @@ struct BarMenuView: View {
}
}
/// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint
/// ("most room: <X> NN%") when there are >=2 subscriptions with quota data.
/// Falls back to the bare label otherwise.
@ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View {
HStack(alignment: .firstTextBaseline) {
SectionLabel("Subscriptions")
Spacer()
if let leader = BarQuotaGauge.headroomLeader(subs) {
Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
/// Order subscription cards by tightest binding window ascending (closest to
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
/// so the actionable quota always leads.
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
subs.sorted { a, b in
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
switch (ra, rb) {
case let (.some(x), .some(y)):
if x != y { return x < y }
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
case (.some, .none):
return true // a has quota, b doesn't a first
case (.none, .some):
return false
case (.none, .none):
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
}
}
}
private var header: some View {
HStack(spacing: 8) {
Image(nsImage: MenuBarIcon.headerImage())
@@ -20,8 +20,17 @@ struct BarPreferences {
/// Seed the registration domain so missing keys resolve to their real defaults
/// rather than the type-zero value. Idempotent safe to call on every launch.
///
/// The two pay-per-use spend-cap alerts default to OFF (opt-in): subscriptions
/// are flat-rate, so a spend alert on them is meaningless, and pool spend is
/// informational context, never a default-on alert. Quota / reauth / cooldown
/// stay default-on (the quota-first alert set). The caps themselves keep Core's
/// sane values so a user who opts in starts with $500 / $10000.
func registerDefaults() {
defaults.register(defaults: BarAlertPrefsStore.registrationDefaults)
var d = BarAlertPrefsStore.registrationDefaults
d[BarAlertPrefsStore.Key.dailyEnabled] = false
d[BarAlertPrefsStore.Key.monthEnabled] = false
defaults.register(defaults: d)
}
/// Read the current preferences. Pulls each key into a plain dictionary and
@@ -77,16 +77,19 @@ struct BarPreferencesView: View {
}
}
/// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool
/// accounts, NOT subscriptions: flat-rate subscription plans have no spend to
/// cap, so these alerts only make sense for metered pool usage.
private var spendSection: some View {
Section("Spend") {
Toggle("Alert on daily spend", isOn: $draft.dailySpendEnabled)
Section("Opt-in · pay-per-use spend") {
Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled)
.onChange(of: draft.dailySpendEnabled) { _ in writeThrough() }
capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled)
Toggle("Alert on monthly spend", isOn: $draft.monthSpendEnabled)
Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled)
.onChange(of: draft.monthSpendEnabled) { _ in writeThrough() }
capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled)
Text("Monthly cap measures calendar month-to-date, so it resets at the start of each billing month.")
Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -0,0 +1,254 @@
import SwiftUI
import CCSBarCore
/// Dedicated, detailed card for a native first-party subscription (Claude Code /
/// Codex). Replaces the cramped generic `BarRowView` for native rows so the
/// user's own plan quota reads as the cockpit hero: a single-column vertical
/// stack, one fact per line, no 2-column flex wrap.
///
/// Why this exists separately from `BarRowView`: the generic row double-labelled
/// the product, showed a confusing "no data" cost cell, and carried pause/solo +
/// tier-lock controls that make no sense for a non-routable flat-rate
/// subscription. Stripping those four elements and stacking the windows
/// vertically is the de-cramping fix. All binding/burn math is pulled fresh at
/// render from `BarQuotaGauge` (pure Core) so the pace clause reflects `now`.
struct BarSubscriptionCard: View {
let row: BarSummaryRow
/// Injected so the pace/countdown math is deterministic; defaults to the live
/// clock in production and is pinned in previews.
var now: Date = Date()
/// Windows decoded from the row; empty for error/reauth rows (no hero gauge).
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
/// The window the subscription runs out of first (lowest remaining). Drives the
/// hero gauge; nil when there is no quota data (error/reauth row).
private var binding: QuotaWindowDetail? {
BarQuotaGauge.selectBindingWindow(windows)
}
var body: some View {
VStack(alignment: .leading, spacing: 7) {
titleRow
if let binding {
heroBlock(binding)
if let secondary = secondaryWindow(excluding: binding) {
secondaryLine(secondary)
}
opusSonnetLines
staleFootnote
} else {
emptyState
}
}
.padding(.vertical, 9)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
BarTheme.subscription.opacity(0.07),
in: RoundedRectangle(cornerRadius: 9))
}
// MARK: Title
/// Product name + tier chip. No provider-chip echo, no "subscription" badge
/// (the section header carries that), no pause toggle, no overflow menu
/// those four removals are the de-cramping fix.
private var titleRow: some View {
HStack(spacing: 6) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
Text(BarFormatting.providerLabel(row.provider))
.font(.system(.body, design: .default).weight(.semibold))
.lineLimit(1)
if row.needsReauth {
Chip("reauth", tint: .red)
}
Spacer(minLength: 4)
if let tier = row.tier {
Chip(tier, tint: BarTheme.subscription)
}
}
}
// MARK: Hero (binding window)
private func heroBlock(_ w: QuotaWindowDetail) -> some View {
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
return VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .center, spacing: 8) {
heroGauge(fill: fill, color: color(for: band))
Text("\(Int(w.remainingPercent.rounded()))% left")
.font(.system(.callout, design: .rounded).weight(.semibold))
.foregroundStyle(color(for: band))
}
Text(heroFacts(w))
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Full-width hero gauge bar, filled by the remaining fraction and tinted by
/// the severity band (reuses Core band/fill math).
private func heroGauge(fill: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.12))
Capsule()
.fill(color)
.frame(width: max(3, geo.size.width * fill))
}
}
.frame(height: 7)
.frame(maxWidth: .infinity)
}
/// One wrapped facts line: "<label> window · resets <countdown> · <pace>".
/// When pace is nil the line ends after the reset countdown; when the limit is
/// reached the pace clause replaces the bare countdown.
private func heroFacts(_ w: QuotaWindowDetail) -> String {
let pace = BarQuotaGauge.paceClause(
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
status: row.quotaStatus,
now: now)
// The limit-reached pace clause already carries "resets in ...", so it
// stands in for the countdown rather than appending to it.
if let pace, pace.hasPrefix("limit reached") {
return "\(w.label) window · \(pace)"
}
var parts = ["\(w.label) window"]
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
parts.append(countdown)
}
if let pace { parts.append(pace) }
return parts.joined(separator: " · ")
}
// MARK: Secondary window
/// The other core window (the non-binding of five_hour / seven_day) shown as a
/// thin inline bar plus a compact remaining + reset line. Opus/Sonnet are NOT
/// secondary candidates they have their own split lines.
private func secondaryWindow(excluding binding: QuotaWindowDetail) -> QuotaWindowDetail? {
let core = windows.filter { $0.key == "five_hour" || $0.key == "seven_day" }
return core.first { $0.key != binding.key }
}
private func secondaryLine(_ w: QuotaWindowDetail) -> some View {
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
return HStack(spacing: 6) {
thinBar(fill: fill, color: color(for: band))
Text("\(w.label) \(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
Text("· \(countdown)")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
// MARK: Opus / Sonnet split (Claude Max only)
/// Indented Opus/Sonnet weekly sub-lines, present only when the row carries the
/// seven_day_opus / seven_day_sonnet windows (Claude Max). Omitted gracefully
/// for Pro / Codex.
@ViewBuilder private var opusSonnetLines: some View {
let opus = windows.first { $0.key == "seven_day_opus" }
let sonnet = windows.first { $0.key == "seven_day_sonnet" }
if opus != nil || sonnet != nil {
VStack(alignment: .leading, spacing: 2) {
if let opus { splitLine(title: "Opus", w: opus) }
if let sonnet { splitLine(title: "Sonnet", w: sonnet) }
}
}
}
private func splitLine(title: String, w: QuotaWindowDetail) -> some View {
HStack(spacing: 4) {
Text("\(title)")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("\(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if let short = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
Text("· resets \(short)")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
// MARK: Stale footnote (Codex only)
/// Muted "as of <HH:mm> (older session)" caption when the Codex reading came
/// from an older session. The gauge/percent still render normally the data is
/// real, only its freshness is qualified. Never fakes a "live" badge.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 9))
.foregroundStyle(.tertiary)
Text("as of \(clock) (older session)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: Empty / error
/// No quota windows (reauth / error row): no hero gauge, just the honest
/// tri-state quota label. Never shows a "no data" cost cell.
private var emptyState: some View {
Text(
row.needsReauth
? "reauth needed"
: BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus)
)
.font(.caption)
.foregroundStyle(.secondary)
}
// MARK: Shared visuals
private func thinBar(fill: Double, color: Color) -> some View {
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.10))
Capsule().fill(color).frame(width: max(2, 40 * fill))
}
.frame(width: 40, height: 4)
}
private var healthColor: Color {
switch row.health {
case "error": return .red
case "warning": return .orange
default: return .green
}
}
private func color(for band: BarQuotaGauge.Band) -> Color {
switch band {
case .green: return .green
case .yellow: return .yellow
case .orange: return .orange
case .red: return .red
case .none: return .secondary
}
}
}
@@ -0,0 +1,43 @@
import SwiftUI
import AppKit
/// Zero-size bridge that walks up to the enclosing `NSScrollView` at runtime and
/// hard-disables both scrollers.
///
/// Why this exists on top of `.scrollIndicators(.never)`: inside a
/// `MenuBarExtra` popover the SwiftUI indicator preference is sometimes ignored,
/// and AppKit still draws a vertical scroller whose track steals horizontal width
/// and shoves content out of alignment. Reaching the real `NSScrollView` and
/// setting `hasVerticalScroller = false` (plus overlay/autohide) guarantees no
/// scroller chrome regardless of how the popover hosts the SwiftUI content.
struct ScrollerHider: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let probe = NSView(frame: .zero)
// Defer the walk until the view is in the hierarchy; at make-time the
// enclosing scroll view does not exist yet.
DispatchQueue.main.async { hideScroller(from: probe) }
return probe
}
func updateNSView(_ nsView: NSView, context: Context) {
// The scroll view can be rebuilt on content changes inside the popover, so
// re-apply on each update to keep the scroller suppressed.
DispatchQueue.main.async { hideScroller(from: nsView) }
}
/// Walk superviews until the first `NSScrollView`, then disable its scrollers.
private func hideScroller(from view: NSView) {
var current: NSView? = view.superview
while let v = current {
if let scroll = v as? NSScrollView {
scroll.hasVerticalScroller = false
scroll.hasHorizontalScroller = false
scroll.scrollerStyle = .overlay
scroll.autohidesScrollers = true
scroll.drawsBackground = false
return
}
current = v.superview
}
}
}
+254
View File
@@ -924,6 +924,260 @@ do {
check(parts.pool.count == 2, "native: pool-only list keeps all rows in pool group")
}
// MARK: Per-window quota detail decode (resilient, backward compatible)
do {
let nativeJSON = """
[
{
"account_id": "claude-code",
"provider": "claude-code",
"displayName": "Claude Code",
"tier": "max",
"paused": false,
"quota_percentage": 44,
"quotaStatus": "ok",
"next_reset": "2026-06-09T20:00:00.000Z",
"is_default": false,
"last_activity_at": null,
"today_cost": null,
"health": "ok",
"cached": false,
"fetchedAt": "2026-06-09T14:00:00.000Z",
"needsReauth": false,
"quota_windows": [
{ "key": "five_hour", "label": "5h", "usedPercent": 56, "remainingPercent": 44, "resetAt": "2026-06-09T20:00:00.000Z", "windowMinutes": 300 },
{ "key": "seven_day", "label": "week", "usedPercent": 30, "remainingPercent": 70, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 },
{ "key": "seven_day_opus", "label": "Opus · week", "usedPercent": 25, "remainingPercent": 75, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 },
{ "key": "seven_day_sonnet", "label": "Sonnet · week", "usedPercent": 60, "remainingPercent": 40, "resetAt": "2026-06-15T00:00:00.000Z", "windowMinutes": 10080 }
]
},
{
"account_id": "codex",
"provider": "codex",
"displayName": "Codex",
"tier": "pro",
"paused": false,
"quota_percentage": 70,
"quotaStatus": "ok",
"next_reset": "2026-06-09T19:00:00.000Z",
"is_default": false,
"last_activity_at": null,
"today_cost": null,
"health": "warning",
"cached": false,
"fetchedAt": "2026-06-09T14:00:00.000Z",
"needsReauth": false,
"quota_windows": [
{ "key": "five_hour", "label": "5h", "usedPercent": 19, "remainingPercent": 81, "resetAt": "2026-06-09T19:00:00.000Z", "windowMinutes": 300 }
],
"stale_as_of": "2026-06-06T11:00:00.000Z"
}
]
"""
do {
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(nativeJSON.utf8))
check(rows.count == 2, "qw: decodes two native rows")
let claude = rows[0]
check(claude.quotaWindows?.count == 4, "qw: claude decodes 4 windows")
check(claude.quotaWindows?[0].key == "five_hour", "qw: first window key five_hour")
check(claude.quotaWindows?[0].label == "5h", "qw: first window label 5h")
check(claude.quotaWindows?[0].usedPercent == 56, "qw: usedPercent decodes")
check(claude.quotaWindows?[0].remainingPercent == 44, "qw: remainingPercent decodes")
check(claude.quotaWindows?[0].windowMinutes == 300, "qw: windowMinutes 300 decodes")
check(
claude.quotaWindows?[0].resetAt == "2026-06-09T20:00:00.000Z", "qw: resetAt decodes (ISO)")
check(
claude.quotaWindows?[2].key == "seven_day_opus", "qw: opus window present (Max)")
check(
claude.quotaWindows?[3].key == "seven_day_sonnet", "qw: sonnet window present (Max)")
check(claude.staleAsOf == nil, "qw: claude (fresh) has nil staleAsOf")
check(claude.quotaWindows?[0].id == "five_hour", "qw: window id is its key")
let codex = rows[1]
check(codex.quotaWindows?.count == 1, "qw: codex decodes 1 window")
check(codex.staleAsOf == "2026-06-06T11:00:00.000Z", "qw: codex staleAsOf decodes (stale)")
} catch {
check(false, "qw: native decode failed: \(error)")
}
}
// Legacy payload WITHOUT the two native-only keys still decodes (quotaWindows
// and staleAsOf -> nil). This is the backward-compatibility guarantee.
do {
let legacyJSON = """
[
{
"account_id": "alice@example.com",
"provider": "agy",
"displayName": "Alice",
"tier": "ultra",
"paused": false,
"quota_percentage": 80,
"quotaStatus": "ok",
"next_reset": null,
"is_default": true,
"last_activity_at": null,
"today_cost": null,
"health": "ok",
"cached": true,
"fetchedAt": "2026-06-07T19:00:00Z",
"needsReauth": false
}
]
"""
do {
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(legacyJSON.utf8))
check(rows.count == 1, "qw-legacy: legacy row decodes")
check(rows[0].quotaWindows == nil, "qw-legacy: missing quota_windows -> nil")
check(rows[0].staleAsOf == nil, "qw-legacy: missing stale_as_of -> nil")
} catch {
check(false, "qw-legacy: legacy decode failed: \(error)")
}
}
// MARK: burnMinutesRemaining (linear single-window projection)
do {
// Mid-window pace: a 300-min window resets in 150 min -> elapsed = 150 min.
// usedPercent = 50 -> rate = 50/150 = 1/3 %/min -> remaining 50% / (1/3) = 150.
let now = Date(timeIntervalSince1970: 1_700_000_000)
let resetIn150 = now.addingTimeInterval(150 * 60)
let t = BarQuotaGauge.burnMinutesRemaining(
usedPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
check(t == 150, "burn: mid-window 50% over 150min elapsed -> 150 min remaining")
// Near-zero usage -> nil ("plenty"); avoids an absurd projection.
let lowUse = BarQuotaGauge.burnMinutesRemaining(
usedPercent: 0.5, resetAt: resetIn150, windowMinutes: 300, now: now)
check(lowUse == nil, "burn: near-zero usage -> nil (plenty)")
// Exhausted -> 0 ("limit reached").
let exhausted = BarQuotaGauge.burnMinutesRemaining(
usedPercent: 100, resetAt: resetIn150, windowMinutes: 300, now: now)
check(exhausted == 0, "burn: usedPercent>=100 -> 0 (limit reached)")
// Unknown window length or reset -> nil (omit pace).
check(
BarQuotaGauge.burnMinutesRemaining(
usedPercent: 50, resetAt: resetIn150, windowMinutes: nil, now: now) == nil,
"burn: nil windowMinutes -> nil")
check(
BarQuotaGauge.burnMinutesRemaining(
usedPercent: 50, resetAt: nil, windowMinutes: 300, now: now) == nil,
"burn: nil resetAt -> nil")
// elapsed <= 0 (reset already further out than the full window) -> nil.
let resetTooFar = now.addingTimeInterval(400 * 60)
check(
BarQuotaGauge.burnMinutesRemaining(
usedPercent: 50, resetAt: resetTooFar, windowMinutes: 300, now: now) == nil,
"burn: elapsed<=0 -> nil")
}
// MARK: selectBindingWindow
do {
let now = Date(timeIntervalSince1970: 1_700_000_000)
_ = now
let fiveHour = QuotaWindowDetail(
key: "five_hour", label: "5h", usedPercent: 56, remainingPercent: 44, windowMinutes: 300)
let week = QuotaWindowDetail(
key: "seven_day", label: "week", usedPercent: 30, remainingPercent: 70, windowMinutes: 10080)
let opus = QuotaWindowDetail(
key: "seven_day_opus", label: "Opus · week", usedPercent: 75, remainingPercent: 25,
windowMinutes: 10080)
let binding = BarQuotaGauge.selectBindingWindow([fiveHour, week, opus])
check(binding?.key == "seven_day_opus", "binding: lowest remaining (opus 25%) wins")
// Tie on remaining% -> shorter window first.
let weekTie = QuotaWindowDetail(
key: "seven_day", label: "week", usedPercent: 60, remainingPercent: 40, windowMinutes: 10080)
let fiveTie = QuotaWindowDetail(
key: "five_hour", label: "5h", usedPercent: 60, remainingPercent: 40, windowMinutes: 300)
let tieBinding = BarQuotaGauge.selectBindingWindow([weekTie, fiveTie])
check(tieBinding?.key == "five_hour", "binding: remaining tie -> shorter window (5h) wins")
check(BarQuotaGauge.selectBindingWindow([]) == nil, "binding: empty input -> nil")
}
// MARK: paceClause phrasing
do {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
// Finite projection -> "~Hh Mm left at this pace".
let finite = BarQuotaGauge.paceClause(
usedPercent: 50, remainingPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
check(finite == "~2h 30m left at this pace", "pace: finite -> '~2h 30m left at this pace'")
// Lots of headroom -> "plenty at this pace".
let plenty = BarQuotaGauge.paceClause(
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
check(plenty == "plenty at this pace", "pace: >=85% remaining -> 'plenty at this pace'")
// Near-zero usage -> "plenty at this pace" (burn nil but window known).
let plentyLow = BarQuotaGauge.paceClause(
usedPercent: 0.5, remainingPercent: 99.5, resetAt: resetIn150, windowMinutes: 300, now: now)
check(plentyLow == "plenty at this pace", "pace: near-zero usage -> 'plenty at this pace'")
// Exhausted -> "limit reached, resets in ...".
let reached = BarQuotaGauge.paceClause(
usedPercent: 100, remainingPercent: 0, resetAt: resetIn150, windowMinutes: 300, now: now)
check(
reached?.hasPrefix("limit reached, resets in") == true,
"pace: 0% remaining -> 'limit reached, resets in ...'")
// < 5 min floor -> limit-reached path, never a scary "~0m".
// usedPercent 99 over 150min elapsed -> ~1.5 min remaining (<5).
let nearFloor = BarQuotaGauge.paceClause(
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
check(
nearFloor?.hasPrefix("limit reached") == true && !(nearFloor?.contains("~0m") ?? true),
"pace: <5m floor -> limit-reached, never '~0m'")
// Unknown window -> nil (omit).
let unknown = BarQuotaGauge.paceClause(
usedPercent: 50, remainingPercent: 50, resetAt: nil, windowMinutes: nil, now: now)
check(unknown == nil, "pace: unknown window -> nil (omit)")
// resetAt in the past -> nil pace.
let pastReset = ISO8601DateFormatter().string(from: now.addingTimeInterval(-60))
let past = BarQuotaGauge.paceClause(
usedPercent: 50, remainingPercent: 50, resetAt: pastReset, windowMinutes: 300, now: now)
check(past == nil, "pace: past resetAt -> nil pace")
}
// MARK: headroomLeader
do {
func subRow(_ provider: String, _ name: String, remaining: Double) -> BarSummaryRow {
BarSummaryRow(
accountId: provider, provider: provider, displayName: name, quotaStatus: "ok",
quotaWindows: [
QuotaWindowDetail(
key: "five_hour", label: "5h", usedPercent: 100 - remaining,
remainingPercent: remaining, windowMinutes: 300)
])
}
let claude = subRow("claude-code", "Claude Code", remaining: 44)
let codex = subRow("codex", "Codex", remaining: 81)
let errorRow = BarSummaryRow(
accountId: "broken", provider: "claude-code", displayName: "Broken", quotaStatus: "error")
let leader = BarQuotaGauge.headroomLeader([claude, codex, errorRow])
check(leader?.label == "Codex", "headroom: highest-binding-remaining (Codex 81%) leads")
check(leader?.remainingPercent == 81, "headroom: leader carries its remaining%")
// Error/reauth rows (no binding window) are excluded from the count.
let single = BarQuotaGauge.headroomLeader([claude, errorRow])
check(single == nil, "headroom: <2 eligible subscriptions -> nil")
check(BarQuotaGauge.headroomLeader([]) == nil, "headroom: empty -> nil")
}
// cleanup
try? FileManager.default.removeItem(atPath: tmp)
@@ -50,4 +50,151 @@ public enum BarQuotaGauge {
if hours > 0 { return "resets in \(hours)h \(minutes)m" }
return "resets in \(minutes)m"
}
// MARK: Burn-rate projection (single-window, no history)
/// Project minutes-to-exhaustion for ONE quota window from a single snapshot.
///
/// Linear model, no smoothing, no cross-window inference: a window of length
/// `windowMinutes` that resets at `resetAt` started at `resetAt - windowMinutes`
/// and has been running `elapsed = windowMinutes - max(0, (resetAt - now)/60)`
/// minutes. The window's OWN average burn rate is `usedPercent / elapsed`
/// (%/min); minutes left to hit 100% is `(100 - usedPercent) / rate`, which
/// simplifies to `(100 - usedPercent) * elapsed / usedPercent`.
///
/// Returns:
/// - nil when any input is unknown (windowMinutes/resetAt nil, elapsed <= 0):
/// the caller OMITS the pace clause rather than guessing.
/// - nil when usage is near-zero (<= ~1%): burn is negligible, the caller
/// renders "plenty at this pace" instead of an absurdly large projection.
/// - 0 when already exhausted (usedPercent >= 100): "limit reached".
/// - otherwise the projected whole minutes remaining at the current pace.
public static func burnMinutesRemaining(
usedPercent: Double, resetAt: Date?, windowMinutes: Int?, now: Date
) -> Int? {
guard let windowMinutes, let resetAt else { return nil }
let minutesToReset = resetAt.timeIntervalSince(now) / 60
let elapsed = Double(windowMinutes) - max(0, minutesToReset)
guard elapsed > 0 else { return nil }
if usedPercent >= 100 { return 0 }
// Near-zero burn would project an effectively infinite runway; treat it as
// "plenty" (nil) so the phrasing layer can say so honestly.
guard usedPercent > 1.0 else { return nil }
let remaining = (100 - usedPercent) * elapsed / usedPercent
return Int(remaining)
}
/// Pick the BINDING window: the one a subscription runs out of first, i.e. the
/// lowest `remainingPercent` (closest to empty). Ties break to the shorter
/// window first (5h before week), then by a stable key order so the choice is
/// deterministic. Opus/Sonnet windows are eligible. Returns nil for empty
/// input (error/reauth rows have no windows, so they get no hero gauge).
public static func selectBindingWindow(_ windows: [QuotaWindowDetail]) -> QuotaWindowDetail? {
guard !windows.isEmpty else { return nil }
return windows.min { a, b in
if a.remainingPercent != b.remainingPercent {
return a.remainingPercent < b.remainingPercent
}
let am = a.windowMinutes ?? Int.max
let bm = b.windowMinutes ?? Int.max
if am != bm { return am < bm }
return keyRank(a.key) < keyRank(b.key)
}
}
/// Stable ordering for window keys when remaining% and length tie.
private static func keyRank(_ key: String) -> Int {
switch key {
case "five_hour": return 0
case "seven_day": return 1
case "seven_day_opus": return 2
case "seven_day_sonnet": return 3
default: return 4
}
}
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
///
/// Phrasing rules (in order):
/// - exhausted (remaining <= 0) or status "rejected" "limit reached,
/// resets in <countdown>". This REPLACES the bare reset countdown.
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil)
/// "plenty at this pace".
/// - a finite projection m >= 5 "~<Hh Mm> left at this pace".
/// - m < 5 routed to the limit-reached path (never a scary "~0m").
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) nil (omit).
/// - resetAt already in the past (clock skew / stale) nil pace; the
/// countdown elsewhere shows "reset due".
public static func paceClause(
usedPercent: Double,
remainingPercent: Double,
resetAt: String?,
windowMinutes: Int?,
status: String = "ok",
now: Date
) -> String? {
let resetDate = resetAt.flatMap { BarFormatting.isoDate($0) }
if remainingPercent <= 0 || status == "rejected" {
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
// resetCountdown returns "resets in 3h 12m"; reuse just the duration.
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
return "limit reached, resets in \(duration)"
}
return "limit reached"
}
// A reset in the past means our window math is unreliable; omit the pace.
if let resetDate, resetDate.timeIntervalSince(now) <= 0 { return nil }
let burn = burnMinutesRemaining(
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
if remainingPercent >= 85 || burn == nil {
// nil burn here is either unknown window (handled below) or near-zero use.
if windowMinutes == nil || resetDate == nil { return nil }
return "plenty at this pace"
}
guard let m = burn else { return nil }
if m < 5 {
// Floor: anything under 5 minutes is effectively spent; say so plainly.
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
return "limit reached, resets in \(duration)"
}
return "limit reached"
}
return "~\(compactDuration(minutes: m)) left at this pace"
}
/// Compact "Hh Mm" / "Mm" duration for pace phrasing (e.g. "2h 5m", "35m").
static func compactDuration(minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
if h > 0 { return "\(h)h \(m)m" }
return "\(m)m"
}
/// Header "most room" leader: among subscription rows that have a binding
/// window, the one whose BINDING window has the HIGHEST remaining%. Rows with
/// no binding window (error/reauth) are excluded. Tie-breaks alphabetically by
/// display name (falling back to provider). Returns nil with fewer than two
/// eligible subscriptions (the header is suppressed below that).
public static func headroomLeader(_ rows: [BarSummaryRow]) -> (label: String, remainingPercent: Double)? {
let eligible: [(label: String, remaining: Double)] = rows.compactMap { row in
guard let binding = selectBindingWindow(row.quotaWindows ?? []) else { return nil }
let label = row.displayName ?? row.provider
return (label, binding.remainingPercent)
}
guard eligible.count >= 2 else { return nil }
let leader = eligible.max { a, b in
if a.remaining != b.remaining { return a.remaining < b.remaining }
// Highest remaining wins; alphabetical tie-break (smaller name "wins" max
// only when remaining is equal, so invert the name comparison).
return a.label > b.label
}
guard let leader else { return nil }
return (leader.label, leader.remaining)
}
}
+78 -1
View File
@@ -1,5 +1,43 @@
import Foundation
/// One quota window for a native subscription row (Claude/Codex).
///
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
/// so the bar never re-derives one from the other (a single source of truth
/// avoids rounding drift between the collapsed glance value and the per-window
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
/// by the burn-rate projection; nil when the backend could not determine it.
///
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
/// inner keys stay camelCase to match the live serializer.
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
public let key: String
public let label: String
public let usedPercent: Double
public let remainingPercent: Double
public let resetAt: String?
public let windowMinutes: Int?
/// SwiftUI list identity: the window key is unique within a row.
public var id: String { key }
public init(
key: String,
label: String,
usedPercent: Double,
remainingPercent: Double,
resetAt: String? = nil,
windowMinutes: Int? = nil
) {
self.key = key
self.label = label
self.usedPercent = usedPercent
self.remainingPercent = remainingPercent
self.resetAt = resetAt
self.windowMinutes = windowMinutes
}
}
/// One account row in the menu-bar glance.
///
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
@@ -27,6 +65,14 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
public let cached: Bool
public let fetchedAt: String?
public let needsReauth: Bool
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
/// entirely so legacy payloads decode unchanged (backward compatible).
public let quotaWindows: [QuotaWindowDetail]?
/// Native-only ISO mtime of the source session that supplied a STALE Codex
/// reading. Present only when the data is stale; nil otherwise. Drives the
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
public let staleAsOf: String?
/// Stable identity for SwiftUI lists: provider-scoped account id.
public var id: String { "\(provider):\(accountId)" }
@@ -47,6 +93,8 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
case cached
case fetchedAt
case needsReauth
case quotaWindows = "quota_windows"
case staleAsOf = "stale_as_of"
}
public init(
@@ -64,7 +112,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
health: String = "ok",
cached: Bool = false,
fetchedAt: String? = nil,
needsReauth: Bool = false
needsReauth: Bool = false,
quotaWindows: [QuotaWindowDetail]? = nil,
staleAsOf: String? = nil
) {
self.accountId = accountId
self.provider = provider
@@ -81,6 +131,33 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
self.cached = cached
self.fetchedAt = fetchedAt
self.needsReauth = needsReauth
self.quotaWindows = quotaWindows
self.staleAsOf = staleAsOf
}
/// Resilient decode: the two native-only keys are decoded with
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
/// yields nil rather than a decode failure. All other keys keep synthesized
/// behavior.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accountId = try c.decode(String.self, forKey: .accountId)
provider = try c.decode(String.self, forKey: .provider)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
tier = try c.decodeIfPresent(String.self, forKey: .tier)
paused = try c.decode(Bool.self, forKey: .paused)
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
health = try c.decode(String.self, forKey: .health)
cached = try c.decode(Bool.self, forKey: .cached)
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
}
}