diff --git a/Sources/CursorUsageMenuBar/Localization/L10n.swift b/Sources/CursorUsageMenuBar/Localization/L10n.swift index 921f69c..49c4220 100644 --- a/Sources/CursorUsageMenuBar/Localization/L10n.swift +++ b/Sources/CursorUsageMenuBar/Localization/L10n.swift @@ -72,6 +72,14 @@ enum L10n { case cumulative case spendAmount case usedSpend + case yourUsage + case yourUsageSubtitle + case groupByModelSpend + case cumulativeSpendAxis + case todayMarker + case emptyCumulativeUsage + case includedSpend + case onDemandSpend // Spending tab case cycleSpending @@ -301,6 +309,14 @@ enum L10n { .cumulative: "Cumulative %@", .spendAmount: "Spend $%.2f", .usedSpend: "Used $%.2f", + .yourUsage: "Your Usage", + .yourUsageSubtitle: "Cumulative spend per day across this billing period", + .groupByModelSpend: "Model · Spend", + .cumulativeSpendAxis: "Cumulative Spend ($)", + .todayMarker: "Today", + .emptyCumulativeUsage: "No cumulative usage data yet", + .includedSpend: "Included", + .onDemandSpend: "On-demand", .cycleSpending: "Cycle spending", .spendShare: "Spend breakdown", @@ -511,6 +527,14 @@ enum L10n { .cumulative: "累计 %@", .spendAmount: "花费 $%.2f", .usedSpend: "已用 $%.2f", + .yourUsage: "Your Usage", + .yourUsageSubtitle: "本账单周期内按日累计花费(按模型)", + .groupByModelSpend: "模型 · 花费", + .cumulativeSpendAxis: "累计花费 ($)", + .todayMarker: "今天", + .emptyCumulativeUsage: "暂无累计用量图数据", + .includedSpend: "包含用量", + .onDemandSpend: "按需用量", .cycleSpending: "本周期花费 (Spending)", .spendShare: "花费占比", diff --git a/Sources/CursorUsageMenuBar/Models/UsageModels.swift b/Sources/CursorUsageMenuBar/Models/UsageModels.swift index 60a1876..04edd7a 100644 --- a/Sources/CursorUsageMenuBar/Models/UsageModels.swift +++ b/Sources/CursorUsageMenuBar/Models/UsageModels.swift @@ -525,6 +525,34 @@ struct DailyModelShareChartPoint: Identifiable, Sendable { } } +struct CumulativeModelSpendPoint: Identifiable, Sendable { + let id: String + let date: Date + let model: String + let cumulativeCents: Double + + init(date: Date, model: String, cumulativeCents: Double) { + self.date = date + self.model = model + self.cumulativeCents = cumulativeCents + id = "\(ISO8601DateFormatter().string(from: date))-\(model)" + } + + var cumulativeDollars: Double { cumulativeCents / 100 } +} + +struct SpendSummaryMetrics: Sendable { + let totalSpendCents: Double? + let includedSpendCents: Double? + let onDemandSpendCents: Double? + + static let empty = SpendSummaryMetrics( + totalSpendCents: nil, + includedSpendCents: nil, + onDemandSpendCents: nil + ) +} + struct ModelTokenUsageSummary: Sendable { let rows: [ModelTokenUsageRow] let totalInputTokens: Int @@ -591,6 +619,8 @@ struct DashboardSnapshot: Sendable { let quotaCurves: [QuotaCurvePoint] let modelBreakdown: [ModelSpendSlice] let dailyModelShare: [DailyModelShareDay] + let cumulativeModelSpend: [CumulativeModelSpendPoint] + let spendSummary: SpendSummaryMetrics let spendingBreakdown: [SpendingBreakdownItem] let todayStats: TodayUsageStats let partialErrors: [String] @@ -613,6 +643,8 @@ struct DashboardSnapshot: Sendable { quotaCurves: [], modelBreakdown: [], dailyModelShare: [], + cumulativeModelSpend: [], + spendSummary: .empty, spendingBreakdown: [], todayStats: .empty, partialErrors: [], @@ -677,6 +709,8 @@ struct DashboardSnapshot: Sendable { quotaCurves: quotaCurves, modelBreakdown: modelBreakdown, dailyModelShare: dailyModelShare, + cumulativeModelSpend: cumulativeModelSpend, + spendSummary: spendSummary, spendingBreakdown: spendingBreakdown, todayStats: todayStats, partialErrors: partialErrors, @@ -707,6 +741,8 @@ struct DashboardSnapshot: Sendable { ), modelBreakdown: UsageAnalytics.modelBreakdown(from: aggregatedUsage, events: allEvents), dailyModelShare: UsageAnalytics.dailyModelShare(from: allEvents), + cumulativeModelSpend: UsageAnalytics.cumulativeModelSpend(from: allEvents), + spendSummary: UsageAnalytics.spendSummary(from: periodUsage, aggregated: aggregatedUsage), spendingBreakdown: spendingBreakdown, todayStats: UsageAnalytics.todayStats(from: allEvents, billing: usageLimit), partialErrors: partialErrors, @@ -733,6 +769,8 @@ struct DashboardSnapshot: Sendable { quotaCurves: quotaCurves, modelBreakdown: modelBreakdown, dailyModelShare: dailyModelShare, + cumulativeModelSpend: cumulativeModelSpend, + spendSummary: incoming.spendSummary, spendingBreakdown: incoming.spendingBreakdown, todayStats: todayStats, partialErrors: incoming.partialErrors, @@ -791,6 +829,11 @@ struct DashboardSnapshot: Sendable { hasher.combine(day.id) hasher.combine(day.totalTokens) } + hasher.combine(cumulativeModelSpend.count) + if let last = cumulativeModelSpend.last { + hasher.combine(last.id) + hasher.combine(last.cumulativeCents) + } hasher.combine(todayStats.eventCount) hasher.combine(todayStats.totalChargedCents) hasher.combine(todayStats.dailyPercent) diff --git a/Sources/CursorUsageMenuBar/Services/CursorAPIClient.swift b/Sources/CursorUsageMenuBar/Services/CursorAPIClient.swift index ce23bed..bfaed66 100644 --- a/Sources/CursorUsageMenuBar/Services/CursorAPIClient.swift +++ b/Sources/CursorUsageMenuBar/Services/CursorAPIClient.swift @@ -77,6 +77,8 @@ struct CursorAPIClient: Sendable { quotaCurves: [], modelBreakdown: modelBreakdown, dailyModelShare: [], + cumulativeModelSpend: [], + spendSummary: UsageAnalytics.spendSummary(from: periodUsage, aggregated: aggregatedUsage), spendingBreakdown: UsageAnalytics.spendingBreakdown(from: periodUsage, summary: summary), todayStats: .empty, partialErrors: partialErrors, diff --git a/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift b/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift index 6dfdd39..534205f 100644 --- a/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift +++ b/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift @@ -54,15 +54,26 @@ enum UsageAnalytics { ] } - /// Billing API 已用 = apiPercentUsed × 包含额度(plan.limit) + /// API 已用:优先 apiPercentUsed × API 额度(与仪表盘 % 一致)。 + /// 勿盲信 period.apiSpend / includedSpend——后者常等于额度上限(如 $70),不是已用。 static func resolveAPIUsedCents( percent: Double?, limitCents: Double?, explicitSpend: Double? ) -> Double? { - if let explicitSpend { return explicitSpend } - guard let percent, let limitCents else { return nil } - return limitCents * (percent / 100) + if let percent, let limitCents { + let fromPercent = limitCents * (percent / 100) + // explicitSpend 若接近额度本身且与百分比明显不符,视为脏数据,忽略 + if let explicitSpend, limitCents > 0 { + let looksLikeLimit = abs(explicitSpend - limitCents) / limitCents < 0.02 + let disagreesWithPercent = percent < 95 && abs(explicitSpend - fromPercent) / limitCents > 0.05 + if looksLikeLimit && disagreesWithPercent { + return fromPercent + } + } + return fromPercent + } + return explicitSpend } /// Billing 总口径:totalPercentUsed + 全周期花费 @@ -788,13 +799,85 @@ enum UsageAnalytics { static func formatPercent(_ value: Double?) -> String { guard let value else { return "—" } - if value < 1 { - return String(format: "%.2f%%", value) + // 对齐 Cursor 新版 Usage 表:固定一位小数(含 0.0%) + return String(format: "%.1f%%", value) + } + + /// 按日累计、按模型堆叠的花费序列(对齐 Cursor「Your Usage」累计面积图) + static func cumulativeModelSpend( + from events: [UsageEvent], + topLimit: Int = 9 + ) -> [CumulativeModelSpendPoint] { + let calendar = Calendar.current + let language = LocalizationManager.resolvedLanguage() + let othersLabel = L10n.string(.othersModel, language: language) + + var dailyModelCents: [Date: [String: Double]] = [:] + var modelTotals: [String: Double] = [:] + + for event in events { + guard let date = event.eventDate else { continue } + let day = calendar.startOfDay(for: date) + let cents = event.chargedCents ?? 0 + guard cents > 0 else { continue } + let model = displayModelName(event.model ?? L10n.string(.unknownModel, language: language)) + dailyModelCents[day, default: [:]][model, default: 0] += cents + modelTotals[model, default: 0] += cents } - if value < 10 { - return String(format: "%.1f%%", value) + + guard !dailyModelCents.isEmpty else { return [] } + + let ranked = modelTotals.sorted { $0.value > $1.value } + let topModels = ranked.prefix(topLimit).map(\.key) + let topSet = Set(topModels) + let hasOthers = ranked.count > topLimit + let seriesModels = topModels + (hasOthers ? [othersLabel] : []) + + let sortedDays = dailyModelCents.keys.sorted() + var cumulative: [String: Double] = Dictionary(uniqueKeysWithValues: seriesModels.map { ($0, 0.0) }) + var points: [CumulativeModelSpendPoint] = [] + + for day in sortedDays { + let dayMap = dailyModelCents[day] ?? [:] + var otherDay = 0.0 + for (model, cents) in dayMap { + if topSet.contains(model) { + cumulative[model, default: 0] += cents + } else { + otherDay += cents + } + } + if hasOthers { + cumulative[othersLabel, default: 0] += otherDay + } + + for model in seriesModels { + points.append( + CumulativeModelSpendPoint( + date: day, + model: model, + cumulativeCents: cumulative[model] ?? 0 + ) + ) + } } - return String(format: "%.0f%%", value.rounded()) + + return points + } + + static func spendSummary( + from period: PeriodUsageResponse?, + aggregated: AggregatedUsageResponse? + ) -> SpendSummaryMetrics { + let plan = period?.planUsage + let total = aggregated?.totalCostCents ?? plan?.totalSpend + let included = plan?.includedSpend ?? total + let onDemand = period?.onDemandUsage?.used ?? 0 + return SpendSummaryMetrics( + totalSpendCents: total, + includedSpendCents: included, + onDemandSpendCents: onDemand + ) } static func formatBillingPeriod(start: Date?, end: Date?, language: ResolvedLanguage) -> String? { diff --git a/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift b/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift index 77eb3a7..3798560 100644 --- a/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift +++ b/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift @@ -47,6 +47,16 @@ final class UsageViewModel: ObservableObject { var summary: UsageSummary? { dashboard.summary } var todayStats: TodayUsageStats { dashboard.todayStats } + /// API 池实际已用(美分)= apiPercent × API 额度;勿用 period.includedSpend(常是额度上限) + var apiPoolUsedCents: Double? { + dashboard.usagePools.first(where: { $0.id == "api" })?.usedCents + } + + var apiPoolLimitCents: Double? { + dashboard.usagePools.first(where: { $0.id == "api" })?.limitCents + ?? summary?.planLimit + } + var menuBarTitle: String { menuBarTitle(language: LocalizationManager.shared.resolved) } diff --git a/Sources/CursorUsageMenuBar/Views/ChartViews.swift b/Sources/CursorUsageMenuBar/Views/ChartViews.swift index 140c794..07698c0 100644 --- a/Sources/CursorUsageMenuBar/Views/ChartViews.swift +++ b/Sources/CursorUsageMenuBar/Views/ChartViews.swift @@ -584,6 +584,182 @@ struct DailyModelUsageChart: View { } } +struct SpendSummaryCards: View { + @EnvironmentObject private var l10n: LocalizationManager + let summary: SpendSummaryMetrics + + var body: some View { + HStack(spacing: 8) { + spendCard(l10n.t(.totalSpend), summary.totalSpendCents) + spendCard(l10n.t(.includedSpend), summary.includedSpendCents) + spendCard(l10n.t(.onDemandSpend), summary.onDemandSpendCents ?? 0) + } + } + + private func spendCard(_ title: String, _ cents: Double?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(format(cents)) + .font(.headline.weight(.bold)) + .monospacedDigit() + .minimumScaleFactor(0.7) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private func format(_ cents: Double?) -> String { + guard let cents else { return "—" } + return String(format: "$%.2f", cents / 100) + } +} + +/// 对齐 Cursor 新版 Your Usage:按模型累计花费的堆叠面积图 +struct CumulativeUsageChart: View { + @EnvironmentObject private var l10n: LocalizationManager + let points: [CumulativeModelSpendPoint] + + private let palette: [Color] = [ + Color(red: 0.15, green: 0.42, blue: 0.28), + Color(red: 0.35, green: 0.55, blue: 0.85), + Color(red: 0.90, green: 0.55, blue: 0.50), + Color(red: 0.55, green: 0.75, blue: 0.90), + Color(red: 0.85, green: 0.78, blue: 0.55), + Color(red: 0.30, green: 0.65, blue: 0.60), + Color(red: 0.55, green: 0.40, blue: 0.75), + Color(red: 0.70, green: 0.25, blue: 0.30), + Color(red: 0.45, green: 0.70, blue: 0.45), + Color.gray, + ] + + private var modelOrder: [String] { + let totals = Dictionary(grouping: points, by: \.model) + .mapValues { pts in pts.map(\.cumulativeCents).max() ?? 0 } + return totals.sorted { $0.value > $1.value }.map(\.key) + } + + private var maxDollars: Double { + let byDay = Dictionary(grouping: points, by: \.date) + .mapValues { pts in pts.map(\.cumulativeDollars).reduce(0, +) } + let peak = byDay.values.max() ?? 0 + return max(peak * 1.08, 1) + } + + private var today: Date { + Calendar.current.startOfDay(for: Date()) + } + + private var showsToday: Bool { + points.contains { Calendar.current.isDate($0.date, inSameDayAs: today) } + } + + var body: some View { + if points.isEmpty { + ChartEmptyState(message: l10n.t(.emptyCumulativeUsage)) + } else { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(l10n.t(.yourUsage)) + .font(.subheadline.weight(.semibold)) + Text(l10n.t(.yourUsageSubtitle)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text(l10n.t(.groupByModelSpend)) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.1)) + .clipShape(Capsule()) + } + + Chart { + ForEach(points) { point in + AreaMark( + x: .value("Date", point.date, unit: .day), + y: .value("Spend", point.cumulativeDollars) + ) + .foregroundStyle(by: .value("Model", point.model)) + .interpolationMethod(.monotone) + } + + if showsToday { + RuleMark(x: .value("Today", today)) + .foregroundStyle(Color.secondary.opacity(0.55)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .annotation(position: .top, alignment: .center) { + Text(l10n.t(.todayMarker)) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.12), in: Capsule()) + } + } + } + .chartForegroundStyleScale( + domain: modelOrder, + range: modelOrder.enumerated().map { index, _ in palette[index % palette.count] } + ) + .chartYScale(domain: 0 ... maxDollars) + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: max(1, uniqueDayCount / 5))) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let amount = value.as(Double.self) { + Text("$\(amount, specifier: amount >= 100 ? "%.0f" : "%.0f")") + .font(.caption2) + } + } + } + } + .chartYAxisLabel(l10n.t(.cumulativeSpendAxis)) + .chartLegend(.hidden) + .frame(height: 220) + + legend + } + } + } + + private var uniqueDayCount: Int { + Set(points.map { Calendar.current.startOfDay(for: $0.date) }).count + } + + private var legend: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 130), spacing: 6)], + alignment: .leading, + spacing: 6 + ) { + ForEach(Array(modelOrder.enumerated()), id: \.element) { index, model in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(palette[index % palette.count]) + .frame(width: 10, height: 10) + Text(model) + .font(.caption2) + .lineLimit(1) + } + } + } + } +} + struct UsagePoolsView: View { let pools: [UsagePoolMetrics] diff --git a/Sources/CursorUsageMenuBar/Views/DashboardView.swift b/Sources/CursorUsageMenuBar/Views/DashboardView.swift index 54f45f3..c9dcb6a 100644 --- a/Sources/CursorUsageMenuBar/Views/DashboardView.swift +++ b/Sources/CursorUsageMenuBar/Views/DashboardView.swift @@ -147,6 +147,23 @@ struct DashboardView: View { } if !viewModel.isDayFilterActive { + PanelCard(title: l10n.t(.yourUsage)) { + if viewModel.dashboard.cumulativeModelSpend.isEmpty { + if viewModel.isLoadingCharts { + ChartLoadingState(message: l10n.t(.loadingChartData)) + } else if let error = viewModel.chartLoadError { + ChartErrorState(message: error) + } else { + ChartEmptyState(message: l10n.t(.emptyCumulativeUsage)) + } + } else { + VStack(alignment: .leading, spacing: 12) { + SpendSummaryCards(summary: viewModel.dashboard.spendSummary) + CumulativeUsageChart(points: viewModel.dashboard.cumulativeModelSpend) + } + } + } + PanelCard(title: l10n.t(.cycleUsage)) { VStack(alignment: .leading, spacing: 12) { if let limit = viewModel.dashboard.usageLimit { @@ -302,7 +319,7 @@ struct DashboardView: View { ) PanelRow( label: l10n.t(.apiIncludedUsed), - value: viewModel.formattedCurrencyFromCents(plan.includedSpend) + value: viewModel.formattedCurrencyFromCents(viewModel.apiPoolUsedCents) ) PanelRow( label: l10n.t(.autoBonusSpend), @@ -312,6 +329,23 @@ struct DashboardView: View { } } + PanelCard(title: l10n.t(.yourUsage)) { + if viewModel.dashboard.cumulativeModelSpend.isEmpty { + if viewModel.isLoadingCharts { + ChartLoadingState(message: l10n.t(.loadingChartData)) + } else if let error = viewModel.chartLoadError { + ChartErrorState(message: error) + } else { + ChartEmptyState(message: l10n.t(.emptyCumulativeUsage)) + } + } else { + VStack(alignment: .leading, spacing: 12) { + SpendSummaryCards(summary: viewModel.dashboard.spendSummary) + CumulativeUsageChart(points: viewModel.dashboard.cumulativeModelSpend) + } + } + } + PanelCard(title: l10n.t(.spendShare)) { SpendingBreakdownChart(items: viewModel.dashboard.spendingBreakdown) } @@ -404,7 +438,7 @@ struct DashboardView: View { if let plan = period?.planUsage { PanelRow( label: l10n.t(.apiIncludedUsed), - value: viewModel.formattedCurrencyFromCents(plan.includedSpend) + value: viewModel.formattedCurrencyFromCents(viewModel.apiPoolUsedCents) ) PanelRow( label: l10n.t(.autoBonusSpend),