diff --git a/CHANGELOG.md b/CHANGELOG.md index 190a79efa5..2c10f5abb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ ### Fixed - OpenCode Go: include recorded local token counts in daily and per-model history without inventing costs or treating missing counts as zero (#3995). Thanks @Yuxin-Qiao! +- Usage & Spend: start long daily ledgers with the newest 30 rows and a Show all control, reducing initial layout work while preserving full-period totals and charts (#3998). Thanks @Yuxin-Qiao! - Security: preserve browser-cookie denial across restarts and CLI configuration, and stage credential writes privately before atomic replacement (reported in #3986). Thanks @bo-vavrik! - Provider plugins: update bundled QuickJS-NG to 0.17.0 with upstream memory-safety and numeric-correctness fixes (#3987). Thanks @bo-vavrik! - OpenRouter: explain the required API key field instead of reporting no available fetch strategy, and clarify where regular and Management keys belong (#3966, #3969). Thanks @harjothkhara! diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 3c71ac51be..fb021f5ac6 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -880,19 +880,45 @@ private struct SpendDashboardTrendPanel: View { } } -struct SpendDailyChartPresentation: Equatable { - enum Content: Equatable { - case chart - case unavailable - } +enum SpendChartContent: Equatable { + case chart + case unavailable +} + +struct SpendChartSeries: Equatable { + let name: String + let provider: UsageProvider +} + +private struct SpendChartStyle: ViewModifier { + let series: [SpendChartSeries] + let currencyCode: String - struct Series: Equatable { - let name: String - let provider: UsageProvider + func body(content: Content) -> some View { + content + .chartForegroundStyleScale( + domain: self.series.map(\.name), + range: self.series.map { + let color = ProviderAccentPalette.color(for: $0.provider) + return Color(red: color.red, green: color.green, blue: color.blue) + }) + .chartLegend(position: .bottom, alignment: .leading, spacing: 8) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(UsageFormatter.compactCurrencyString(amount, currencyCode: self.currencyCode)) + } + } + } + } } +} - let content: Content - let series: [Series] +struct SpendDailyChartPresentation: Equatable { + let content: SpendChartContent + let series: [SpendChartSeries] let dayCount: Int init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) { @@ -902,7 +928,7 @@ struct SpendDailyChartPresentation: Equatable { var seenNames: Set = [] self.series = dailyPoints.compactMap { point in guard seenNames.insert(point.providerName).inserted else { return nil } - return Series(name: point.providerName, provider: point.provider) + return SpendChartSeries(name: point.providerName, provider: point.provider) } } @@ -944,22 +970,7 @@ private struct SpendDailyChartContent: View { } .chartXScale(domain: self.group.chartDomain) .chartXAxis { AxisMarks(format: self.dayFormat) } - .chartForegroundStyleScale( - domain: presentation.series.map(\.name), - range: presentation.series.map { self.providerColor($0.provider) }) - .chartLegend(position: .bottom, alignment: .leading, spacing: 8) - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine() - AxisValueLabel { - if let amount = value.as(Double.self) { - Text(UsageFormatter.compactCurrencyString( - amount, - currencyCode: self.group.currencyCode)) - } - } - } - } + .modifier(SpendChartStyle(series: presentation.series, currencyCode: self.group.currencyCode)) .frame(height: 170) .accessibilityLabel(L("Daily estimated spend")) .accessibilityValue(presentation.accessibilityValue) @@ -978,11 +989,6 @@ private struct SpendDailyChartContent: View { let day = point.day.formatted(self.dayFormat) return "\(point.providerName), \(day)" } - - private func providerColor(_ provider: UsageProvider) -> Color { - let color = ProviderAccentPalette.color(for: provider) - return Color(red: color.red, green: color.green, blue: color.blue) - } } /// Finds the id of the highest-`stackEnd` point per grouping key (day/hour), regardless of how @@ -1017,18 +1023,8 @@ private func spendStackedBarSegmentShape(isTopOfStack: Bool) -> UnevenRoundedRec } struct SpendHourlyChartPresentation: Equatable { - enum Content: Equatable { - case chart - case unavailable - } - - struct Series: Equatable { - let name: String - let provider: UsageProvider - } - - let content: Content - let series: [Series] + let content: SpendChartContent + let series: [SpendChartSeries] let hourCount: Int let includeDateInPointLabels: Bool @@ -1039,7 +1035,7 @@ struct SpendHourlyChartPresentation: Equatable { var seenNames: Set = [] self.series = hourlyPoints.compactMap { point in guard seenNames.insert(point.providerName).inserted else { return nil } - return Series(name: point.providerName, provider: point.provider) + return SpendChartSeries(name: point.providerName, provider: point.provider) } } @@ -1084,22 +1080,7 @@ private struct SpendHourlyChartContent: View { currencyCode: self.group.currencyCode))) } .chartXScale(domain: self.group.hourlyChartDomain ?? self.group.chartDomain) - .chartForegroundStyleScale( - domain: presentation.series.map(\.name), - range: presentation.series.map { self.providerColor($0.provider) }) - .chartLegend(position: .bottom, alignment: .leading, spacing: 8) - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine() - AxisValueLabel { - if let amount = value.as(Double.self) { - Text(UsageFormatter.compactCurrencyString( - amount, - currencyCode: self.group.currencyCode)) - } - } - } - } + .modifier(SpendChartStyle(series: presentation.series, currencyCode: self.group.currencyCode)) .frame(height: 170) .accessibilityLabel(L("Hourly estimated spend")) .accessibilityValue(presentation.accessibilityValue) @@ -1116,11 +1097,6 @@ private struct SpendHourlyChartContent: View { timeZone: self.group.timeZone, includeDate: includeDate) } - - private func providerColor(_ provider: UsageProvider) -> Color { - let color = ProviderAccentPalette.color(for: provider) - return Color(red: color.red, green: color.green, blue: color.blue) - } } private enum SpendDailyLedgerLayout { @@ -1141,8 +1117,27 @@ private enum SpendDailyLedgerLayout { + (horizontalPadding * 2) } +/// Bound initial ledger layout on long ranges; expansion still exposes the complete history. +func spendDailyLedgerVisibleSummaries( + _ summaries: [SpendDashboardModel.DailySummary], + showsAllRows: Bool, + collapsedRowCount: Int) -> [SpendDashboardModel.DailySummary] +{ + Array(summaries.suffix(showsAllRows ? summaries.count : collapsedRowCount).reversed()) +} + private struct SpendDailyLedger: View { let group: SpendDashboardModel.CurrencyGroup + @State private var showsAllRows = false + + static let collapsedRowCount = 30 + + private var visibleSummaries: [SpendDashboardModel.DailySummary] { + spendDailyLedgerVisibleSummaries( + self.group.dailySummaries, + showsAllRows: self.showsAllRows, + collapsedRowCount: Self.collapsedRowCount) + } var body: some View { SpendDashboardPanel { @@ -1161,20 +1156,24 @@ private struct SpendDailyLedger: View { VStack(alignment: .leading, spacing: 0) { self.header Divider() - LazyVStack(spacing: 0) { - ForEach(self.group.dailySummaries.reversed()) { summary in + VStack(spacing: 0) { + ForEach(Array(self.visibleSummaries.enumerated()), id: \.element.id) { index, summary in + if index > 0 { + Divider() + } SpendDailyLedgerRow( summary: summary, currencyCode: self.group.currencyCode, timeZone: self.group.timeZone) - if summary.day != self.group.dailySummaries.first?.day { - Divider() - } } } } .frame(minWidth: SpendDailyLedgerLayout.minimumTableWidth, alignment: .leading) } + SpendPanelExpandButton( + rowCount: self.group.dailySummaries.count, + collapsedRowCount: Self.collapsedRowCount, + showsAllRows: self.$showsAllRows) } } } @@ -1243,7 +1242,6 @@ private struct SpendDailyLedgerRow: View { HStack(spacing: 5) { ForEach(self.activeProviders.prefix(4)) { row in SpendProviderIcon(provider: row.provider) - .help(row.displayName) } if self.activeProviders.count > 4 { Text("+\(codexBarLocalizedInteger(self.activeProviders.count - 4))") @@ -1251,6 +1249,7 @@ private struct SpendDailyLedgerRow: View { .foregroundStyle(.secondary) } } + .help(self.activeProviders.map(\.displayName).joined(separator: ", ")) } } diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index c16cfcd20d..dc4b5bdc82 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -2004,6 +2004,24 @@ private final class MenuBarLayoutSizeCountingImage: NSImage { } extension MenuBarLayoutRendererTests { + @Test + func `default status item preserves the 0_65_0 width contract`() { + let icon = NSImage(size: NSSize(width: 16, height: 16)) + let renderer = MenuBarLayoutRenderer() + let output = renderer.render( + layout: .defaultLayout, data: self.data(), icon: icon, options: self.options()) + #expect(output.attributedTitle.string == "\u{2009}50%") + // The renderer and sizing implementation are unchanged from v0.65.0. + #expect(output.statusItemWidth(gap: .tight) == 49) + #expect(output.statusItemWidth(gap: .regular) == 56) + let iconOnly = renderer.render( + layout: MenuBarLayout(lines: [[.icon]]), data: self.data(), icon: icon, options: self.options()) + #expect(iconOnly.statusItemWidth(gap: .tight) == 19) + #expect(iconOnly.statusItemWidth(gap: .regular) == 26) + print("STATUS_WIDTH default regular=\(output.statusItemWidth(gap: .regular)) " + + "tight=\(output.statusItemWidth(gap: .tight)) iconOnly regular=26 tight=19") + } + @Test func `plain single line status content reuses a template image by value`() throws { let cache = MenuBarLayoutTitleCache(capacity: 2) diff --git a/Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift b/Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift index b1978011c6..52de57db0e 100644 --- a/Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift @@ -4,6 +4,23 @@ import Testing @testable import CodexBarCore struct SpendDashboardDailyLedgerTests { + @Test(arguments: [0, 7, 30, 31, 365]) + func `ledger expansion preserves every day in newest first order`(days: Int) { + let summaries = (0.. Double { + Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15 + } +} diff --git a/docs/ui.md b/docs/ui.md index e8aca72800..47fae1fec4 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -192,6 +192,8 @@ Hover over a daily bar in a provider menu’s cost chart to inspect its date, co ### Daily spend ledger +Long ranges initially show the newest 30 daily rows. **Show all** expands the complete ledger and **Show less** collapses it again; chart data, totals, exports, and coverage still use the full selected reporting period. Provider icons share one tooltip listing the day's active providers. + Usage & Spend includes a daily ledger for each currency group. Rows use the selected bucket time zone and app language, retain priced days when another day is unpriced, and mark unavailable amounts with a dash. When one source on a day has no price, the row shows the known spend of the other sources with a tilde, the same partial marker as the group total. A day with no known spend keeps the dash. Zero-usage rows require established common coverage; unknown activity is not described as idle. Narrow settings windows allow horizontal ledger scrolling. Source filtering and dashboard accounting remain authoritative. OpenCodex cost and request aggregates cover the selected history window, including All; older activity remains included alongside its token counts.