codeburn/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Resham Joshi 5800179da9
Add multi-day calendar selection with custom popover UI (#393)
* Add multi-day calendar selection with custom popover UI

Replace native DatePicker with a custom-themed CalendarPopover supporting
non-contiguous multi-day selection. Add --days CLI flag for comma-separated
date filtering that post-filters from the bounding range scan.

CLI: parseDaysFlag() parses and validates dates, filterProjectsByDays()
filters turns to only selected days. Verified that sum of individual --day
calls matches --days output across cost, calls, activities, models, and
providers.

Swift: DataClient passes --days for multi-day, with single-day fallback.
AppStore tracks selectedDays as Set<String> with PayloadCacheKey support.
CalendarPopover uses pending state committed only on Done tap.

* Clear + Done in calendar resets to current period

When user clears all selected days and taps Done, switch back to the
active period instead of silently keeping the old day selection.
2026-05-24 09:49:59 -07:00

1841 lines
72 KiB
Swift

import SwiftUI
private let trendChartHeight: CGFloat = 90
// Cached formatters and a calendar to avoid allocating fresh ones on every
// SwiftUI body re-eval. Hover scrubbing on the trend bars triggers many
// re-evals per second; a fresh DateFormatter / Calendar each time was a
// measurable hot spot.
private let yyyymmdd: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f
}()
private let prettyDayFormat: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE MMM d"
return f
}()
private let mmmDayFormat: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d"
f.timeZone = .current
return f
}()
private let gregorianCalendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.timeZone = .current
return c
}()
/// Three switchable insight visualizations: Calendar (this month), Forecast (burn rate),
/// Pulse (efficiency KPIs). Pills at top toggle between them.
struct HeatmapSection: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 10) {
InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes)
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.onAppear { ensureValidSelection() }
.onChange(of: store.selectedProvider) { _, _ in ensureValidSelection() }
}
private var bindingMode: Binding<InsightMode> {
Binding(get: { store.selectedInsight }, set: { store.selectedInsight = $0 })
}
private var visibleModes: [InsightMode] {
// Plan sources from a provider's OAuth usage endpoint. Currently
// implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on
// All / Cursor / Droid / Gemini / Copilot until those providers ship
// their own quota data sources.
InsightMode.allCases.filter { mode in
if mode == .plan {
return store.selectedProvider == .claude || store.selectedProvider == .codex
}
return true
}
}
private func ensureValidSelection() {
if !visibleModes.contains(store.selectedInsight) {
store.selectedInsight = visibleModes.first ?? .trend
}
}
@ViewBuilder
private var content: some View {
switch store.selectedInsight {
case .plan:
if store.selectedProvider == .codex {
CodexPlanInsight()
} else {
PlanInsight(usage: store.subscription)
}
case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod)
case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(payload: store.payload)
case .optimize: OptimizeInsight(payload: store.payload)
}
}
}
// MARK: - Pill Switcher
private struct InsightPillSwitcher: View {
@Binding var selected: InsightMode
let visibleModes: [InsightMode]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(visibleModes) { mode in
Button {
selected = mode
} label: {
Text(mode.rawValue)
.font(.system(size: 11, weight: .medium))
.fixedSize()
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Trend (14-day bar chart with peak + average)
private struct TrendInsight: View {
let days: [DailyHistoryEntry]
let period: Period
private var trendDayCount: Int {
switch period {
case .today, .sevenDays: return 19
case .thirtyDays: return 30
case .month: return 31
case .all: return min(days.count, 90)
}
}
private var barGap: CGFloat {
trendDayCount > 45 ? 2 : 4
}
var body: some View {
let dayCount = trendDayCount
let bars = buildTrendBars(from: days, dayCount: dayCount)
let stats = computeTrendStats(bars: bars, allDays: days, dayCount: dayCount)
// Tokens are real for the .all-providers view; per-provider history doesn't carry
// token breakdown yet, so fall back to $ when no tokens are present.
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
let useTokens = totalTokens > 0
let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost }
let maxValue = max(bars.map(metric).max() ?? 1, 0.01)
let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count)
let peakValue = bars.filter({ metric($0) > 0 }).max(by: { metric($0) < metric($1) })
let yesterdayValue = stats.yesterdayBar.map(metric)
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 1) {
Text("Last \(dayCount) days")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
.font(.system(size: 18, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
}
Spacer()
if let delta = stats.deltaPercent {
HStack(spacing: 3) {
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 9, weight: .bold))
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d")
.font(.system(size: 10.5))
.monospacedDigit()
}
.foregroundStyle(Theme.brandAccent)
}
}
TrendChart(
bars: bars,
maxValue: maxValue,
avgValue: avgValue,
metric: metric,
formatValue: { formatValue($0, useTokens: useTokens) },
barGap: barGap
)
.zIndex(1)
HStack(spacing: 14) {
MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens))
MiniStat(label: "Peak", value: peakLabel(peakValue, metric: metric, useTokens: useTokens))
MiniStat(label: "Yesterday", value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "")
}
}
}
private func formatHero(useTokens: Bool, tokens: Double, dollars: Double) -> String {
useTokens ? "\(formatTokens(tokens)) tokens" : dollars.asCurrency()
}
private func formatValue(_ v: Double, useTokens: Bool) -> String {
useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency()
}
private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String {
guard let peak, metric(peak) > 0 else { return "" }
return "\(formatValue(metric(peak), useTokens: useTokens)) on \(shortDate(peak.date))"
}
private func formatTokens(_ n: Double) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
private func shortDate(_ ymd: String) -> String {
let parts = ymd.split(separator: "-")
guard parts.count == 3 else { return ymd }
return "\(parts[1])/\(parts[2])"
}
}
private struct TrendChart: View {
let bars: [TrendBar]
let maxValue: Double
let avgValue: Double
let metric: (TrendBar) -> Double
let formatValue: (Double) -> String
let barGap: CGFloat
@State private var hoveredBarID: TrendBar.ID?
private var peakBarID: TrendBar.ID? {
bars.filter { metric($0) > 0 }.max(by: { metric($0) < metric($1) })?.id
}
var body: some View {
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
ZStack(alignment: .bottomLeading) {
HStack(alignment: .bottom, spacing: barGap) {
ForEach(bars) { bar in
BarColumn(
bar: bar,
value: metric(bar),
maxValue: maxValue,
isHovered: hoveredBarID == bar.id,
isPeak: bar.id == peakBarID
)
.onHover { hovering in
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: trendChartHeight, alignment: .bottom)
GeometryReader { geo in
Path { p in
let y = geo.size.height - (geo.size.height * avgFraction)
p.move(to: CGPoint(x: 0, y: y))
p.addLine(to: CGPoint(x: geo.size.width, y: y))
}
.stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
}
.frame(height: trendChartHeight)
.allowsHitTesting(false)
}
.frame(height: trendChartHeight)
.overlay(alignment: .bottomLeading) {
if let hoveredBar {
BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue)
.padding(.top, 6)
.offset(y: 92)
.transition(.opacity)
.allowsHitTesting(false)
.zIndex(10)
}
}
.animation(.easeInOut(duration: 0.12), value: hoveredBarID)
}
private var hoveredBar: TrendBar? {
guard let id = hoveredBarID else { return nil }
return bars.first { $0.id == id }
}
}
private struct BarColumn: View {
let bar: TrendBar
let value: Double
let maxValue: Double
let isHovered: Bool
let isPeak: Bool
var body: some View {
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
let height = max(2, trendChartHeight * fraction)
VStack(spacing: 0) {
Spacer(minLength: 0)
if isPeak && value > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(Color.yellow.opacity(0.85))
.frame(maxWidth: .infinity)
.frame(height: max(2, trendChartHeight * 0.05))
}
RoundedRectangle(cornerRadius: 2)
.fill(barColor)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
)
.scaleEffect(x: isHovered ? 1.08 : 1.0, y: 1.0, anchor: .bottom)
.animation(.easeOut(duration: 0.12), value: isHovered)
}
.contentShape(Rectangle())
}
private var barColor: Color {
if bar.isToday { return Theme.brandAccent }
if value <= 0 { return Color.secondary.opacity(0.15) }
if isHovered { return Theme.brandAccent.opacity(0.85) }
let ratio = maxValue > 0 ? value / maxValue : 0
return Theme.brandAccent.opacity(0.42 + ratio * 0.48)
}
}
private struct BarTooltipCard: View {
let bar: TrendBar
/// Value to display in the tooltip header. Matches the metric the trend chart
/// is currently using (tokens when the .all-providers view has token data,
/// cost when provider-filtered views force a $ fallback). Passing this in keeps
/// the tooltip in sync with the chart instead of always reading bar.tokens,
/// which is zero for provider-filtered days.
let value: Double
let formatValue: (Double) -> String
@Environment(\.colorScheme) private var colorScheme
private var backgroundFill: Color {
colorScheme == .dark ? Color.white : Color.black
}
private var primaryText: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var secondaryText: Color {
colorScheme == .dark ? Color.black.opacity(0.7) : Color.white.opacity(0.72)
}
private var tertiaryText: Color {
colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.52)
}
private var borderStroke: Color {
colorScheme == .dark ? Color.black.opacity(0.12) : Color.white.opacity(0.12)
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(prettyDate(bar.date))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(primaryText)
Spacer()
Text("\(formatValue(value))")
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
}
if !bar.topModels.isEmpty {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.offset) { idx, m in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1)
.fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12))
.frame(width: 3, height: 12)
Text(m.name)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(primaryText)
.lineLimit(1)
Spacer()
if m.cost > 0 {
Text(m.cost.asCompactCurrency())
.font(.codeMono(size: 9.5, weight: .semibold))
.foregroundStyle(secondaryText)
}
Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
.font(.codeMono(size: 9.5, weight: .medium))
.foregroundStyle(tertiaryText)
}
}
}
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(backgroundFill)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(borderStroke, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.35), radius: 10, y: 4)
}
private func formatTokensCompact(_ n: Double) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
}
private func prettyDate(_ ymd: String) -> String {
guard let date = yyyymmdd.date(from: ymd) else { return ymd }
return prettyDayFormat.string(from: date)
}
private struct MiniStat: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary)
Text(value)
.font(.system(size: 11.5, weight: .semibold))
.monospacedDigit()
.foregroundStyle(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .separatorColor).opacity(0.35))
)
}
}
private struct TrendBar: Identifiable {
var id: String { date }
let date: String
let cost: Double
let inputTokens: Double
let outputTokens: Double
let isToday: Bool
let topModels: [DailyModelBreakdown]
var tokens: Double { inputTokens + outputTokens }
}
private struct TrendStats {
let totalThisWindow: Double
let avgPerDay: Double
let peak: TrendBar?
let activeDays: Int
let deltaPercent: Double?
let yesterdayBar: TrendBar?
}
private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] {
let calendar = gregorianCalendar
let formatter = yyyymmdd
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
let today = calendar.startOfDay(for: Date())
let todayKey = formatter.string(from: today)
var bars: [TrendBar] = []
for offset in (0..<dayCount).reversed() {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
let key = formatter.string(from: d)
let entry = entryByDate[key]
bars.append(TrendBar(
date: key,
cost: entry?.cost ?? 0,
inputTokens: Double(entry?.inputTokens ?? 0),
outputTokens: Double(entry?.outputTokens ?? 0),
isToday: key == todayKey,
topModels: entry?.topModels ?? []
))
}
return bars
}
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry], dayCount: Int) -> TrendStats {
let total = bars.reduce(0.0) { $0 + $1.cost }
let active = bars.filter { $0.cost > 0 }.count
let avg = bars.isEmpty ? 0 : total / Double(bars.count)
let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost })
let calendar = gregorianCalendar
let formatter = yyyymmdd
let today = calendar.startOfDay(for: Date())
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today)
var deltaPercent: Double? = nil
if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
let priorStartStr = formatter.string(from: priorStart)
let thisStartStr = formatter.string(from: thisStart)
let priorTotal = allDays
.filter { $0.date >= priorStartStr && $0.date < thisStartStr }
.reduce(0.0) { $0 + $1.cost }
if priorTotal > 0 {
deltaPercent = ((total - priorTotal) / priorTotal) * 100
}
}
let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: today)
let yesterdayKey = yesterdayDate.map { formatter.string(from: $0) }
let yesterdayBar = bars.first(where: { $0.date == yesterdayKey })
return TrendStats(
totalThisWindow: total,
avgPerDay: avg,
peak: peak,
activeDays: active,
deltaPercent: deltaPercent,
yesterdayBar: yesterdayBar
)
}
// MARK: - Forecast
private struct ForecastInsight: View {
let days: [DailyHistoryEntry]
var body: some View {
let stats = computeForecast(days: days)
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Month-to-date")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(stats.mtd.asCurrency())
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Theme.brandAccent)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("On pace for")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(stats.projection.asCurrency())
.font(.system(size: 16, weight: .semibold))
.monospacedDigit()
}
}
HStack(spacing: 14) {
ForecastStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency())
ForecastStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency())
ForecastStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency())
}
if let prevTotal = stats.previousMonthTotal {
HStack(spacing: 4) {
Image(systemName: stats.projection > prevTotal ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 9, weight: .bold))
Text(comparisonText(projection: stats.projection, previous: prevTotal))
.font(.system(size: 10.5))
.monospacedDigit()
}
.foregroundStyle(Theme.brandAccent)
}
}
}
private func comparisonText(projection: Double, previous: Double) -> String {
guard previous > 0 else { return "no prior month" }
let diff = ((projection - previous) / previous) * 100
let sign = diff >= 0 ? "+" : ""
return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))"
}
}
private struct ForecastStat: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary)
Text(value)
.font(.system(size: 12, weight: .semibold))
.monospacedDigit()
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct ForecastStats {
let mtd: Double
let projection: Double
let weekAvg: Double
let weekTotal: Double
let yesterday: Double
let previousMonthTotal: Double?
}
private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats {
let calendar = gregorianCalendar
let formatter = yyyymmdd
let now = Date()
let comps = calendar.dateComponents([.year, .month, .day], from: now)
guard
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
else {
return ForecastStats(mtd: 0, projection: 0, weekAvg: 0, weekTotal: 0, yesterday: 0, previousMonthTotal: nil)
}
let firstStr = formatter.string(from: firstOfMonth)
let totalDays = rangeOfMonth.count
let dayOfMonth = comps.day ?? 1
let mtdEntries = days.filter { $0.date >= firstStr }
let mtd = mtdEntries.reduce(0.0) { $0 + $1.cost }
let avgPerElapsedDay = dayOfMonth > 0 ? mtd / Double(dayOfMonth) : 0
let projection = avgPerElapsedDay * Double(totalDays)
let weekStart = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now))
let weekStartStr = weekStart.map { formatter.string(from: $0) } ?? ""
let weekEntries = days.filter { $0.date >= weekStartStr }
let weekTotal = weekEntries.reduce(0.0) { $0 + $1.cost }
let weekAvg = weekTotal / 7.0
let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
let yesterdayStr = yesterdayDate.map { formatter.string(from: $0) } ?? ""
let yesterday = days.first(where: { $0.date == yesterdayStr })?.cost ?? 0
var previousMonthTotal: Double? = nil
if
let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
let prevRange = calendar.range(of: .day, in: .month, for: prevMonthDate),
let prevFirst = calendar.date(from: DateComponents(year: calendar.component(.year, from: prevMonthDate), month: calendar.component(.month, from: prevMonthDate), day: 1)),
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
{
let prevFirstStr = formatter.string(from: prevFirst)
let prevLastStr = formatter.string(from: prevLast)
let prevEntries = days.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
if !prevEntries.isEmpty {
previousMonthTotal = prevEntries.reduce(0.0) { $0 + $1.cost }
}
}
return ForecastStats(
mtd: mtd,
projection: projection,
weekAvg: weekAvg,
weekTotal: weekTotal,
yesterday: yesterday,
previousMonthTotal: previousMonthTotal
)
}
// MARK: - Pulse
private struct PulseInsight: View {
let payload: MenubarPayload
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
PulseTile(
label: "Cost / session",
value: payload.current.sessions > 0
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
: "",
color: .secondary
)
}
CostPerEditCaption(models: payload.current.modelEfficiency)
}
}
private var cacheHitText: String {
let v = payload.current.cacheHitPercent
return v <= 0 ? "" : String(format: "%.0f%%", v)
}
private var oneShotText: String {
guard let r = payload.current.oneShotRate else { return "" }
return String(format: "%.0f%%", r * 100)
}
private var oneShotColor: Color {
payload.current.oneShotRate == nil ? .secondary : Theme.brandAccent
}
}
private struct PulseTile: View {
let label: String
let value: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(value)
.font(.system(size: 18, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(color)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.secondary.opacity(0.06))
)
}
}
private struct CostPerEditCaption: View {
let models: [ModelEfficiencyEntry]
var body: some View {
let valid = models.compactMap { m -> (String, Double)? in
guard let cpe = m.costPerEdit, cpe > 0 else { return nil }
return (m.name, cpe)
}.sorted(by: { $0.1 < $1.1 })
if let best = valid.first {
HStack(spacing: 4) {
Image(systemName: "pencil.line")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
Text("Cost/edit")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatCPE(best.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(best.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
if valid.count > 1, let worst = valid.last, worst.0 != best.0 {
Text("")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Text(formatCPE(worst.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.primary)
Text(worst.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
}
}
private func formatCPE(_ v: Double) -> String {
if v < 0.01 { return String(format: "$%.3f", v) }
return String(format: "$%.2f", v)
}
}
/// Connects optimize findings directly to plan utilization: "address N findings to recover X
/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens).
/// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
private struct OptimizeSavingsBadge: View {
let payload: MenubarPayload
var body: some View {
let findingCount = payload.optimize.findingCount
let savingsUSD = payload.optimize.savingsUSD
if findingCount == 0 || savingsUSD <= 0 {
EmptyView()
} else {
Button { openOptimize() } label: {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(captionText(findingCount: findingCount, savingsUSD: savingsUSD))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Theme.brandAccent.opacity(0.10))
)
}
.buttonStyle(.plain)
.padding(.top, 2)
}
}
private func captionText(findingCount: Int, savingsUSD: Double) -> String {
let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx)
let tokensLabel = formatTokens(tokens)
let plural = findingCount == 1 ? "finding" : "findings"
return "Save ~\(savingsUSD.asCompactCurrency()) / ~\(tokensLabel) tokens · \(findingCount) \(plural)"
}
private func openOptimize() {
TerminalLauncher.open(subcommand: ["optimize"])
}
private func formatTokens(_ n: Double) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
}
// MARK: - Stats
private struct StatsInsight: View {
let payload: MenubarPayload
var body: some View {
let stats = computeAllStats(payload: payload)
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
StatRow(label: "Favorite model", value: stats.favoriteModel)
StatRow(label: "Active days (month)", value: stats.activeDaysFraction)
StatRow(label: "Most active day", value: stats.mostActiveDay)
StatRow(label: "Peak day spend", value: stats.peakDaySpend)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 8) {
StatRow(label: "Sessions today", value: "\(payload.current.sessions)")
StatRow(label: "Calls today", value: payload.current.calls.asThousandsSeparated())
StatRow(label: "Current streak", value: stats.currentStreak)
StatRow(label: "Longest streak", value: stats.longestStreak)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if let lifetime = stats.lifetimeTotal {
Divider().opacity(0.5)
HStack {
Text("Tracked spend (last \(stats.historyDayCount) days)")
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(lifetime.asCurrency())
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Theme.brandAccent)
}
}
if !payload.current.topProjects.isEmpty {
Divider().opacity(0.5)
TopProjectsList(projects: payload.current.topProjects)
}
if let top = payload.current.topSessions.first, top.cost > 0 {
HStack(spacing: 4) {
Image(systemName: "flame")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(Theme.brandAccent)
Text("Costliest session")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(top.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
Text("· \(projectDisplayName(top.project))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
}
}
private struct RetryTaxSection: View {
let retryTax: RetryTax
let totalCost: Double
@State private var expanded = false
var body: some View {
if retryTax.totalUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.2.squarepath")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.orange)
Text("Retry tax")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(retryTax.totalUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.orange)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((retryTax.totalUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(retryTax.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
if let rpe = model.retriesPerEdit {
Text(String(format: "%.1f ret/edit", rpe))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
}
Text(model.taxUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.orange.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.orange.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
private struct StatRow: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary)
Text(value)
.font(.system(size: 12, weight: .semibold))
.monospacedDigit()
.foregroundStyle(.primary)
}
}
}
private func projectDisplayName(_ path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
private struct TopProjectsList: View {
let projects: [ProjectEntry]
@State private var expanded: String?
var body: some View {
let top = Array(projects.prefix(3))
let maxCost = top.first?.cost ?? 1
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(top.enumerated()), id: \.offset) { idx, project in
let expandKey = "\(idx):\(project.name)"
let isOpen = expanded == expandKey
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(isOpen ? 90 : 0))
Text(projectDisplayName(project.name))
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
Text("\(project.sessions) sess")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
Text(project.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
RoundedRectangle(cornerRadius: 2)
.fill(Theme.brandAccent.opacity(0.5))
.frame(
width: max(2, 40 * CGFloat(project.cost / max(maxCost, 0.01))),
height: 6
)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded = isOpen ? nil : expandKey
}
}
if isOpen, !project.sessionDetails.isEmpty {
SessionDetailsList(sessions: project.sessionDetails)
.padding(.top, 6)
.padding(.leading, 14)
}
}
}
}
}
}
private struct SessionDetailsList: View {
let sessions: [SessionDetailEntry]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(sessions.prefix(5).enumerated()), id: \.offset) { idx, sess in
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 0) {
Text(sess.cost.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.primary)
.monospacedDigit()
.frame(width: 52, alignment: .trailing)
Text(" \(sess.calls) calls")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Spacer()
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.inputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.outputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
HStack(spacing: 4) {
ForEach(Array(sess.models.prefix(3).enumerated()), id: \.offset) { _, model in
Text(model.name)
.font(.system(size: 8.5, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1.5)
.background(Theme.brandAccent.opacity(0.1))
.clipShape(Capsule())
}
}
.padding(.leading, 52)
}
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(.primary.opacity(0.03))
)
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.15))
)
)
}
}
}
private func compactTokens(_ n: Int) -> String {
let d = Double(n)
if d >= 1_000_000 { return String(format: "%.1fM", d / 1_000_000) }
if d >= 1_000 { return String(format: "%.0fK", d / 1_000) }
return "\(n)"
}
}
// MARK: - Optimize
private struct OptimizeInsight: View {
let payload: MenubarPayload
var body: some View {
let totalWaste = payload.current.retryTax.totalUSD + payload.current.routingWaste.totalSavingsUSD
let cost = payload.current.cost
VStack(alignment: .leading, spacing: 12) {
if totalWaste > 0, cost > 0 {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Potential savings")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(totalWaste.asCompactCurrency())
.font(.system(size: 24, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int((totalWaste / cost * 100).rounded()))% of spend")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.orange.opacity(0.8))
Text("could be optimized")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
}
.padding(.bottom, 2)
}
RetryTaxSection(retryTax: payload.current.retryTax, totalCost: cost)
RoutingWasteSection(routingWaste: payload.current.routingWaste, totalCost: cost)
}
}
}
private struct RoutingWasteSection: View {
let routingWaste: RoutingWaste
let totalCost: Double
@State private var expanded = false
var body: some View {
if routingWaste.totalSavingsUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.swap")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.purple)
Text("Routing waste")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(routingWaste.totalSavingsUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.purple)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((routingWaste.totalSavingsUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
if !routingWaste.baselineModel.isEmpty {
Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(routingWaste.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "$%.2f/edit", model.costPerEdit))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
Text(model.savingsUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.purple.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.purple.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
private struct AllStats {
let favoriteModel: String
let activeDaysFraction: String
let mostActiveDay: String
let peakDaySpend: String
let currentStreak: String
let longestStreak: String
let lifetimeTotal: Double?
let historyDayCount: Int
}
@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats {
let history = payload.history.daily
let favoriteModel = payload.current.topModels.first?.name ?? ""
let calendar = gregorianCalendar
let formatter = yyyymmdd
let displayFormatter = mmmDayFormat
let now = Date()
let today = calendar.startOfDay(for: now)
let comps = calendar.dateComponents([.year, .month, .day], from: now)
var activeDaysFraction = ""
if
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
{
let firstStr = formatter.string(from: firstOfMonth)
let mtdActive = history.filter { $0.date >= firstStr && $0.cost > 0 }.count
activeDaysFraction = "\(mtdActive)/\(rangeOfMonth.count)"
}
let peak = history.max(by: { $0.cost < $1.cost })
let mostActiveDay: String
let peakDaySpend: String
if let peak, peak.cost > 0, let date = formatter.date(from: peak.date) {
mostActiveDay = displayFormatter.string(from: date)
peakDaySpend = peak.cost.asCompactCurrency()
} else {
mostActiveDay = ""
peakDaySpend = ""
}
let costByDate = Dictionary(history.map { ($0.date, $0.cost) }, uniquingKeysWith: +)
var currentStreak = 0
for offset in 0..<400 {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
let key = formatter.string(from: d)
if (costByDate[key] ?? 0) > 0 { currentStreak += 1 } else { break }
}
var longestStreak = 0
var running = 0
if let firstDate = history.map(\.date).min(),
let lastDate = history.map(\.date).max(),
let start = formatter.date(from: firstDate),
let end = formatter.date(from: lastDate) {
var cursor = start
while cursor <= end {
let key = formatter.string(from: cursor)
if (costByDate[key] ?? 0) > 0 {
running += 1
longestStreak = max(longestStreak, running)
} else {
running = 0
}
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
cursor = next
}
}
let lifetimeTotal: Double? = history.isEmpty ? nil : history.reduce(0.0) { $0 + $1.cost }
return AllStats(
favoriteModel: favoriteModel,
activeDaysFraction: activeDaysFraction,
mostActiveDay: mostActiveDay,
peakDaySpend: peakDaySpend,
currentStreak: currentStreak == 0 ? "" : "\(currentStreak) days",
longestStreak: longestStreak == 0 ? "" : "\(longestStreak) days",
lifetimeTotal: lifetimeTotal,
historyDayCount: history.count
)
}
// MARK: - Plan (subscription)
private struct PlanInsight: View {
@Environment(AppStore.self) private var store
let usage: SubscriptionUsage?
private static let fiveHourSeconds: TimeInterval = 5 * 3600
private static let sevenDaySeconds: TimeInterval = 7 * 86400
private static let freshWindowThreshold: Double = 0.05
@State private var projections: [String: WindowProjection] = [:]
var body: some View {
Group {
switch store.subscriptionLoadState {
case .notBootstrapped, .dormant:
PlanConnectView { Task { await store.bootstrapSubscription() } }
case .bootstrapping:
PlanLoadingView()
case .loading:
if let usage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
case .noCredentials:
PlanNoCredentialsView()
case .failed:
PlanFailedView(error: store.subscriptionError)
case .transientFailure:
if let usage {
loadedBody(usage: usage)
} else {
PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.")
}
case let .terminalFailure(reason):
PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } }
case .loaded:
if let usage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
}
}
}
@ViewBuilder
private func loadedBody(usage: SubscriptionUsage) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.tier.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Spacer()
if let resets = headlineReset(usage: usage) {
Text("Resets \(resets)")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
VStack(spacing: 8) {
if let p = usage.fiveHourPercent {
UtilizationRow(label: "5-hour window", percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"])
}
if let p = usage.sevenDayPercent {
UtilizationRow(label: "7-day total", percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"])
}
if let p = usage.sevenDayOpusPercent {
UtilizationRow(label: "7-day Opus", percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"])
}
if let p = usage.sevenDaySonnetPercent {
UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"])
}
}
OptimizeSavingsBadge(payload: store.payload)
}
.task(id: usage.fetchedAt) {
await recomputeProjections(usage: usage)
}
}
private func recomputeProjections(usage: SubscriptionUsage) async {
var result: [String: WindowProjection] = [:]
let inputs: [(String, Double?, Date?, TimeInterval)] = [
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds),
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds),
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds),
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds),
]
for (key, percent, resetsAt, windowSeconds) in inputs {
if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) {
result[key] = projection
}
}
projections = result
}
/// Linear extrapolation when window is past the freshness threshold; otherwise falls back to
/// the prior cycle's final percent from the snapshot store.
private func project(key: String, percent: Double?, resetsAt: Date?, windowSeconds: TimeInterval) async -> WindowProjection? {
guard let percent, let resetsAt else { return nil }
let windowStart = resetsAt.addingTimeInterval(-windowSeconds)
let elapsed = Date().timeIntervalSince(windowStart)
let elapsedFraction = elapsed / windowSeconds
if elapsedFraction > Self.freshWindowThreshold, percent > 0 {
let projectedPercent = percent / elapsedFraction
var hitDate: Date? = nil
if projectedPercent > 100, percent < 100 {
let remainingPercent = 100 - percent
let percentPerSecond = percent / elapsed
if percentPerSecond > 0 {
hitDate = Date().addingTimeInterval(remainingPercent / percentPerSecond)
}
}
return WindowProjection(percent: projectedPercent, willOverflow: projectedPercent > 100, hitsLimitAt: hitDate, source: .linear)
}
// Window too fresh OR percent exactly zero -- use the prior cycle's final reading.
if let prior = await SubscriptionSnapshotStore.previousWindowFinal(windowKey: key, currentResetsAt: resetsAt) {
return WindowProjection(percent: prior, willOverflow: prior > 100, hitsLimitAt: nil, source: .historicalBaseline)
}
return nil
}
private func headlineReset(usage: SubscriptionUsage) -> String? {
let candidates = [
usage.fiveHourResetsAt,
usage.sevenDayResetsAt,
usage.sevenDayOpusResetsAt,
usage.sevenDaySonnetResetsAt,
].compactMap { $0 }
guard let earliest = candidates.min() else { return nil }
return relativeReset(earliest)
}
}
// MARK: - Plan empty/loading/failure states
private struct PlanLoadingView: View {
var body: some View {
VStack(spacing: 8) {
ProgressView().scaleEffect(0.8)
Text("Reading Claude credentials...")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
private struct PlanNoCredentialsView: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(spacing: 10) {
Image(systemName: "key.slash")
.font(.system(size: 24))
.foregroundStyle(.tertiary)
Text("No Claude credentials found")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
Button("Try Again") {
Task { await store.bootstrapSubscription() }
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
private struct PlanFailedView: View {
@Environment(AppStore.self) private var store
let error: String?
var body: some View {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 18))
.foregroundStyle(Theme.brandAccent)
Text("Couldn't load plan data")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
if let error {
Text(error)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
.lineLimit(3)
}
Button("Retry") {
Task { await store.refreshSubscription() }
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
}
/// Shown the very first time a user opens the Plan tab. Clicking Connect is the
/// only path to triggering the macOS keychain prompt for Claude Code credentials
/// the menubar app does not touch the keychain at startup.
private struct PlanConnectView: View {
let onConnect: () -> Void
var body: some View {
VStack(spacing: 10) {
Image(systemName: "link.circle")
.font(.system(size: 26))
.foregroundStyle(Theme.brandAccent)
Text("Connect Claude subscription")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
Button("Connect", action: onConnect)
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
/// Shown when the refresh token has been invalidated (typically because the user
/// re-authenticated on another device). Clicking the button re-runs bootstrap,
/// which reads Claude's credentials source again and writes a fresh copy to our
/// own keychain item.
private struct PlanReconnectView: View {
let reason: String?
let onReconnect: () -> Void
var body: some View {
VStack(spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath.circle")
.font(.system(size: 24))
.foregroundStyle(.red)
Text("Reconnect Claude")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
.lineLimit(3)
Button("Reconnect", action: onReconnect)
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(.red)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from
/// store.codexUsage / store.codexLoadState. We deliberately skip the
/// "On pace at reset" projection here that math is fed by local
/// per-message Claude spend extrapolated against the API quota windows;
/// our local Codex spend isn't an apples-to-apples signal for the
/// ChatGPT-subscription rate windows reported by wham/usage. Add when
/// we wire a comparable extrapolator.
private struct CodexPlanInsight: View {
@Environment(AppStore.self) private var store
var body: some View {
Group {
switch store.codexLoadState {
case .notBootstrapped, .dormant:
PlanConnectView { Task { await store.bootstrapCodex() } }
case .bootstrapping:
PlanLoadingView()
case .loading:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
case .noCredentials:
PlanNoCredentialsView()
case .failed:
PlanFailedView(error: store.codexError)
case .transientFailure:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.")
}
case let .terminalFailure(reason):
PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } }
case .loaded:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
}
}
}
@ViewBuilder
private func loadedBody(usage: CodexUsage) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.plan.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if let primary = usage.primary {
UtilizationRow(
label: "\(primary.windowLabel) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
)
}
if let secondary = usage.secondary {
UtilizationRow(
label: "\(secondary.windowLabel) window",
percent: secondary.usedPercent,
resetsAt: secondary.resetsAt,
projection: nil
)
}
// Surface non-zero per-model rate limits (Codex Spark, etc.) so
// power users see them; idle ones stay collapsed.
ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in
if let p = limit.primary, p.usedPercent > 0 {
UtilizationRow(
label: "\(limit.name) · \(p.windowLabel)",
percent: p.usedPercent,
resetsAt: p.resetsAt,
projection: nil
)
}
if let s = limit.secondary, s.usedPercent > 0 {
UtilizationRow(
label: "\(limit.name) · \(s.windowLabel)",
percent: s.usedPercent,
resetsAt: s.resetsAt,
projection: nil
)
}
}
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
}
private func relativeReset(_ date: Date) -> String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
}
private struct WindowProjection {
enum Source { case linear, historicalBaseline }
let percent: Double
let willOverflow: Bool
let hitsLimitAt: Date?
let source: Source
}
private struct UtilizationRow: View {
let label: String
/// API returns utilization as 0..100 (a percentage value, not a fraction).
let percent: Double
let resetsAt: Date?
let projection: WindowProjection?
var body: some View {
VStack(spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.0f%%", clampedPercent))
.font(.codeMono(size: 11, weight: .semibold))
.foregroundStyle(barColor)
.monospacedDigit()
}
UtilizationBar(
fraction: clampedPercent / 100,
color: barColor,
markerFraction: projection.map { min(max($0.percent, 0), 100) / 100 }
)
.frame(height: 6)
if let projection {
ProjectionCaption(projection: projection)
}
}
}
private var clampedPercent: Double { min(max(percent, 0), 100) }
/// Single-color brand palette decision (see session notes): the number is the signal, not
/// the color. Keeping this as a computed property so a future threshold-based palette
/// reintroduction stays scoped to one place.
private var barColor: Color { Theme.brandAccent }
}
private struct ProjectionCaption: View {
let projection: WindowProjection
var body: some View {
HStack(spacing: 3) {
if projection.willOverflow {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(Theme.brandAccent)
} else {
Image(systemName: "arrow.up.right")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.tertiary)
}
Text(captionText)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(projection.willOverflow
? AnyShapeStyle(Theme.brandAccent)
: AnyShapeStyle(.tertiary))
Spacer()
}
}
private var captionText: String {
let projected = String(format: "%.0f%%", projection.percent)
switch projection.source {
case .linear:
if projection.willOverflow, let hit = projection.hitsLimitAt {
return "On pace: \(projected) at reset · hits 100% \(relativeReset(hit))"
}
return "On pace: \(projected) at reset"
case .historicalBaseline:
return "Based on last cycle: \(projected)"
}
}
}
private struct UtilizationBar: View {
/// 0..1 fraction of the bar to fill.
let fraction: Double
let color: Color
/// Optional 0..1 marker position for projected utilization at reset.
let markerFraction: Double?
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.secondary.opacity(0.12))
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: max(0, geo.size.width * CGFloat(fraction)))
if let m = markerFraction {
Rectangle()
.fill(Color.primary.opacity(0.55))
.frame(width: 1.5)
.offset(x: max(0, geo.size.width * CGFloat(m)) - 0.75)
}
}
}
}
}
private func relativeReset(_ date: Date) -> String {
let interval = date.timeIntervalSinceNow
if interval <= 0 { return "now" }
let hours = interval / 3600
if hours < 1 {
let minutes = Int(ceil(interval / 60))
return "in \(minutes)m"
}
if hours < 24 { return "in \(Int(ceil(hours)))h" }
let days = Int(ceil(hours / 24))
return "in \(days)d"
}