diff --git a/CHANGELOG.md b/CHANGELOG.md index d77b382503..b174b41634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Menu: apply the cost summary display style to every provider's menu card, so Submenu only hides inline cost rows for z.ai and other providers (#2976). Thanks @ar0nbg! +- Cost history: align x-axis date labels with their bars in status-menu charts (#2974). Thanks @Yuxin-Qiao! - Codex: keep CLI-owned `auth.json` read-only during usage refresh, delegate stale native credentials to CLI recovery, and fail closed for stale external OAuth files (#2944). Thanks @Yuxin-Qiao! - Usage & Spend: keep safely priced Codex totals visible after completed history scans when request-tier uncertainty leaves some days unpriced (#2948). Thanks @Atopoz for the report! - Vertex AI: match Cloud Monitoring quota usage without a `limit_name` to its unambiguous same-metric, same-location limit, restoring quota percentages (#2958). Thanks @MachApple! diff --git a/Sources/CodexBar/ChartAxisLabelLayout.swift b/Sources/CodexBar/ChartAxisLabelLayout.swift new file mode 100644 index 0000000000..bc82d550e0 --- /dev/null +++ b/Sources/CodexBar/ChartAxisLabelLayout.swift @@ -0,0 +1,16 @@ +import SwiftUI + +enum ChartAxisLabelLayout { + /// Keeps the label's horizontal center on the x-axis value shared with its bar. + static let barCenteredAnchor = UnitPoint.top + + static func barCenterX(slotIndex: Int, slotCount: Int, chartWidth: CGFloat) -> CGFloat? { + guard slotCount > 0, (0..= 0 else { return nil } + let slotWidth = chartWidth / CGFloat(slotCount) + return (CGFloat(slotIndex) + 0.5) * slotWidth + } + + static func labelCenterX(tickX: CGFloat, labelWidth: CGFloat, anchor: UnitPoint) -> CGFloat { + tickX + (0.5 - anchor.x) * labelWidth + } +} diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index d104d2dd05..4dde51476d 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -7,12 +7,6 @@ import SwiftUI struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry - enum AxisLabelPlacement: Equatable { - case hidden - case centered - case edges - } - enum ChartMetric: CaseIterable, Hashable { case tokens case cost @@ -187,7 +181,7 @@ struct CostHistoryChartMenuView: View { AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) if let date = value.as(Date.self) { - AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + AxisValueLabel(anchor: ChartAxisLabelLayout.barCenteredAnchor) { Text(date, format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) @@ -614,29 +608,6 @@ struct CostHistoryChartMenuView: View { detailRowHeight: detailLayout.rowHeight) } - private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { - switch dates.count { - case 0: .hidden - case 1: .centered - default: .edges - } - } - - private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { - switch self.axisLabelPlacement(for: axisDates) { - case .hidden, .centered: - .top - case .edges: - if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { - .topLeading - } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { - .topTrailing - } else { - .top - } - } - } - private static func barColor(for provider: UsageProvider) -> Color { let color = ProviderAccentPalette.color(for: provider) return Color(red: color.red, green: color.green, blue: color.blue) @@ -1237,16 +1208,6 @@ extension CostHistoryChartMenuView { metric: self.defaultMetric(provider: provider, daily: daily)).axisDates } - static func _axisLabelPlacementForTesting( - provider: UsageProvider, - daily: [DailyEntry]) -> AxisLabelPlacement - { - self.axisLabelPlacement(for: self.makeModel( - provider: provider, - daily: daily, - metric: self.defaultMetric(provider: provider, daily: daily)).axisDates) - } - static func _yAxisTickValuesForTesting(maxCostUSD: Double) -> [Double] { self.yAxisTickValues(maxCostUSD: maxCostUSD) } diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 56693b0d35..8d1eca3a08 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -131,16 +131,13 @@ struct PlanUtilizationHistoryChartMenuView: View { AxisMarks(values: model.axisIndexes) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) - AxisValueLabel { + AxisValueLabel(anchor: ChartAxisLabelLayout.barCenteredAnchor) { if let raw = value.as(Double.self) { let index = Int(raw.rounded()) if let point = model.pointsByIndex[index] { - let isTrailingFullChartLabel = index == model.points.last?.index - && model.points.count == Layout.maxPoints Self.axisLabel( for: point, - windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0, - isTrailingFullChartLabel: isTrailingFullChartLabel) + windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0) } } } @@ -606,23 +603,13 @@ struct PlanUtilizationHistoryChartMenuView: View { return deduplicated.map(Double.init) } - @ViewBuilder private static func axisLabel( for point: Point, - windowMinutes: Int, - isTrailingFullChartLabel: Bool) -> some View + windowMinutes: Int) -> some View { - let label = Text(point.date.formatted(self.axisFormat(windowMinutes: windowMinutes))) + Text(point.date.formatted(self.axisFormat(windowMinutes: windowMinutes))) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - - if isTrailingFullChartLabel { - label - .frame(width: 48, alignment: .trailing) - .offset(x: -24) - } else { - label - } } private nonisolated static func axisFormat(windowMinutes: Int) -> Date.FormatStyle { diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index 75b7aefd07..1f309c43cb 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -91,7 +91,7 @@ struct UsageBreakdownChartMenuView: View { AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) if let date = value.as(Date.self) { - AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + AxisValueLabel(anchor: ChartAxisLabelLayout.barCenteredAnchor) { Text(date, format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) @@ -323,16 +323,6 @@ struct UsageBreakdownChartMenuView: View { return [firstDate, lastDate] } - private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { - if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { - return .topLeading - } - if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { - return .topTrailing - } - return .top - } - private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, diff --git a/Tests/CodexBarTests/ChartAxisLabelLayoutTests.swift b/Tests/CodexBarTests/ChartAxisLabelLayoutTests.swift new file mode 100644 index 0000000000..afd4c66652 --- /dev/null +++ b/Tests/CodexBarTests/ChartAxisLabelLayoutTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CodexBar + +struct ChartAxisLabelLayoutTests { + @Test + func `first date label center matches first bar center`() throws { + let barCenter = try #require(ChartAxisLabelLayout.barCenterX( + slotIndex: 0, + slotCount: 16, + chartWidth: 480)) + let labelCenter = ChartAxisLabelLayout.labelCenterX( + tickX: barCenter, + labelWidth: 44, + anchor: ChartAxisLabelLayout.barCenteredAnchor) + + #expect(abs(labelCenter - barCenter) < 0.0001) + } + + @Test + func `last date label center matches last bar center`() throws { + let barCenter = try #require(ChartAxisLabelLayout.barCenterX( + slotIndex: 15, + slotCount: 16, + chartWidth: 480)) + let labelCenter = ChartAxisLabelLayout.labelCenterX( + tickX: barCenter, + labelWidth: 64, + anchor: ChartAxisLabelLayout.barCenteredAnchor) + + #expect(abs(labelCenter - barCenter) < 0.0001) + } +} diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index a88700b726..f9906afc31 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -349,10 +349,6 @@ struct CostHistoryChartMenuViewTests { #expect(cal.component(.day, from: dates[0]) == 21) #expect(cal.component(.month, from: dates[1]) == 6) #expect(cal.component(.day, from: dates[1]) == 17) - #expect( - CostHistoryChartMenuView._axisLabelPlacementForTesting( - provider: .codex, - daily: daily) == .edges) } @Test @@ -370,10 +366,6 @@ struct CostHistoryChartMenuViewTests { ] let dates = CostHistoryChartMenuView._axisDatesForTesting(provider: .codex, daily: daily) #expect(dates.count == 1) - #expect( - CostHistoryChartMenuView._axisLabelPlacementForTesting( - provider: .codex, - daily: daily) == .centered) } @Test @@ -391,10 +383,6 @@ struct CostHistoryChartMenuViewTests { ] let dates = CostHistoryChartMenuView._axisDatesForTesting(provider: .codex, daily: daily) #expect(dates.isEmpty) - #expect( - CostHistoryChartMenuView._axisLabelPlacementForTesting( - provider: .codex, - daily: daily) == .hidden) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 7fd363cb10..e906d7e246 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1713,7 +1713,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/CostHistoryChartMenuView.swift", - line: 1135, + line: 1106, anchor: "let projects = provider == .codex ? snapshot.projects : []", expectedProviderIDs: ["codex"], expectedReferenceCount: 2,