@@ -9,27 +9,14 @@ struct CostHistoryChartMenuView: View {
99 private struct Point : Identifiable {
1010 let id : String
1111 let date : Date
12- let displayCostUSD : Double
13- let actualCostUSD : Double ?
12+ let costUSD : Double
1413 let totalTokens : Int ?
15- let hasUsage : Bool
16- let isPlaceholder : Bool
17-
18- init (
19- date: Date ,
20- displayCostUSD: Double ,
21- actualCostUSD: Double ? ,
22- totalTokens: Int ? ,
23- hasUsage: Bool ,
24- isPlaceholder: Bool = false )
25- {
14+
15+ init ( date: Date , costUSD: Double , totalTokens: Int ? ) {
2616 self . date = date
27- self . displayCostUSD = displayCostUSD
28- self . actualCostUSD = actualCostUSD
17+ self . costUSD = costUSD
2918 self . totalTokens = totalTokens
30- self . hasUsage = hasUsage
31- self . isPlaceholder = isPlaceholder
32- self . id = " \( Int ( date. timeIntervalSince1970) ) - \( displayCostUSD) - \( isPlaceholder) - \( hasUsage) "
19+ self . id = " \( Int ( date. timeIntervalSince1970) ) - \( costUSD) "
3320 }
3421 }
3522
@@ -39,16 +26,6 @@ struct CostHistoryChartMenuView: View {
3926 private let width : CGFloat
4027 @State private var selectedDateKey : String ?
4128
42- private struct DetailModelLine : Identifiable {
43- let id : String
44- let text : String
45- }
46-
47- private struct DetailContent {
48- let primary : String
49- let models : [ DetailModelLine ]
50- }
51-
5229 init ( provider: UsageProvider , daily: [ DailyEntry ] , totalCostUSD: Double ? , width: CGFloat ) {
5330 self . provider = provider
5431 self . daily = daily
@@ -57,7 +34,7 @@ struct CostHistoryChartMenuView: View {
5734 }
5835
5936 var body : some View {
60- let model = Self . makeModel ( provider: self . provider, daily: self . daily, now : Date ( ) )
37+ let model = Self . makeModel ( provider: self . provider, daily: self . daily)
6138 VStack ( alignment: . leading, spacing: 10 ) {
6239 if model. points. isEmpty {
6340 Text ( " No cost history data. " )
@@ -68,16 +45,15 @@ struct CostHistoryChartMenuView: View {
6845 ForEach ( model. points) { point in
6946 BarMark (
7047 x: . value( " Day " , point. date, unit: . day) ,
71- y: . value( " Cost " , point. displayCostUSD ) )
72- . foregroundStyle ( point. isPlaceholder ? Color . clear : model . barColor )
48+ y: . value( " Cost " , point. costUSD ) )
49+ . foregroundStyle ( point. costUSD > 0 ? model . barColor : Color . clear )
7350 }
7451 if let peak = Self . peakPoint ( model: model) {
75- let peakCostUSD = peak. actualCostUSD ?? 0
76- let capStart = max ( peakCostUSD - Self. capHeight ( maxValue: model. maxCostUSD) , 0 )
52+ let capStart = max ( peak. costUSD - Self. capHeight ( maxValue: model. maxCostUSD) , 0 )
7753 BarMark (
7854 x: . value( " Day " , peak. date, unit: . day) ,
7955 yStart: . value( " Cap start " , capStart) ,
80- yEnd: . value( " Cap end " , peakCostUSD ) )
56+ yEnd: . value( " Cap end " , peak . costUSD ) )
8157 . foregroundStyle ( Color ( nsColor: . systemYellow) )
8258 }
8359 }
@@ -92,8 +68,7 @@ struct CostHistoryChartMenuView: View {
9268 }
9369 }
9470 . chartLegend ( . hidden)
95- . chartYScale ( domain: 0 ... model. chartMaxCostUSD)
96- . frame ( maxWidth: . infinity, minHeight: 130 , maxHeight: 130 )
71+ . frame ( height: 130 )
9772 . chartOverlay { proxy in
9873 GeometryReader { geo in
9974 ZStack ( alignment: . topLeading) {
@@ -113,40 +88,33 @@ struct CostHistoryChartMenuView: View {
11388 }
11489 }
11590
116- let detail = self . detailContent ( model: model)
91+ let detail = self . detailLines ( model: model)
11792 VStack ( alignment: . leading, spacing: 0 ) {
11893 Text ( detail. primary)
11994 . font ( . caption)
12095 . foregroundStyle ( . secondary)
12196 . lineLimit ( 1 )
12297 . truncationMode ( . tail)
123- . frame ( maxWidth: . infinity, minHeight: 16 , maxHeight: 16 , alignment: . leading)
124- VStack ( alignment: . leading, spacing: 2 ) {
125- ForEach ( 0 ..< model. maxDetailLineCount, id: \. self) { index in
126- let line = index < detail. models. count ? detail. models [ index] : nil
127- Text ( line? . text ?? " " )
128- . font ( . caption)
129- . foregroundStyle ( . secondary)
130- . lineLimit ( 1 )
131- . truncationMode ( . tail)
132- . frame ( maxWidth: . infinity, alignment: . leading)
133- . opacity ( line == nil ? 0 : 1 )
134- }
135- }
136- . padding ( . top, 6 )
98+ . frame ( height: 16 , alignment: . leading)
99+ Text ( detail. secondary ?? " " )
100+ . font ( . caption)
101+ . foregroundStyle ( . secondary)
102+ . lineLimit ( 1 )
103+ . truncationMode ( . tail)
104+ . frame ( height: 16 , alignment: . leading)
105+ . opacity ( detail. secondary == nil ? 0 : 1 )
137106 }
138107 }
139108
140109 if let total = self . totalCostUSD {
141110 Text ( " Total (30d): \( UsageFormatter . usdString ( total) ) " )
142111 . font ( . caption)
143112 . foregroundStyle ( . secondary)
144- . frame ( maxWidth: . infinity, alignment: . leading)
145113 }
146114 }
147115 . padding ( . horizontal, 16 )
148116 . padding ( . vertical, 10 )
149- . frame ( width : self . width, alignment: . leading)
117+ . frame ( minWidth : self . width, maxWidth : . infinity , alignment: . leading)
150118 }
151119
152120 private struct Model {
@@ -158,9 +126,6 @@ struct CostHistoryChartMenuView: View {
158126 let barColor : Color
159127 let peakKey : String ?
160128 let maxCostUSD : Double
161- let minimumVisibleCostUSD : Double
162- let chartMaxCostUSD : Double
163- let maxDetailLineCount : Int
164129 }
165130
166131 private static let selectionBandColor = Color ( nsColor: . labelColor) . opacity ( 0.1 )
@@ -169,11 +134,15 @@ struct CostHistoryChartMenuView: View {
169134 maxValue * 0.05
170135 }
171136
137+ private static func makeModel( provider: UsageProvider , daily: [ DailyEntry ] ) -> Model {
138+ self . makeModel ( provider: provider, daily: daily, now: Date ( ) )
139+ }
140+
172141 private static func makeModel( provider: UsageProvider , daily: [ DailyEntry ] , now: Date ) -> Model {
173142 let sorted = daily. sorted { lhs, rhs in lhs. date < rhs. date }
143+
174144 var entriesByKey : [ String : DailyEntry ] = [ : ]
175145 entriesByKey. reserveCapacity ( sorted. count)
176-
177146 for entry in sorted {
178147 entriesByKey [ entry. date] = entry
179148 }
@@ -191,46 +160,21 @@ struct CostHistoryChartMenuView: View {
191160
192161 var peak : ( key: String , costUSD: Double ) ?
193162 var maxCostUSD : Double = 0
194- var maxDetailLineCount = 1
195-
196- for entry in sorted {
197- if let costUSD = entry. costUSD, costUSD > 0 {
198- maxCostUSD = max ( maxCostUSD, costUSD)
199- }
200- }
201-
202- let minimumVisibleCostUSD = maxCostUSD > 0 ? maxCostUSD * 0.01 : 0
203163
204164 for item in dayRange {
205165 let entry = entriesByKey [ item. key]
206- let actualCostUSD = entry. flatMap ( \. costUSD) . map { max ( 0 , $0) }
207- let hasUsage = entry. map { Self . hasUsage ( entry: $0) } ?? false
208- let hasVisibleCost = ( actualCostUSD ?? 0 ) > 0
209- let displayCostUSD = if hasVisibleCost {
210- max ( actualCostUSD ?? 0.0 , minimumVisibleCostUSD)
211- } else {
212- 0.0
213- }
214- let point = Point (
215- date: item. date,
216- displayCostUSD: displayCostUSD,
217- actualCostUSD: actualCostUSD,
218- totalTokens: entry? . totalTokens,
219- hasUsage: hasUsage,
220- isPlaceholder: !hasVisibleCost)
166+ let costUSD = max ( 0 , entry? . costUSD ?? 0 )
167+ let point = Point ( date: item. date, costUSD: costUSD, totalTokens: entry? . totalTokens)
221168 points. append ( point)
222169 pointsByKey [ item. key] = point
223170 dateKeys. append ( ( item. key, item. date) )
224- if let entry {
225- maxDetailLineCount = max ( maxDetailLineCount, Self . detailLineCount ( for: entry) )
226- }
227-
228- if let actualCostUSD, actualCostUSD > 0 {
171+ if costUSD > 0 {
229172 if let cur = peak {
230- if actualCostUSD > cur. costUSD { peak = ( item. key, actualCostUSD ) }
173+ if costUSD > cur. costUSD { peak = ( item. key, costUSD ) }
231174 } else {
232- peak = ( item. key, actualCostUSD )
175+ peak = ( item. key, costUSD )
233176 }
177+ maxCostUSD = max ( maxCostUSD, costUSD)
234178 }
235179 }
236180
@@ -240,8 +184,6 @@ struct CostHistoryChartMenuView: View {
240184 return [ first, last]
241185 } ( )
242186
243- let chartMaxCostUSD = max ( maxCostUSD, minimumVisibleCostUSD * 8 , 1 )
244-
245187 let barColor = Self . barColor ( for: provider)
246188 return Model (
247189 points: points,
@@ -251,10 +193,7 @@ struct CostHistoryChartMenuView: View {
251193 axisDates: axisDates,
252194 barColor: barColor,
253195 peakKey: peak? . key,
254- maxCostUSD: maxCostUSD,
255- minimumVisibleCostUSD: minimumVisibleCostUSD,
256- chartMaxCostUSD: chartMaxCostUSD,
257- maxDetailLineCount: maxDetailLineCount)
196+ maxCostUSD: maxCostUSD)
258197 }
259198
260199 private static func barColor( for provider: UsageProvider ) -> Color {
@@ -303,11 +242,6 @@ struct CostHistoryChartMenuView: View {
303242 return model. pointsByDateKey [ key]
304243 }
305244
306- private static func detailLineCount( for entry: DailyEntry ) -> Int {
307- guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return 1 }
308- return breakdown. count
309- }
310-
311245 private static func hasUsage( entry: DailyEntry ) -> Bool {
312246 if let totalTokens = entry. totalTokens, totalTokens > 0 {
313247 return true
@@ -397,98 +331,73 @@ struct CostHistoryChartMenuView: View {
397331 return best? . key
398332 }
399333
400- private func detailContent ( model: Model ) -> DetailContent {
334+ private func detailLines ( model: Model ) -> ( primary : String , secondary : String ? ) {
401335 guard let key = self . selectedDateKey,
402336 let point = model. pointsByDateKey [ key] ,
403337 let date = Self . dateFromDayKey ( key)
404338 else {
405- return DetailContent ( primary : " Hover a bar for details " , models : [ ] )
339+ return ( " Hover a bar for details " , nil )
406340 }
407341
408342 let dayLabel = date. formatted ( . dateTime. month ( . abbreviated) . day ( ) )
409- let partial = self . hasUnpricedModels ( key: key, model: model) ? " partial " : " "
410- let models = self . topModelLines ( key: key, model: model)
411- let primary = if let actualCostUSD = point. actualCostUSD, actualCostUSD > 0 {
412- " \( dayLabel) : \( UsageFormatter . usdString ( actualCostUSD) ) \( partial) "
413- } else if point. hasUsage {
343+ let primary = if let entry = model. entriesByDateKey [ key] , let costUSD = entry. costUSD, costUSD > 0 {
344+ " \( dayLabel) : \( UsageFormatter . usdString ( costUSD) ) "
345+ } else if let entry = model. entriesByDateKey [ key] , Self . hasUsage ( entry: entry) {
414346 " \( dayLabel) : No priced cost data "
415347 } else {
416348 " \( dayLabel) : No cost data "
417349 }
418350
419351 if let tokens = point. totalTokens {
420- return DetailContent (
421- primary: " \( primary) · \( UsageFormatter . tokenCountString ( tokens) ) tokens " ,
422- models: models)
423- }
424- return DetailContent ( primary: primary, models: models)
425- }
426-
427- private func hasUnpricedModels( key: String , model: Model ) -> Bool {
428- guard let entry = model. entriesByDateKey [ key] ,
429- let breakdown = entry. modelBreakdowns
430- else {
431- return false
352+ let withTokens = " \( primary) · \( UsageFormatter . tokenCountString ( tokens) ) tokens "
353+ let secondary = self . topModelsText ( key: key, model: model)
354+ return ( withTokens, secondary)
432355 }
433- return breakdown. contains { $0. costUSD == nil }
356+ let secondary = self . topModelsText ( key: key, model: model)
357+ return ( primary, secondary)
434358 }
435359
436- private func topModelLines ( key: String , model: Model ) -> [ DetailModelLine ] {
437- guard let entry = model. entriesByDateKey [ key] else { return [ ] }
438- guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return [ ] }
360+ private func topModelsText ( key: String , model: Model ) -> String ? {
361+ guard let entry = model. entriesByDateKey [ key] else { return nil }
362+ guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return nil }
439363 let parts = breakdown
440- . map { item in
441- (
442- name: UsageFormatter . modelDisplayName ( item. modelName) ,
443- costUSD: item. costUSD)
364+ . compactMap { item -> ( name: String , costUSD: Double ) ? in
365+ guard let costUSD = item. costUSD, costUSD > 0 else { return nil }
366+ return ( UsageFormatter . modelDisplayName ( item. modelName) , costUSD)
444367 }
445368 . sorted { lhs, rhs in
446- lhs. name. localizedStandardCompare ( rhs. name) == . orderedAscending
369+ if lhs. costUSD == rhs. costUSD { return lhs. name < rhs. name }
370+ return lhs. costUSD > rhs. costUSD
447371 }
448- . map { item in
449- if let costUSD = item. costUSD, costUSD > 0 {
450- return DetailModelLine (
451- id: item. name,
452- text: " \( item. name) : \( UsageFormatter . usdString ( costUSD) ) " )
453- }
454- let suffix = item. name == " GPT-5.3 Spark " ? " Research Preview " : " unpriced "
455- return DetailModelLine ( id: item. name, text: " \( item. name) : \( suffix) " )
456- }
457- return Array ( parts)
372+ . prefix ( 3 )
373+ . map { " \( $0. name) \( UsageFormatter . usdString ( $0. costUSD) ) " }
374+ guard !parts. isEmpty else { return nil }
375+ return " Top: \( parts. joined ( separator: " · " ) ) "
458376 }
459377}
460378
461379extension CostHistoryChartMenuView {
462380 enum TestSupport {
463- struct SnapshotPoint : Equatable {
381+ struct DayState : Equatable {
464382 let dayKey : String
465- let displayCostUSD : Double
466- let actualCostUSD : Double ?
467- let hasUsage : Bool
468- let isPlaceholder : Bool
469- }
470-
471- struct Snapshot : Equatable {
472- let points : [ SnapshotPoint ]
383+ let costUSD : Double
384+ let hasEntry : Bool
473385 }
474386
475387 @MainActor
476- static func makeSnapshot (
388+ static func makeDayStates (
477389 provider: UsageProvider = . codex,
478390 daily: [ DailyEntry ] ,
479- now: Date ) -> Snapshot
391+ now: Date ) -> [ DayState ]
480392 {
481393 let model = CostHistoryChartMenuView . makeModel ( provider: provider, daily: daily, now: now)
482- let points = model. dateKeys. compactMap { item -> SnapshotPoint ? in
394+ return model. dateKeys. compactMap { item -> DayState ? in
483395 guard let point = model. pointsByDateKey [ item. key] else { return nil }
484- return SnapshotPoint (
396+ return DayState (
485397 dayKey: item. key,
486- displayCostUSD: point. displayCostUSD,
487- actualCostUSD: point. actualCostUSD,
488- hasUsage: point. hasUsage,
489- isPlaceholder: point. isPlaceholder)
398+ costUSD: point. costUSD,
399+ hasEntry: model. entriesByDateKey [ item. key] != nil )
490400 }
491- return Snapshot ( points: points)
492401 }
493402 }
494403}
0 commit comments