mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 10:17:05 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user