Skip to content

Commit 6b9be17

Browse files
committed
Add daily spend ledger
1 parent 23b2a1c commit 6b9be17

3 files changed

Lines changed: 608 additions & 16 deletions

File tree

Sources/CodexBar/PreferencesSpendDashboardPane.swift

Lines changed: 271 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
3044
enum SpendDashboardModelHistoryPresentation: Equatable {
3145
case unavailable
3246
case empty
@@ -229,6 +243,7 @@ struct SpendDashboardPane: View {
229243
private 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+
294528
private struct SpendProviderPanel: View {
295529
let group: SpendDashboardModel.CurrencyGroup
296530

@@ -415,6 +649,7 @@ struct SpendDailyChartPresentation: Equatable {
415649

416650
private 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

Comments
 (0)