Skip to content

Commit 970d0ed

Browse files
committed
Limit cost chart to day backfill
1 parent 861193b commit 970d0ed

2 files changed

Lines changed: 86 additions & 182 deletions

File tree

Sources/CodexBar/CostHistoryChartMenuView.swift

Lines changed: 64 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -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

461379
extension 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

Comments
 (0)