Skip to content

Commit ff3374e

Browse files
Add overview token cost breakdown
1 parent 6e7b617 commit ff3374e

5 files changed

Lines changed: 462 additions & 16 deletions

File tree

Sources/CodexBar/StatusItemController+Menu.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ extension StatusItemController {
579579
let spendModel = self.overviewSpendDashboardModel(providers: enabledProviders)
580580
let spendSummary = OverviewSpendSummary(
581581
model: spendModel,
582-
connectedProviderCount: enabledProviders.count)
582+
trackedProviders: enabledProviders)
583583
let fallbackCurrencyCode = spendModel.groups.first?.currencyCode ?? "USD"
584584
let sharePayload = ShareStatsBuilder.make(
585585
model: spendModel,
@@ -609,6 +609,7 @@ extension StatusItemController {
609609
spendSummary.primarySpendText,
610610
spendSummary.coverageText,
611611
spendSummary.tokenText ?? "",
612+
spendSummary.tokenAllocation == nil ? "noAllocation" : "allocation",
612613
].joined(separator: "|"),
613614
containsInteractiveControls: sharePayload != nil)
614615
menu.addItem(summaryItem)

Sources/CodexBar/StatusItemController+MenuTypes.swift

Lines changed: 236 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,108 @@ extension ProviderSwitcherSelection {
3333
}
3434
}
3535

