diff --git a/apps/agentacct/Sources/agentacct/DashboardPane.swift b/apps/agentacct/Sources/agentacct/DashboardPane.swift index c51f659..81a6f3e 100644 --- a/apps/agentacct/Sources/agentacct/DashboardPane.swift +++ b/apps/agentacct/Sources/agentacct/DashboardPane.swift @@ -199,10 +199,11 @@ enum DashboardUsageSeries: String, CaseIterable, Identifiable { } } - func subtitle(dayCount: Int) -> String { + func subtitle(rangeDays: Int, periodPresentation: UsagePeriodPresentation) -> String { + let range = periodPresentation.historyRangeDescription(days: rangeDays) switch self { - case .tokens: return "Fresh tokens · last \(dayCount) days · client reported" - case .cost: return "Estimated cost · last \(dayCount) days · pricing-table basis" + case .tokens: return "Fresh tokens · \(range) · client reported" + case .cost: return "Estimated cost · \(range) · pricing-table basis" } } } @@ -306,8 +307,15 @@ struct DashboardPane: View { ) } - if let periods = dashboard.usage?.byPeriod, periods.count > 1 { - DashboardUsageChart(periods: periods) + if let usage = dashboard.usage, + let periods = usage.byPeriod, + periods.count > 1 + { + DashboardUsageChart( + periods: periods, + rangeDays: dashboard.usageDays, + periodPresentation: UsagePeriodPresentation(usage: usage) + ) } } .padding(Space.gutter) @@ -931,6 +939,8 @@ private struct AgentPlanRowView: View { private struct DashboardUsageChart: View { let periods: [PeriodBucket] + let rangeDays: Int + let periodPresentation: UsagePeriodPresentation @State private var series: DashboardUsageSeries = .tokens @State private var hoveredIndex: Int? @@ -949,7 +959,12 @@ private struct DashboardUsageChart: View { Text("Usage history") .font(Type.titleCard) .foregroundStyle(Theme.muted) - Text(series.subtitle(dayCount: periods.count)) + Text( + series.subtitle( + rangeDays: rangeDays, + periodPresentation: periodPresentation + ) + ) .font(Type.caption) .foregroundStyle(Theme.muted) } @@ -1053,7 +1068,7 @@ private struct DashboardUsageChart: View { .accessibilityLabel( "\(period.period ?? period.shortLabel), \(series.valueText(for: period))" ) - .accessibilityHint("Pins or clears this day's value") + .accessibilityHint(periodPresentation.pinAccessibilityHint) .accessibilityAddTraits(pinnedIndex == index ? .isSelected : []) .accessibilityIdentifier("dashboard.usage.day.\(index)") diff --git a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift index 99a71e7..060f105 100644 --- a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift +++ b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift @@ -109,6 +109,20 @@ struct WorkSnapshotFixture: Decodable { } } +enum SnapshotRecordedUsageState { + case sevenDays + case ninetyDays + + func storeState(for fixture: DashboardSnapshotFixture) -> SnapshotUsageStoreState { + switch self { + case .sevenDays: + return SnapshotUsageStoreState(days: 7, summary: fixture.usage) + case .ninetyDays: + return SnapshotUsageStoreState(days: 90, summary: fixture.usage90Days) + } + } +} + enum SnapshotError: LocalizedError { case unsupportedSchema(payload: String, actual: String, expected: String) case missingFixtureDate @@ -141,6 +155,7 @@ struct DashboardSnapshotConfiguration { let height: CGFloat let colorScheme: ColorScheme let workState: SnapshotWorkStoreState + let recordedUsageState: SnapshotRecordedUsageState var filename: String { let appearance = colorScheme == .dark ? "dark" : "light" @@ -148,15 +163,17 @@ struct DashboardSnapshotConfiguration { } static let reviewConfigurations: [Self] = [ - Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light, workState: .populated), - Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark, workState: .populated), + Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light, workState: .populated, recordedUsageState: .sevenDays), + Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark, workState: .populated, recordedUsageState: .sevenDays), // The reference viewport must show the complete dashboard, including // chart labels. The shorter minimum pair intentionally verifies the // real top-of-scroll experience instead. - Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light, workState: .populated), - Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark, workState: .populated), - Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .light, workState: .listErrorWithRetainedData), - Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .listErrorWithRetainedData), + Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light, workState: .populated, recordedUsageState: .sevenDays), + Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark, workState: .populated, recordedUsageState: .sevenDays), + Self(viewport: "weekly-reference", width: 1120, height: 900, colorScheme: .light, workState: .populated, recordedUsageState: .ninetyDays), + Self(viewport: "weekly-reference", width: 1120, height: 900, colorScheme: .dark, workState: .populated, recordedUsageState: .ninetyDays), + Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .light, workState: .listErrorWithRetainedData, recordedUsageState: .sevenDays), + Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .listErrorWithRetainedData, recordedUsageState: .sevenDays), ] } @@ -202,7 +219,8 @@ enum DashboardSnapshotRenderer { let glance = GlanceState(preloaded: fixture.glanceSnapshot) let dashboard = DashboardStore( preloaded: fixture, - workState: configuration.workState + workState: configuration.workState, + usageState: configuration.recordedUsageState.storeState(for: fixture) ) let selection = AppSelection() selection.pane = .dashboard diff --git a/apps/agentacct/Sources/agentacct/DashboardStore.swift b/apps/agentacct/Sources/agentacct/DashboardStore.swift index c4e50da..75d1dd3 100644 --- a/apps/agentacct/Sources/agentacct/DashboardStore.swift +++ b/apps/agentacct/Sources/agentacct/DashboardStore.swift @@ -380,7 +380,7 @@ final class DashboardStore { } /// Switch the pane range and refetch BOTH the plan lane and the cost cube - /// so the plan breakdown, the daily bars, and the $ view stay on one window. + /// so the plan breakdown, the period bars, and the $ view stay on one window. /// The range label only flips once both payloads have landed, and only the /// newest in-flight switch is allowed to write. func setUsageDays(_ days: Int) async { diff --git a/apps/agentacct/Sources/agentacct/UsagePane.swift b/apps/agentacct/Sources/agentacct/UsagePane.swift index 51e5bbf..eeb9cd3 100644 --- a/apps/agentacct/Sources/agentacct/UsagePane.swift +++ b/apps/agentacct/Sources/agentacct/UsagePane.swift @@ -371,19 +371,23 @@ struct UsagePeriodPresentation { let value: String? let absent: String private let unit: String + private let bucketDescription: String? init(usage: UsageSummary) { switch usage.filtersEcho?.granularity { case "daily": unit = "day" + bucketDescription = nil label = "Active days" absent = "no daily series" case "weekly": unit = "week" + bucketDescription = "weekly buckets" label = "Active weeks" absent = "no weekly series" default: unit = "period" + bucketDescription = "period buckets" label = "Active periods" absent = "no period series" } @@ -405,6 +409,12 @@ struct UsagePeriodPresentation { var previousAccessibilityLabel: String { "Previous usage \(unit)" } var nextAccessibilityLabel: String { "Next usage \(unit)" } var selectionAccessibilityHint: String { "Selects this \(unit)'s value" } + var pinAccessibilityHint: String { "Pins or clears this \(unit)'s value" } + + func historyRangeDescription(days: Int) -> String { + let range = "last \(days) days" + return bucketDescription.map { "\(range) · \($0)" } ?? range + } } private struct UsagePlanClientDetail: View { diff --git a/apps/agentacct/Sources/agentacct/UsageSnapshotHarness.swift b/apps/agentacct/Sources/agentacct/UsageSnapshotHarness.swift index a32fa59..07d51a3 100644 --- a/apps/agentacct/Sources/agentacct/UsageSnapshotHarness.swift +++ b/apps/agentacct/Sources/agentacct/UsageSnapshotHarness.swift @@ -7,26 +7,12 @@ struct UsageSnapshotConfiguration { case disconnected } - enum RecordedUsageState { - case sevenDays - case ninetyDays - - func storeState(for fixture: DashboardSnapshotFixture) -> SnapshotUsageStoreState { - switch self { - case .sevenDays: - return SnapshotUsageStoreState(days: 7, summary: fixture.usage) - case .ninetyDays: - return SnapshotUsageStoreState(days: 90, summary: fixture.usage90Days) - } - } - } - let viewport: String let width: CGFloat let height: CGFloat let colorScheme: ColorScheme let capacityState: CapacityState - let recordedUsageState: RecordedUsageState + let recordedUsageState: SnapshotRecordedUsageState var filename: String { let appearance = colorScheme == .dark ? "dark" : "light" diff --git a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift index a01eb77..e6ac447 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift @@ -121,6 +121,42 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertEqual(DashboardUsageSeries.cost.totalText(for: [periods[2]]), "—") } + func testUsageSeriesDescribesTheSelectedRangeAndEffectiveGranularity() throws { + let daily = try usagePeriodPresentation(granularity: "daily") + let weekly = try usagePeriodPresentation(granularity: "weekly") + let unknown = try usagePeriodPresentation(granularity: nil) + + XCTAssertEqual( + DashboardUsageSeries.tokens.subtitle( + rangeDays: 7, + periodPresentation: daily + ), + "Fresh tokens · last 7 days · client reported" + ) + XCTAssertEqual( + DashboardUsageSeries.tokens.subtitle( + rangeDays: 90, + periodPresentation: weekly + ), + "Fresh tokens · last 90 days · weekly buckets · client reported" + ) + XCTAssertEqual( + DashboardUsageSeries.cost.subtitle( + rangeDays: 90, + periodPresentation: weekly + ), + "Estimated cost · last 90 days · weekly buckets · pricing-table basis" + ) + XCTAssertEqual( + DashboardUsageSeries.tokens.subtitle( + rangeDays: 30, + periodPresentation: unknown + ), + "Fresh tokens · last 30 days · period buckets · client reported" + ) + XCTAssertEqual(weekly.pinAccessibilityHint, "Pins or clears this week's value") + } + func testActiveWorkIncludesOnlyRunningStates() { let cases: [(status: String?, isActive: Bool)] = [ ("started", true), @@ -1042,4 +1078,15 @@ final class DashboardInteractionTests: XCTestCase { private func decode(_ type: Value.Type, from json: String) throws -> Value { try JSONDecoder().decode(type, from: Data(json.utf8)) } + + private func usagePeriodPresentation(granularity: String?) throws -> UsagePeriodPresentation { + let filtersEcho = granularity.map { + ",\"filters_echo\":{\"granularity\":\"\($0)\"}" + } ?? "" + let usage = try decode( + UsageSummary.self, + from: "{\"by_client\":[],\"by_model\":[]\(filtersEcho)}" + ) + return UsagePeriodPresentation(usage: usage) + } } diff --git a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift index 9c845fc..c9ae537 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift @@ -16,6 +16,8 @@ final class DashboardSnapshotHarnessTests: XCTestCase { ExpectedArtifact(filename: "dashboard-minimum-dark.png", pixelsWide: 1920, pixelsHigh: 1120), ExpectedArtifact(filename: "dashboard-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "dashboard-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "dashboard-weekly-reference-light.png", pixelsWide: 2240, pixelsHigh: 1800), + ExpectedArtifact(filename: "dashboard-weekly-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1800), ExpectedArtifact(filename: "dashboard-attention-unavailable-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "dashboard-attention-unavailable-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ] diff --git a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift index 11045bd..69f4d07 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift @@ -10,6 +10,8 @@ final class DashboardVisualRegressionTests: XCTestCase { "dashboard-minimum-dark.png", "dashboard-reference-light.png", "dashboard-reference-dark.png", + "dashboard-weekly-reference-light.png", + "dashboard-weekly-reference-dark.png", "dashboard-attention-unavailable-light.png", "dashboard-attention-unavailable-dark.png", ] diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-dark.png new file mode 100644 index 0000000..5a05607 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-light.png new file mode 100644 index 0000000..d895a3b Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-weekly-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/UsagePeriodPresentationTests.swift b/apps/agentacct/Tests/agentacctTests/UsagePeriodPresentationTests.swift index 31857b0..8a821b4 100644 --- a/apps/agentacct/Tests/agentacctTests/UsagePeriodPresentationTests.swift +++ b/apps/agentacct/Tests/agentacctTests/UsagePeriodPresentationTests.swift @@ -14,6 +14,7 @@ final class UsagePeriodPresentationTests: XCTestCase { XCTAssertEqual(presentation.tokenChartTitle(group: nil), "Fresh tokens per day") XCTAssertEqual(presentation.previousAccessibilityLabel, "Previous usage day") XCTAssertEqual(presentation.selectionAccessibilityHint, "Selects this day's value") + XCTAssertEqual(presentation.pinAccessibilityHint, "Pins or clears this day's value") } func testUsesEffectiveWeeklyGranularity() throws { @@ -31,6 +32,7 @@ final class UsagePeriodPresentationTests: XCTestCase { ) XCTAssertEqual(presentation.nextAccessibilityLabel, "Next usage week") XCTAssertEqual(presentation.selectionAccessibilityHint, "Selects this week's value") + XCTAssertEqual(presentation.pinAccessibilityHint, "Pins or clears this week's value") } func testUnknownOrMissingGranularityUsesTruthfulGenericCopy() throws { @@ -44,6 +46,7 @@ final class UsagePeriodPresentationTests: XCTestCase { XCTAssertEqual(presentation.absent, "no period series") XCTAssertEqual(presentation.costChartTitle, "Cost per period") XCTAssertEqual(presentation.previousAccessibilityLabel, "Previous usage period") + XCTAssertEqual(presentation.pinAccessibilityHint, "Pins or clears this period's value") } }