@@ -27,6 +27,20 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String {
2727 " \( L ( " Coverage " ) ) : \( codexBarLocalizedInteger ( covered) ) / \( codexBarLocalizedInteger ( requested) ) "
2828}
2929
30+ func spendDashboardSelectedDailySummary(
31+ selectedDay: Date ? ,
32+ summaries: [ SpendDashboardModel . DailySummary ] ,
33+ calendar: Calendar = . current) -> SpendDashboardModel . DailySummary ?
34+ {
35+ guard let selectedDay else { return summaries. last }
36+ if let exact = summaries. first ( where: { calendar. isDate ( $0. day, inSameDayAs: selectedDay) } ) {
37+ return exact
38+ }
39+ return summaries. min {
40+ abs ( $0. day. timeIntervalSince ( selectedDay) ) < abs ( $1. day. timeIntervalSince ( selectedDay) )
41+ }
42+ }
43+
3044enum SpendDashboardModelHistoryPresentation : Equatable {
3145 case unavailable
3246 case empty
@@ -229,6 +243,7 @@ struct SpendDashboardPane: View {
229243private struct SpendCurrencySection : View {
230244 let group : SpendDashboardModel . CurrencyGroup
231245 let requestedDays : Int
246+ @State private var selectedDay : Date ?
232247
233248 var body : some View {
234249 VStack ( alignment: . leading, spacing: 12 ) {
@@ -268,10 +283,36 @@ private struct SpendCurrencySection: View {
268283 }
269284 }
270285
286+ SpendDailyChart (
287+ group: self . group,
288+ selectedDay: self . $selectedDay)
289+ if let selectedSummary = self . selectedSummary {
290+ SpendSelectedDayPanel (
291+ summary: selectedSummary,
292+ currencyCode: self . group. currencyCode)
293+ }
294+ SpendDailyLedger (
295+ group: self . group,
296+ selectedDay: self . $selectedDay)
271297 SpendProviderPanel ( group: self . group)
272298 SpendModelPanel ( group: self . group)
273- SpendDailyChart ( group: self . group)
274299 }
300+ . onAppear {
301+ self . normalizeSelection ( )
302+ }
303+ . onChange ( of: self . group. dailySummaries) { _, _ in
304+ self . normalizeSelection ( )
305+ }
306+ }
307+
308+ private var selectedSummary : SpendDashboardModel . DailySummary ? {
309+ spendDashboardSelectedDailySummary (
310+ selectedDay: self . selectedDay,
311+ summaries: self . group. dailySummaries)
312+ }
313+
314+ private func normalizeSelection( ) {
315+ self . selectedDay = self . selectedSummary? . day
275316 }
276317}
277318
@@ -291,6 +332,199 @@ private struct SpendSummaryValue: View {
291332 }
292333}
293334
335+ private struct SpendSelectedDayPanel : View {
336+ let summary : SpendDashboardModel . DailySummary
337+ let currencyCode : String
338+
339+ var body : some View {
340+ SpendDashboardPanel {
341+ VStack ( alignment: . leading, spacing: 14 ) {
342+ HStack ( alignment: . firstTextBaseline) {
343+ VStack ( alignment: . leading, spacing: 3 ) {
344+ Text ( L ( " Day " ) )
345+ . font ( . caption)
346+ . foregroundStyle ( . secondary)
347+ Text ( self . summary. day, format: . dateTime. weekday ( . wide) . day ( ) . month ( . wide) . year ( ) )
348+ . font ( . headline)
349+ }
350+ Spacer ( )
351+ Text ( UsageFormatter . currencyString (
352+ self . summary. totalCost,
353+ currencyCode: self . currencyCode) )
354+ . font ( . title2. weight ( . semibold) )
355+ . monospacedDigit ( )
356+ }
357+
358+ HStack ( spacing: 24 ) {
359+ SpendSummaryValue (
360+ title: L ( " Tracked tokens " ) ,
361+ value: self . summary. totalTokens. map ( UsageFormatter . tokenCountString) ?? " — " )
362+ SpendSummaryValue (
363+ title: L ( " Requests " ) ,
364+ value: self . summary. requestCount. map ( codexBarLocalizedInteger) ?? " — " )
365+ SpendSummaryValue (
366+ title: L ( " Providers " ) ,
367+ value: codexBarLocalizedInteger ( self . activeProviders. count) )
368+ Spacer ( )
369+ }
370+
371+ Divider ( )
372+ VStack ( alignment: . leading, spacing: 0 ) {
373+ ForEach ( self . summary. providers) { row in
374+ if row. id != self . summary. providers. first? . id {
375+ Divider ( )
376+ }
377+ HStack ( spacing: 10 ) {
378+ SpendProviderIcon ( provider: row. provider)
379+ VStack ( alignment: . leading, spacing: 2 ) {
380+ Text ( row. displayName)
381+ Text ( self . usageLine ( row) )
382+ . font ( . caption)
383+ . foregroundStyle ( . secondary)
384+ }
385+ Spacer ( )
386+ Text ( UsageFormatter . currencyString (
387+ row. totalCost,
388+ currencyCode: self . currencyCode) )
389+ . monospacedDigit ( )
390+ }
391+ . padding ( . vertical, 7 )
392+ . accessibilityElement ( children: . combine)
393+ }
394+ }
395+ }
396+ }
397+ }
398+
399+ private var activeProviders : [ SpendDashboardModel . DailyProviderRow ] {
400+ self . summary. providers. filter {
401+ $0. totalCost > 0 || ( $0. totalTokens ?? 0 ) > 0 || ( $0. requestCount ?? 0 ) > 0
402+ }
403+ }
404+
405+ private func usageLine( _ row: SpendDashboardModel . DailyProviderRow ) -> String {
406+ let tokens = row. totalTokens. map ( UsageFormatter . tokenCountString) ?? " — "
407+ let requests = row. requestCount. map ( codexBarLocalizedInteger) ?? " — "
408+ return " \( tokens) \( L ( " tokens " ) ) · \( requests) \( L ( " requests " ) ) "
409+ }
410+ }
411+
412+ private struct SpendDailyLedger : View {
413+ let group : SpendDashboardModel . CurrencyGroup
414+ @Binding var selectedDay : Date ?
415+
416+ var body : some View {
417+ SpendDashboardPanel {
418+ VStack ( alignment: . leading, spacing: 0 ) {
419+ HStack {
420+ Text ( L ( " Daily estimated spend " ) ) . font ( . headline)
421+ Spacer ( )
422+ }
423+ . padding ( . bottom, 10 )
424+
425+ if self . group. dailySummaries. isEmpty {
426+ ContentUnavailableView (
427+ L ( " Spend unavailable " ) ,
428+ systemImage: " calendar.badge.exclamationmark " )
429+ . frame ( maxWidth: . infinity, minHeight: 120 )
430+ } else {
431+ self . header
432+ Divider ( )
433+ LazyVStack ( spacing: 0 ) {
434+ ForEach ( Array ( self . group. dailySummaries. reversed ( ) ) ) { summary in
435+ Button {
436+ self . selectedDay = summary. day
437+ } label: {
438+ self . row ( summary)
439+ }
440+ . buttonStyle ( . plain)
441+ . accessibilityLabel ( self . accessibilityLabel ( summary) )
442+ if summary. id != self . group. dailySummaries. first? . id {
443+ Divider ( )
444+ }
445+ }
446+ }
447+ }
448+ }
449+ }
450+ }
451+
452+ private var header : some View {
453+ HStack ( spacing: 12 ) {
454+ Text ( L ( " Day " ) ) . frame ( width: 132 , alignment: . leading)
455+ Text ( L ( " Providers " ) ) . frame ( minWidth: 120 , maxWidth: . infinity, alignment: . leading)
456+ Text ( L ( " Tracked tokens " ) ) . frame ( width: 90 , alignment: . trailing)
457+ Text ( L ( " Requests " ) ) . frame ( width: 72 , alignment: . trailing)
458+ Text ( L ( " Estimated spend " ) ) . frame ( width: 116 , alignment: . trailing)
459+ }
460+ . font ( . caption)
461+ . foregroundStyle ( . secondary)
462+ . padding ( . horizontal, 8 )
463+ . padding ( . vertical, 6 )
464+ }
465+
466+ private func row( _ summary: SpendDashboardModel . DailySummary ) -> some View {
467+ HStack ( spacing: 12 ) {
468+ Text ( summary. day, format: . dateTime. weekday ( . abbreviated) . day ( ) . month ( . abbreviated) )
469+ . frame ( width: 132 , alignment: . leading)
470+ self . providerMix ( summary)
471+ . frame ( minWidth: 120 , maxWidth: . infinity, alignment: . leading)
472+ Text ( summary. totalTokens. map ( UsageFormatter . tokenCountString) ?? " — " )
473+ . frame ( width: 90 , alignment: . trailing)
474+ Text ( summary. requestCount. map ( codexBarLocalizedInteger) ?? " — " )
475+ . frame ( width: 72 , alignment: . trailing)
476+ Text ( UsageFormatter . currencyString (
477+ summary. totalCost,
478+ currencyCode: self . group. currencyCode) )
479+ . fontWeight ( . medium)
480+ . frame ( width: 116 , alignment: . trailing)
481+ }
482+ . monospacedDigit ( )
483+ . padding ( . horizontal, 8 )
484+ . padding ( . vertical, 10 )
485+ . contentShape ( Rectangle ( ) )
486+ . background (
487+ self . isSelected ( summary) ? Color . accentColor. opacity ( 0.12 ) : Color . clear,
488+ in: RoundedRectangle ( cornerRadius: 8 , style: . continuous) )
489+ }
490+
491+ @ViewBuilder
492+ private func providerMix( _ summary: SpendDashboardModel . DailySummary ) -> some View {
493+ let active = summary. providers. filter {
494+ $0. totalCost > 0 || ( $0. totalTokens ?? 0 ) > 0 || ( $0. requestCount ?? 0 ) > 0
495+ }
496+ if active. isEmpty {
497+ Text ( L ( " No usage yet " ) )
498+ . font ( . caption)
499+ . foregroundStyle ( . secondary)
500+ } else {
501+ HStack ( spacing: 5 ) {
502+ ForEach ( active. prefix ( 4 ) ) { row in
503+ SpendProviderIcon ( provider: row. provider)
504+ . help ( row. displayName)
505+ }
506+ if active. count > 4 {
507+ Text ( " + \( codexBarLocalizedInteger ( active. count - 4 ) ) " )
508+ . font ( . caption)
509+ . foregroundStyle ( . secondary)
510+ }
511+ }
512+ }
513+ }
514+
515+ private func isSelected( _ summary: SpendDashboardModel . DailySummary ) -> Bool {
516+ guard let selectedDay else { return false }
517+ return Calendar . current. isDate ( summary. day, inSameDayAs: selectedDay)
518+ }
519+
520+ private func accessibilityLabel( _ summary: SpendDashboardModel . DailySummary ) -> String {
521+ let day = summary. day. formatted (
522+ . dateTime. weekday ( . wide) . day ( ) . month ( . wide) . year ( ) . locale ( codexBarLocalizedLocale ( ) ) )
523+ let spend = UsageFormatter . currencyString ( summary. totalCost, currencyCode: self . group. currencyCode)
524+ return " \( day) , \( spend) "
525+ }
526+ }
527+
294528private struct SpendProviderPanel : View {
295529 let group : SpendDashboardModel . CurrencyGroup
296530
@@ -415,6 +649,7 @@ struct SpendDailyChartPresentation: Equatable {
415649
416650private struct SpendDailyChart : View {
417651 let group : SpendDashboardModel . CurrencyGroup
652+ @Binding var selectedDay : Date ?
418653
419654 var body : some View {
420655 let presentation = SpendDailyChartPresentation (
@@ -427,17 +662,24 @@ private struct SpendDailyChart: View {
427662 ContentUnavailableView ( L ( " Spend unavailable " ) , systemImage: " chart.bar.xaxis " )
428663 . frame ( maxWidth: . infinity, minHeight: 170 )
429664 } else {
430- Chart ( self . group. dailyPoints) { point in
431- BarMark (
432- x: . value( L ( " Day " ) , point. day, unit: . day) ,
433- yStart: . value( L ( " Estimated spend " ) , point. stackStart) ,
434- yEnd: . value( L ( " Estimated spend " ) , point. stackEnd) ,
435- width: . ratio( 0.72 ) )
436- . foregroundStyle ( by: . value( L ( " Provider " ) , point. providerName) )
437- . accessibilityLabel ( Text ( self . pointAccessibilityLabel ( point) ) )
438- . accessibilityValue ( Text ( UsageFormatter . currencyString (
439- point. cost,
440- currencyCode: self . group. currencyCode) ) )
665+ Chart {
666+ ForEach ( self . group. dailyPoints) { point in
667+ BarMark (
668+ x: . value( L ( " Day " ) , point. day, unit: . day) ,
669+ yStart: . value( L ( " Estimated spend " ) , point. stackStart) ,
670+ yEnd: . value( L ( " Estimated spend " ) , point. stackEnd) ,
671+ width: . ratio( 0.72 ) )
672+ . foregroundStyle ( by: . value( L ( " Provider " ) , point. providerName) )
673+ . accessibilityLabel ( Text ( self . pointAccessibilityLabel ( point) ) )
674+ . accessibilityValue ( Text ( UsageFormatter . currencyString (
675+ point. cost,
676+ currencyCode: self . group. currencyCode) ) )
677+ }
678+ if let selectedSummary = self . selectedSummary {
679+ RuleMark ( x: . value( L ( " Selected day " ) , selectedSummary. day, unit: . day) )
680+ . foregroundStyle ( . primary. opacity ( 0.45 ) )
681+ . lineStyle ( StrokeStyle ( lineWidth: 1 , dash: [ 3 , 3 ] ) )
682+ }
441683 }
442684 . chartXScale ( domain: self . group. chartDomain)
443685 . chartForegroundStyleScale (
@@ -459,11 +701,28 @@ private struct SpendDailyChart: View {
459701 . frame ( height: 170 )
460702 . accessibilityLabel ( L ( " Daily estimated spend " ) )
461703 . accessibilityValue ( presentation. accessibilityValue)
704+ . chartXSelection ( value: self . chartSelection)
462705 }
463706 }
464707 }
465708 }
466709
710+ private var selectedSummary : SpendDashboardModel . DailySummary ? {
711+ spendDashboardSelectedDailySummary (
712+ selectedDay: self . selectedDay,
713+ summaries: self . group. dailySummaries)
714+ }
715+
716+ private var chartSelection : Binding < Date ? > {
717+ Binding (
718+ get: { self . selectedSummary? . day } ,
719+ set: { value in
720+ self . selectedDay = spendDashboardSelectedDailySummary (
721+ selectedDay: value,
722+ summaries: self . group. dailySummaries) ? . day
723+ } )
724+ }
725+
467726 private func pointAccessibilityLabel( _ point: SpendDashboardModel . DailyPoint ) -> String {
468727 let day = point. day. formatted (
469728 . dateTime. month ( . abbreviated) . day ( ) . locale ( codexBarLocalizedLocale ( ) ) )
0 commit comments