diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index d2a1ffc8..225abc81 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -19,6 +19,9 @@ struct BarAnalyticsView: View { /// top-models detail placed below the pool accounts. enum Section { case spend, breakdown } var section: Section = .spend + /// Controls whether the spend sparkline renders as bars or a line graph. + /// Passed in by BarMenuView so it reflects the user's persisted choice live. + var spendChartStyle: SpendChartStyle = .bars private var lastActive: String? { BarFormatting.lastActiveLabel( @@ -74,8 +77,10 @@ struct BarAnalyticsView: View { .font(.caption2) .foregroundStyle(.secondary) if !sparklineIsEmpty { - Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent) - .frame(height: 18) + // height: 30 (up from 18) so daily spend gradations are clearly readable. + Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent, + style: spendChartStyle) + .frame(height: 30) } } else { Text(idleCaption) diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index b80b190c..625c3bd7 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -50,9 +50,12 @@ struct BarMenuView: View { accountsSection // (3) SPEND — demoted to a thin informational strip below the cockpit. + // spendChartStyle is threaded from the viewModel so a live change in + // Settings immediately updates the chart without a refresh. if let analytics = viewModel.analytics { Divider() - BarAnalyticsView(analytics: analytics, section: .spend) + BarAnalyticsView(analytics: analytics, section: .spend, + spendChartStyle: viewModel.spendChartStyle) } // (4) POOL ACCOUNTS — compact generic rows, subordinate. @@ -72,17 +75,18 @@ struct BarMenuView: View { .padding(14) } .scrollIndicators(.never) - // 620 fits the full reordered layout (subscription cards + spend strip + - // pool rows + surface/models) plus the added vertical breathing room - // before scroll engages, for a typical 1-4 account setup. Scroll engages - // gracefully only on real overflow. - .frame(maxHeight: 620) + // 700 gives more vertical breathing room before scroll engages — useful + // for 3-4 subscription cards each carrying multiple quota windows. + // Scroll still engages gracefully on genuine overflow. + .frame(maxHeight: 700) } Divider() footer } - .frame(width: 380) + // 360 is narrower than the old 380, keeping the popover compact while still + // fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48). + .frame(width: 360) .onAppear { viewModel.onOpen() // Disarm quit on every popover open so a stale armed state never persists. diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift index e9e7da13..59f493af 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -51,10 +51,9 @@ struct BarPreferencesView: View { .padding(.vertical, 10) } - /// Theme picker — placed first because it affects the whole dropdown, so it is - /// the most prominent setting. Bound directly to `$viewModel.appearance` (NOT - /// the alert-pref draft): appearance is global chrome, its `didSet` persists, - /// and @Published drives the live re-render — no writeThrough() needed. + /// Theme + chart style. Appearance affects the whole dropdown; chart style is + /// scoped to the spend sparkline. Both are bound directly to viewModel properties + /// whose `didSet` persist — no writeThrough() needed here. private var appearanceSection: some View { Section("Appearance") { Picker("Menu bar theme", selection: $viewModel.appearance) { @@ -63,6 +62,13 @@ struct BarPreferencesView: View { Text("Dark").tag(BarAppearance.dark) } .pickerStyle(.segmented) + // Changing this live-updates the spend chart in the open dropdown because + // viewModel.spendChartStyle is @Published and SpendChartStyleStore persists it. + Picker("Spend graph", selection: $viewModel.spendChartStyle) { + Text("Bars").tag(SpendChartStyle.bars) + Text("Line").tag(SpendChartStyle.line) + } + .pickerStyle(.segmented) } } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index fc2a162a..dd3b33b8 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -24,7 +24,9 @@ struct BarSubscriptionCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 6) { + // spacing: 4 (down from 6) and vertical padding: 8 (down from 11) keep the + // card compact so 2-3 cards fit in the dropdown without triggering scroll. + VStack(alignment: .leading, spacing: 4) { titleRow if windows.isEmpty { emptyState @@ -33,7 +35,7 @@ struct BarSubscriptionCard: View { staleFootnote } } - .padding(.vertical, 11) + .padding(.vertical, 8) .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( @@ -71,7 +73,9 @@ struct BarSubscriptionCard: View { let ordered = orderedWindows let bindingKey = binding?.key - return VStack(alignment: .leading, spacing: 5) { + // spacing: 4 (down from 5) — rows are already compact; tighter gap fits more + // without hurting legibility. + return VStack(alignment: .leading, spacing: 4) { ForEach(ordered) { w in windowBarRow(w, isBinding: w.key == bindingKey) } diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index f0283e76..a1843f50 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -25,6 +25,11 @@ final class BarViewModel: ObservableObject { /// Which figure leads the always-on title. Persisted; a change re-derives /// `statusTitle` live because it is @Published. @Published var glanceMode: BarGlanceMode + /// Render style for the spend sparkline (bars or line). Persisted via + /// SpendChartStyleStore; didSet mirrors the BarAppearance/iconStyle pattern. + @Published var spendChartStyle: SpendChartStyle { + didSet { SpendChartStyleStore.save(spendChartStyle) } + } /// The alerts the most recent evaluation wanted delivered, surfaced in the /// dropdown so users who deny notifications still see the conditions. @Published var activeAlerts: [BarNotification] = [] @@ -51,6 +56,7 @@ final class BarViewModel: ObservableObject { self.iconStyle = MenuBarIcon.loadStyle() self.appearance = BarAppearanceStore.load() self.glanceMode = prefs.load().glanceMode + self.spendChartStyle = SpendChartStyleStore.load() reconnect() } diff --git a/macos-bar/Sources/CCSBarApp/Sparkline.swift b/macos-bar/Sources/CCSBarApp/Sparkline.swift index 2e4f0705..fb403ad0 100644 --- a/macos-bar/Sources/CCSBarApp/Sparkline.swift +++ b/macos-bar/Sources/CCSBarApp/Sparkline.swift @@ -1,27 +1,95 @@ import SwiftUI import CCSBarCore -/// A compact bar sparkline for daily values (e.g. cost per day over 7 days). -/// Zero-value days render as faint placeholders so the cadence stays readable. +/// A compact sparkline for daily values (e.g. cost per day over 30 days). +/// +/// Two render styles controlled by `style`: +/// - `.bars` (default): the original RoundedRectangle bar chart. Zero-value days +/// render as faint placeholders so the cadence stays readable. +/// - `.line`: a Path-based polyline through the normalized points, stroked ~1.5pt, +/// with a subtle area fill (accent at ~0.15 opacity) under the curve. Better for +/// reading trend direction over a long window. Falls back to a flat baseline when +/// count < 2 or all values are zero. struct Sparkline: View { let values: [Double] // Default is the dark preset's accent: a default argument can't read the // environment, so this is the static fallback. Live callers pass the themed // `theme.accent` from the parent so the rendered bar follows the chosen theme. var accent: Color = BarTheme.dark.accent + /// Render mode. Default `.bars` preserves the original look; `.line` draws a + /// trend line instead. + var style: SpendChartStyle = .bars var body: some View { GeometryReader { geo in - let peak = max(values.max() ?? 0, 0.0001) - HStack(alignment: .bottom, spacing: 3) { - ForEach(Array(values.enumerated()), id: \.offset) { _, value in - let height = CGFloat(value / peak) * geo.size.height - RoundedRectangle(cornerRadius: 2) - .fill(value > 0 ? accent : Color.secondary.opacity(0.2)) - .frame(height: max(2, height)) - } + switch style { + case .bars: + barsBody(in: geo.size) + case .line: + lineBody(in: geo.size) + } + } + } + + // MARK: Bar render (original) + + private func barsBody(in size: CGSize) -> some View { + let peak = max(values.max() ?? 0, 0.0001) + return HStack(alignment: .bottom, spacing: 3) { + ForEach(Array(values.enumerated()), id: \.offset) { _, value in + let height = CGFloat(value / peak) * size.height + RoundedRectangle(cornerRadius: 2) + .fill(value > 0 ? accent : Color.secondary.opacity(0.2)) + .frame(height: max(2, height)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + + // MARK: Line render + + /// Polyline path connecting the normalized data points. x is evenly spaced + /// across the width; y is inverted so a larger value is higher (closer to 0). + @ViewBuilder private func lineBody(in size: CGSize) -> some View { + let peak = max(values.max() ?? 0, 0.0001) + let count = values.count + let allZero = values.allSatisfy { $0 <= 0 } + + if count < 2 || allZero { + // Flat baseline — nothing to show; render a faint hairline so the area + // is not invisible on an idle spend strip. + Path { p in + p.move(to: CGPoint(x: 0, y: size.height)) + p.addLine(to: CGPoint(x: size.width, y: size.height)) + } + .stroke(accent.opacity(0.2), lineWidth: 1) + } else { + // Build points: x evenly spaced, y inverted (0 = top = max value). + let pts: [CGPoint] = values.enumerated().map { i, v in + let x = CGFloat(i) / CGFloat(count - 1) * size.width + let y = size.height - CGFloat(v / peak) * size.height + return CGPoint(x: x, y: y) + } + + // Area fill: close the path by dropping to the bottom edge. + let fillPath = Path { p in + p.move(to: CGPoint(x: pts[0].x, y: size.height)) + p.addLine(to: pts[0]) + for pt in pts.dropFirst() { p.addLine(to: pt) } + p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: size.height)) + p.closeSubpath() + } + + // Stroke path. + let strokePath = Path { p in + p.move(to: pts[0]) + for pt in pts.dropFirst() { p.addLine(to: pt) } + } + + ZStack { + fillPath.fill(accent.opacity(0.15)) + strokePath.stroke(accent, lineWidth: 1.5) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 5687ae46..5bbc2db7 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1347,6 +1347,38 @@ do { defaults.removePersistentDomain(forName: suiteName) } +// MARK: SpendChartStyle — enum stability + store round-trip + +// Default is .bars (the pre-existing render mode; no visual regression on upgrade). +check(SpendChartStyle.bars.rawValue == "bars", "SpendChartStyle .bars rawValue stable") +check(SpendChartStyle.line.rawValue == "line", "SpendChartStyle .line rawValue stable") +check(SpendChartStyle(rawValue: "bars") == .bars, "SpendChartStyle round-trip bars") +check(SpendChartStyle(rawValue: "line") == .line, "SpendChartStyle round-trip line") +check(SpendChartStyle(rawValue: "unknown") == nil, "SpendChartStyle unknown rawValue -> nil") +check(SpendChartStyle.allCases.count == 2, "SpendChartStyle has exactly 2 cases") + +// Store round-trip: save .line -> load() == .line; absent key defaults to .bars. +do { + let suiteName = "ccs-bar-check-chart-\(ProcessInfo.processInfo.globallyUniqueString)" + let defaults = UserDefaults(suiteName: suiteName)! + // Absent key -> .bars (the default). + let raw = defaults.string(forKey: SpendChartStyleStore.defaultsKey) + ?? SpendChartStyle.bars.rawValue + check(SpendChartStyle(rawValue: raw) == .bars, + "SpendChartStyleStore defaults to .bars on absent key") + // Save .line -> load .line. + defaults.set(SpendChartStyle.line.rawValue, forKey: SpendChartStyleStore.defaultsKey) + let back = SpendChartStyle(rawValue: + defaults.string(forKey: SpendChartStyleStore.defaultsKey) ?? "") + check(back == .line, "SpendChartStyleStore round-trips save(.line) -> .line") + // Garbage -> nil (coalesces to .bars on live load). + defaults.set("nonsense", forKey: SpendChartStyleStore.defaultsKey) + let g = SpendChartStyle(rawValue: + defaults.string(forKey: SpendChartStyleStore.defaultsKey) ?? "") + check(g == nil, "SpendChartStyleStore garbage raw -> nil (coalesces to .bars)") + defaults.removePersistentDomain(forName: suiteName) +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarChartStyle.swift b/macos-bar/Sources/CCSBarCore/BarChartStyle.swift new file mode 100644 index 00000000..b4befb45 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarChartStyle.swift @@ -0,0 +1,35 @@ +import Foundation + +// MARK: - SpendChartStyle + +/// User-selectable render style for the spend sparkline in the dropdown. +/// +/// `.bars` (default) draws the existing RoundedRectangle bar chart. +/// `.line` draws a Path-based line graph with a faint area fill, which is +/// better for spotting trend direction across the 30-day window. +/// +/// Sendable + CaseIterable so the harness can iterate all cases and the value +/// can cross actor boundaries safely. +public enum SpendChartStyle: String, CaseIterable, Sendable { + case bars + case line +} + +// MARK: - SpendChartStyleStore + +/// Persists the chosen spend-chart style. Mirrors the BarAppearanceStore pattern: +/// a UserDefaults key, a static load, and a static save. The `?? .bars` fallback +/// on a nil/unrecognized raw value is the sole source of the default. +public enum SpendChartStyleStore { + public static let defaultsKey = "ccsbar.spendChartStyle" + + public static func load() -> SpendChartStyle { + let raw = UserDefaults.standard.string(forKey: defaultsKey) + ?? SpendChartStyle.bars.rawValue + return SpendChartStyle(rawValue: raw) ?? .bars + } + + public static func save(_ style: SpendChartStyle) { + UserDefaults.standard.set(style.rawValue, forKey: defaultsKey) + } +}