36+
struct OverviewTokenAllocation: Equatable {
37+
struct CostPerMillionTokens: Equatable {
38+
let amount: Double
39+
let currencyCode: String
40+
}
41+
42+
struct Row: Identifiable, Equatable {
43+
let id: String
44+
let provider: UsageProvider
45+
let displayName: String
46+
let tokenCount: Int?
47+
let tokenFraction: Double?
48+
let costPerMillionTokens: CostPerMillionTokens?
49+
}
50+
51+
let knownTotalTokens: Int
52+
let isPartial: Bool
53+
let rows: [Row]
54+
55+
init?(model: SpendDashboardModel, trackedProviders: Set<UsageProvider>) {
56+
let sourceRows = model.groups.flatMap { group in
57+
group.providers.map { row in
58+
(row: row, currencyCode: group.currencyCode)
59+
}
60+
}
61+
var knownTotalTokens = 0
62+
for source in sourceRows {
63+
guard let tokens = source.row.totalTokens else { continue }
64+
guard tokens >= 0 else { return nil }
65+
let result = knownTotalTokens.addingReportingOverflow(tokens)
66+
guard !result.overflow else { return nil }
67+
knownTotalTokens = result.partialValue
68+
}
69+
guard knownTotalTokens > 0 else { return nil }
70+
71+
self.knownTotalTokens = knownTotalTokens
72+
self.isPartial = Set(sourceRows.map(\.row.provider)) != trackedProviders ||
73+
sourceRows.contains { $0.row.totalTokens == nil || $0.row.coveredDayCount < model.requestedDays } ||
74+
model.groups.contains { $0.coveredDayCount < model.requestedDays }
75+
self.rows = sourceRows.map { source in
76+
let tokenFraction = source.row.totalTokens.map {
77+
Double($0) / Double(knownTotalTokens)
78+
}
79+
return Row(
80+
id: source.row.id,
81+
provider: source.row.provider,
82+
displayName: source.row.displayName,
83+
tokenCount: source.row.totalTokens,
84+
tokenFraction: tokenFraction,
85+
costPerMillionTokens: Self.costPerMillionTokens(
86+
tokens: source.row.totalTokens,
87+
cost: source.row.totalCost,
88+
currencyCode: source.currencyCode))
89+
}
90+
}
91+
92+
private static func costPerMillionTokens(
93+
tokens: Int?,
94+
cost: Double?,
95+
currencyCode: String) -> CostPerMillionTokens?
96+
{
97+
guard let tokens, tokens > 0,
98+
let cost, cost.isFinite, cost >= 0,
99+
!currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
100+
else { return nil }
101+
let amount = (cost / Double(tokens)) * 1_000_000
102+
guard amount.isFinite, amount >= 0 else { return nil }
103+
return CostPerMillionTokens(amount: amount, currencyCode: currencyCode)
104+
}
105+
}
106+
36107
struct OverviewSpendSummary: Equatable {
37108
let primarySpendText: String
38109
let coverageText: String
39110
let tokenText: String?
111+
let tokenAllocation: OverviewTokenAllocation?
40112
let isPartial: Bool
41113

42-
init(model: SpendDashboardModel, connectedProviderCount: Int) {
43-
let connectedCount = max(0, connectedProviderCount)
44-
let knownCostCount = model.groups.reduce(0) { $0 + $1.knownCostProviderCount }
45-
let knownTokenRows = model.groups.flatMap(\.providers).compactMap(\.totalTokens)
114+
init(model: SpendDashboardModel, trackedProviders: [UsageProvider]) {
115+
let trackedProviderSet = Set(trackedProviders)
116+
let connectedCount = trackedProviderSet.count
117+
let sourceRows = model.groups.flatMap(\.providers)
118+
let rowsByProvider = Dictionary(grouping: sourceRows, by: \.provider)
119+
let modeledProviderSet = Set(rowsByProvider.keys)
120+
let knownCostProviderSet = Set(rowsByProvider.compactMap { provider, rows in
121+
rows.allSatisfy { $0.totalCost != nil } ? provider : nil
122+
})
123+
let knownCostCount = knownCostProviderSet.intersection(trackedProviderSet).count
124+
let costCoverageIsComplete = modeledProviderSet == trackedProviderSet &&
125+
knownCostProviderSet == trackedProviderSet &&
126+
model.groups.allSatisfy { $0.totalCost != nil }
127+
let knownTokenRows = sourceRows.compactMap(\.totalTokens)
46128
let knownTokens = Self.safeTokenSum(knownTokenRows)
47-
let tokenCoverageIsComplete = knownTokenRows.count == connectedCount &&
129+
let tokenCoverageIsComplete = modeledProviderSet == trackedProviderSet &&
130+
sourceRows.allSatisfy { $0.totalTokens != nil && $0.coveredDayCount >= model.requestedDays } &&
48131
model.groups.allSatisfy { $0.totalTokens != nil }
49-
self.isPartial = knownCostCount < connectedCount || model.groups.contains { $0.totalCost == nil }
132+
self.isPartial = !costCoverageIsComplete
50133

51134
let spendTexts = model.groups.compactMap { group -> String? in
52135
guard let cost = group.totalCost ?? group.knownCost else { return nil }
53136
let formatted = UsageFormatter.currencyString(cost, currencyCode: group.currencyCode)
54-
let groupIsPartial = group.totalCost == nil || knownCostCount < connectedCount
137+
let groupIsPartial = group.totalCost == nil || !costCoverageIsComplete
55138
return groupIsPartial ? "~\(formatted)" : formatted
56139
}
57140
self.primarySpendText = spendTexts.isEmpty ? L("Spend unavailable") : spendTexts.joined(separator: " · ")
@@ -62,6 +145,7 @@ struct OverviewSpendSummary: Equatable {
62145
let value = tokenCoverageIsComplete ? formatted : "~\(formatted)"
63146
return L("%@ tokens", value)
64147
}
148+
self.tokenAllocation = OverviewTokenAllocation(model: model, trackedProviders: trackedProviderSet)
65149
}
66150

67151
private static func safeTokenSum(_ values: [Int]) -> Int? {
@@ -76,7 +160,8 @@ struct OverviewSpendSummary: Equatable {
76160
}
77161

78162
struct OverviewSpendSummaryCardView: View {
79-
static let rowHeight: CGFloat = 94
163+
static let baseRowHeight: CGFloat = 94
164+
static let rowHeight: CGFloat = 146
80165

81166
let summary: OverviewSpendSummary
82167
let days: Int
@@ -110,6 +195,10 @@ struct OverviewSpendSummaryCardView: View {
110195
.font(.caption)
111196
.foregroundStyle(.secondary)
112197
.lineLimit(1)
198+
199+
if let allocation = self.summary.tokenAllocation {
200+
OverviewTokenAllocationView(allocation: allocation)
201+
}
113202
}
114203

115204
Spacer(minLength: 6)
@@ -130,7 +219,9 @@ struct OverviewSpendSummaryCardView: View {
130219
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
131220
.padding(.vertical, 10)
132221
.frame(width: self.width, alignment: .leading)
133-
.frame(minHeight: Self.rowHeight, alignment: .leading)
222+
.frame(
223+
minHeight: self.summary.tokenAllocation == nil ? Self.baseRowHeight : Self.rowHeight,
224+
alignment: .leading)
134225
.background {
135226
RoundedRectangle(cornerRadius: 12, style: .continuous)
136227
.fill(Color.accentColor.opacity(0.08))
@@ -139,6 +230,142 @@ struct OverviewSpendSummaryCardView: View {
139230
}
140231
}
141232

233+
private struct OverviewTokenAllocationView: View {
234+
private static let displayLimit = 3
235+
private static let segmentSpacing: CGFloat = 1
236+
237+
let allocation: OverviewTokenAllocation
238+
@Environment(\.accessibilityReduceMotion) private var reduceMotion
239+
240+
var body: some View {
241+
VStack(alignment: .leading, spacing: 4) {
242+
HStack(spacing: 5) {
243+
Text(L("Tracked tokens"))
244+
Spacer(minLength: 4)
245+
Text(
246+
(self.allocation.isPartial ? "~" : "") +
247+
ShareStatsFormatting.compactCount(self.allocation.knownTotalTokens))
248+
.monospacedDigit()
249+
}
250+
.font(.caption2.weight(.medium))
251+
.foregroundStyle(.secondary)
252+
253+
self.segmentedBar
254+
.frame(height: 6)
255+
256+
HStack(alignment: .firstTextBaseline, spacing: 8) {
257+
ForEach(Array(self.visibleRows.enumerated()), id: \.offset) { _, row in
258+
VStack(alignment: .leading, spacing: 1) {
259+
Text(self.allocationText(for: row))
260+
.font(.caption2.weight(.semibold))
261+
.lineLimit(1)
262+
Text(self.rateText(for: row))
263+
.font(.caption2)
264+
.foregroundStyle(.secondary)
265+
.lineLimit(1)
266+
}
267+
.frame(maxWidth: .infinity, alignment: .leading)
268+
.accessibilityElement(children: .ignore)
269+
.accessibilityLabel(self.accessibilityText(for: row))
270+
}
271+
if self.hiddenRowCount > 0 {
272+
Text("+\(self.hiddenRowCount)")
273+
.font(.caption2.weight(.medium))
274+
.foregroundStyle(.secondary)
275+
.accessibilityLabel(L("%d more items", self.hiddenRowCount))
276+
}
277+
}
278+
}
279+
.animation(
280+
self.reduceMotion ? nil : .easeOut(duration: 0.2),
281+
value: self.allocation.rows)
282+
}
283+
284+
private var segmentedBar: some View {
285+
GeometryReader { geometry in
286+
let rows = self.segmentRows
287+
let spacing = Self.segmentSpacing * CGFloat(max(0, rows.count - 1))
288+
let availableWidth = max(0, geometry.size.width - spacing)
289+
HStack(spacing: Self.segmentSpacing) {
290+
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
291+
RoundedRectangle(cornerRadius: 3, style: .continuous)
292+
.fill(UsageMenuCardView.Model.progressColor(for: row.provider))
293+
.frame(width: availableWidth * (row.tokenFraction ?? 0))
294+
}
295+
}
296+
}
297+
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 3, style: .continuous))
298+
.accessibilityElement(children: .ignore)
299+
.accessibilityLabel("\(L("Tracked tokens")) · \(L("Usage breakdown"))")
300+
.accessibilityValue(self.accessibilityAllocationValue)
301+
}
302+
303+
private var orderedRows: [OverviewTokenAllocation.Row] {
304+
self.allocation.rows.sorted { lhs, rhs in
305+
switch (lhs.tokenCount, rhs.tokenCount) {
306+
case let (left?, right?) where left != right: left > right
307+
case (_?, nil): true
308+
case (nil, _?): false
309+
default: lhs.id < rhs.id
310+
}
311+
}
312+
}
313+
314+
private var visibleRows: [OverviewTokenAllocation.Row] {
315+
Array(self.orderedRows.prefix(Self.displayLimit))
316+
}
317+
318+
private var hiddenRowCount: Int {
319+
max(0, self.allocation.rows.count - self.visibleRows.count)
320+
}
321+
322+
private var segmentRows: [OverviewTokenAllocation.Row] {
323+
self.orderedRows.filter { ($0.tokenFraction ?? 0) > 0 }
324+
}
325+
326+
private var accessibilityAllocationValue: String {
327+
self.allocation.rows.map(self.accessibilityText).joined(separator: ", ")
328+
}
329+
330+
private func allocationText(for row: OverviewTokenAllocation.Row) -> String {
331+
let percent = row.tokenFraction.map { UsageFormatter.percentString($0 * 100) } ?? L("Unknown")
332+
return "\(row.displayName) \(percent)"
333+
}
334+
335+
private func rateText(for row: OverviewTokenAllocation.Row) -> String {
336+
guard let rate = row.costPerMillionTokens else { return L("unavailable") }
337+
return OverviewTokenRateFormatting.text(rate)
338+
}
339+
340+
private func accessibilityText(for row: OverviewTokenAllocation.Row) -> String {
341+
"\(self.allocationText(for: row)), \(self.rateText(for: row))"
342+
}
343+
}
344+
345+
enum OverviewTokenRateFormatting {
346+
static func text(_ rate: OverviewTokenAllocation.CostPerMillionTokens) -> String {
347+
let formatted = UsageFormatter.currencyString(rate.amount, currencyCode: rate.currencyCode)
348+
let zero = UsageFormatter.currencyString(0, currencyCode: rate.currencyCode)
349+
if rate.amount > 0, formatted == zero {
350+
let minimum = self.minimumVisibleAmount(currencyCode: rate.currencyCode)
351+
return "<\(UsageFormatter.currencyString(minimum, currencyCode: rate.currencyCode)) / 1M"
352+
}
353+
return "\(rate.amount == 0 ? "" : "~")\(formatted) / 1M"
354+
}
355+
356+
private static func minimumVisibleAmount(currencyCode: String) -> Double {
357+
let zero = UsageFormatter.currencyString(0, currencyCode: currencyCode)
358+
var candidate = 1.0
359+
var minimum = candidate
360+
for _ in 0..<8 {
361+
guard UsageFormatter.currencyString(candidate, currencyCode: currencyCode) != zero else { break }
362+
minimum = candidate
363+
candidate /= 10
364+
}
365+
return minimum
366+
}
367+
}
368+
142369
struct OverviewMenuCardRowView: View {
143370
enum Emphasis: Equatable {
144371
case prominent

0 commit comments

Comments
 (0)