Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AIBattery/Models/ModelPricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ struct ModelPricing {
}
if hit.found { return hit.value }

// Compute outside the lock — avoids nested lock with ModelNameMapper
// Compute outside the lock — avoids nested lock with ModelNameMapper.
// Linear scan over 6 entries is fine — result is cached per modelId above.
let display = ModelNameMapper.displayName(for: modelId).lowercased()
var result: ModelPricing?
for (key, pricing) in pricingTable {
Expand Down
16 changes: 8 additions & 8 deletions AIBattery/Models/UsageSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ struct UsageSnapshot {
let maxUrgency = scored.map(\.1).max() ?? 0
// Among tied modes, prefer context > 5h > 7d (most actionable first)
let tiePriority: [MetricMode] = [.contextHealth, .fiveHour, .sevenDay]
return scored
let winner = scored
.filter { $0.1 == maxUrgency }
.min { (tiePriority.firstIndex(of: $0.0) ?? .max) < (tiePriority.firstIndex(of: $1.0) ?? .max) }!
.0
.min { (tiePriority.firstIndex(of: $0.0) ?? .max) < (tiePriority.firstIndex(of: $1.0) ?? .max) }
return winner?.0 ?? .fiveHour
}

// MARK: - Urgency scoring
Expand All @@ -114,11 +114,11 @@ struct UsageSnapshot {
return interpolate(percent, anchors: anchors)
}

/// Piecewise-linear interpolation. Values below first anchor clamp to 0, above last to 1.
/// Piecewise-linear interpolation. Values below first anchor clamp to first y, above last to last y.
private static func interpolate(_ value: Double, anchors: [(Double, Double)]) -> Double {
guard !anchors.isEmpty else { return 0 }
if value <= anchors.first!.0 { return anchors.first!.1 }
if value >= anchors.last!.0 { return anchors.last!.1 }
guard let first = anchors.first, let last = anchors.last else { return 0 }
if value <= first.0 { return first.1 }
if value >= last.0 { return last.1 }
for i in 0..<(anchors.count - 1) {
let (x0, y0) = anchors[i]
let (x1, y1) = anchors[i + 1]
Expand All @@ -127,7 +127,7 @@ struct UsageSnapshot {
return y0 + t * (y1 - y0)
}
}
return anchors.last!.1
return last.1
}

// Daily activity for chart
Expand Down
94 changes: 42 additions & 52 deletions AIBattery/Views/ActivityChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,25 +237,16 @@ struct ActivityChartView: View {
.chartYAxis { sharedYAxis }
.chartPlotStyle { plot in plot.background(.clear) }
.chartOverlay { proxy in
GeometryReader { geo in
Rectangle().fill(.clear).contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
let origin = geo[proxy.plotAreaFrame].origin
let x = location.x - origin.x
if let date: Date = proxy.value(atX: x) {
let cal = Calendar.current
selectedDailyId = data
.min(by: {
abs(cal.dateComponents([.hour], from: $0.date, to: date).hour ?? .max)
< abs(cal.dateComponents([.hour], from: $1.date, to: date).hour ?? .max)
})?.id
}
case .ended:
selectedDailyId = nil
}
}
chartHoverOverlay(proxy: proxy) { x in
guard let date: Date = proxy.value(atX: x) else { return }
let cal = Calendar.current
selectedDailyId = data
.min(by: {
abs(cal.dateComponents([.hour], from: $0.date, to: date).hour ?? .max)
< abs(cal.dateComponents([.hour], from: $1.date, to: date).hour ?? .max)
})?.id
} onEnd: {
selectedDailyId = nil
}
}
.frame(height: 50)
Expand Down Expand Up @@ -324,20 +315,11 @@ struct ActivityChartView: View {
.chartYAxis { sharedYAxis }
.chartPlotStyle { plot in plot.background(.clear) }
.chartOverlay { proxy in
GeometryReader { geo in
Rectangle().fill(.clear).contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
let origin = geo[proxy.plotAreaFrame].origin
let x = location.x - origin.x
if let value: Double = proxy.value(atX: x) {
selectedHourlyOffset = max(0, min(11, Int(value.rounded())))
}
case .ended:
selectedHourlyOffset = nil
}
}
chartHoverOverlay(proxy: proxy) { x in
guard let value: Double = proxy.value(atX: x) else { return }
selectedHourlyOffset = max(0, min(11, Int(value.rounded())))
} onEnd: {
selectedHourlyOffset = nil
}
}
.frame(height: 50)
Expand Down Expand Up @@ -408,25 +390,16 @@ struct ActivityChartView: View {
.chartYAxis { sharedYAxis }
.chartPlotStyle { plot in plot.background(.clear) }
.chartOverlay { proxy in
GeometryReader { geo in
Rectangle().fill(.clear).contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
let origin = geo[proxy.plotAreaFrame].origin
let x = location.x - origin.x
if let date: Date = proxy.value(atX: x) {
let cal = Calendar.current
selectedMonthlyId = data
.min(by: {
abs(cal.dateComponents([.day], from: $0.date, to: date).day ?? .max)
< abs(cal.dateComponents([.day], from: $1.date, to: date).day ?? .max)
})?.id
}
case .ended:
selectedMonthlyId = nil
}
}
chartHoverOverlay(proxy: proxy) { x in
guard let date: Date = proxy.value(atX: x) else { return }
let cal = Calendar.current
selectedMonthlyId = data
.min(by: {
abs(cal.dateComponents([.day], from: $0.date, to: date).day ?? .max)
< abs(cal.dateComponents([.day], from: $1.date, to: date).day ?? .max)
})?.id
} onEnd: {
selectedMonthlyId = nil
}
}
.frame(height: 50)
Expand Down Expand Up @@ -653,6 +626,23 @@ struct ActivityChartView: View {
/// Shared RuleMark styling for hover selection indicators.
private static let selectionRuleStyle = StrokeStyle(lineWidth: 0.5, dash: [3, 3])

/// Shared chart overlay that converts mouse location to plot-area X offset.
/// Eliminates the duplicated GeometryReader + Rectangle + onContinuousHover boilerplate.
private func chartHoverOverlay(proxy: ChartProxy, onHover: @escaping (CGFloat) -> Void, onEnd: @escaping () -> Void) -> some View {
GeometryReader { geo in
Rectangle().fill(.clear).contentShape(Rectangle())
.onContinuousHover { phase in
switch phase {
case .active(let location):
let origin = geo[proxy.plotAreaFrame].origin
onHover(location.x - origin.x)
case .ended:
onEnd()
}
}
}
}

// MARK: - Formatters

static func dayShortLabel(_ date: Date) -> String {
Expand Down
30 changes: 20 additions & 10 deletions AIBattery/Views/TokenUsageSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,27 @@ struct TokenUsageSection: View {
"cpu", "bolt", "sparkles", "cube", "wand.and.stars"
]

/// Sort: active model first, then by total tokens descending.
private var sortedTokens: [ModelTokenSummary] {
guard let activeId = activeModelId, !activeId.isEmpty else {
return snapshot.modelTokens
/// Index of the active model in snapshot.modelTokens, or nil if none active.
/// Used to display active model first without copying the array on every render.
private var activeModelIndex: Int? {
guard let activeId = activeModelId, !activeId.isEmpty else { return nil }
return snapshot.modelTokens.firstIndex(where: { activeId.hasPrefix($0.id) || $0.id.hasPrefix(activeId) })
}

/// Iteration order: active model first, then the rest in original order.
/// Returns (index, model) pairs without allocating a new array.
private var orderedTokens: [(offset: Int, element: ModelTokenSummary)] {
let tokens = snapshot.modelTokens
guard let activeIdx = activeModelIndex else {
return Array(tokens.enumerated())
}
var sorted = snapshot.modelTokens
if let idx = sorted.firstIndex(where: { activeId.hasPrefix($0.id) || $0.id.hasPrefix(activeId) }) {
let active = sorted.remove(at: idx)
sorted.insert(active, at: 0)
var result = [(offset: Int, element: ModelTokenSummary)]()
result.reserveCapacity(tokens.count)
result.append((activeIdx, tokens[activeIdx]))
for (i, model) in tokens.enumerated() where i != activeIdx {
result.append((i, model))
}
return sorted
return result
}

private func isActive(_ model: ModelTokenSummary) -> Bool {
Expand All @@ -40,7 +50,7 @@ struct TokenUsageSection: View {

// Per-model breakdown with token types underneath
if !collapsed && !snapshot.modelTokens.isEmpty {
ForEach(Array(sortedTokens.enumerated()), id: \.element.id) { index, model in
ForEach(orderedTokens, id: \.element.id) { index, model in
modelRow(model, index: index)
}
}
Expand Down
Loading