@@ -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+
36107struct 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
78162struct 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+
142369struct OverviewMenuCardRowView : View {
143370 enum Emphasis : Equatable {
144371 case prominent
0 commit comments