diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index 028f5060..6abc79fc 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift new file mode 100644 index 00000000..cf353d3b --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift @@ -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) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 86e1e01a..4078fec0 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -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: 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()) diff --git a/macos-bar/Sources/CCSBarApp/BarPreferences.swift b/macos-bar/Sources/CCSBarApp/BarPreferences.swift index 0e98dab5..7e4e0467 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferences.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferences.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift index 2009887e..7ee26bfb 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -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) } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift new file mode 100644 index 00000000..aabcf133 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -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: "