diff --git a/brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift b/brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift index 1f65d16c..356fb1c1 100644 --- a/brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift +++ b/brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift @@ -567,6 +567,7 @@ private struct BrainBarFlowLaneCard: View { BrainBarHeroSparkline( values: lane.values, accentColor: lane.accentColor, + activityWindowMinutes: lane.activityWindowMinutes, pulseRevision: pulseRevision ) .frame(height: chartHeight) @@ -1055,6 +1056,7 @@ private struct BrainBarFlowStatusPill: View { private struct BrainBarHeroSparkline: View { let values: [Int] let accentColor: NSColor + let activityWindowMinutes: Int let pulseRevision: Int @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -1076,7 +1078,8 @@ private struct BrainBarHeroSparkline: View { SparklineChart( presentation: SparklineChartPresentation( label: "Recent activity sparkline", - values: values + values: values, + activityWindowMinutes: activityWindowMinutes ), accentColor: accentColor, compact: SparklineRenderer.isCompact(size: renderSize) diff --git a/brain-bar/Sources/BrainBar/Dashboard/PipelineState.swift b/brain-bar/Sources/BrainBar/Dashboard/PipelineState.swift index a98af8e3..3c1d0224 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/PipelineState.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/PipelineState.swift @@ -182,6 +182,7 @@ struct DashboardFlowLane: Sendable, Equatable { let status: DashboardFlowLaneStatus let statusText: String let windowLabel: String + let activityWindowMinutes: Int let rateText: String let volumeText: String let lastEventText: String @@ -311,6 +312,7 @@ struct DashboardFlowSummary: Sendable, Equatable { status: ingressStatus, statusText: ingressStatus == .live ? "Ingress live now" : (ingressStatus == .recent ? "Recent writes in \(windowLabel.lowercased())" : "No recent writes"), windowLabel: windowLabel, + activityWindowMinutes: stats.activityWindowMinutes, rateText: DashboardMetricFormatter.rateString( totalEvents: stats.recentWriteCount, activityWindowMinutes: stats.activityWindowMinutes @@ -361,6 +363,7 @@ struct DashboardFlowSummary: Sendable, Equatable { status: enrichmentStatus, statusText: enrichmentStatusText, windowLabel: windowLabel, + activityWindowMinutes: stats.activityWindowMinutes, rateText: DashboardMetricFormatter.rateString( totalEvents: stats.recentEnrichmentCount, activityWindowMinutes: stats.activityWindowMinutes diff --git a/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift b/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift index 417704e8..109ba560 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift @@ -13,15 +13,18 @@ struct SparklineChartPoint: Identifiable, Equatable, Sendable { struct SparklineChartPresentation: Equatable, Sendable { let label: String let values: [Int] + let activityWindowMinutes: Int let latestBucketName: String init( label: String, values: [Int], + activityWindowMinutes: Int = 30, latestBucketName: String = "latest bucket count" ) { self.label = label self.values = values + self.activityWindowMinutes = activityWindowMinutes self.latestBucketName = latestBucketName } @@ -43,6 +46,40 @@ struct SparklineChartPresentation: Equatable, Sendable { max(values.max() ?? 0, 1) } + func bucketLabel(for bucket: Int) -> String { + guard !values.isEmpty else { return "no bucket" } + let clampedBucket = min(max(bucket, 0), values.count - 1) + let totalSeconds = max(activityWindowMinutes * 60, 1) + let bucketWidthSeconds = max(1, Double(totalSeconds) / Double(values.count)) + let bucketStart = Double(clampedBucket) * bucketWidthSeconds + let bucketEnd = min(Double(clampedBucket + 1) * bucketWidthSeconds, Double(totalSeconds)) + let olderSecondsAgo = max(0, Int(round(Double(totalSeconds) - bucketStart))) + let newerSecondsAgo = max(0, Int(round(Double(totalSeconds) - bucketEnd))) + + if newerSecondsAgo == 0 { + return "last \(Self.durationLabel(seconds: olderSecondsAgo))" + } + return "\(Self.durationLabel(seconds: olderSecondsAgo))-\(Self.durationLabel(seconds: newerSecondsAgo)) ago" + } + + func tooltipText(forBucket bucket: Int) -> String { + let clampedBucket = min(max(bucket, 0), max(values.count - 1, 0)) + let value = values.indices.contains(clampedBucket) ? values[clampedBucket] : 0 + return "\(bucketLabel(for: clampedBucket)): \(value)" + } + + private static func durationLabel(seconds: Int) -> String { + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + if remainingSeconds == 0 { + return "\(minutes)m" + } + if minutes == 0 { + return "\(remainingSeconds)s" + } + return "\(minutes)m \(remainingSeconds)s" + } + private var trendDescription: String { guard let last = values.last else { return "no trend" @@ -66,6 +103,8 @@ struct SparklineChart: View { let presentation: SparklineChartPresentation let accentColor: NSColor let compact: Bool + @State private var hoveredBucket: Int? + @State private var hoverLocation: CGPoint? init( presentation: SparklineChartPresentation, @@ -103,10 +142,83 @@ struct SparklineChart: View { .background(Color.clear) .padding(compact ? 2 : 10) } + .chartOverlay { chartProxy in + GeometryReader { geometry in + if let plotAnchor = chartProxy.plotFrame { + let plotFrame = geometry[plotAnchor] + + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let location): + hoveredBucket = nearestBucket( + to: location, + plotFrame: plotFrame, + chartProxy: chartProxy + ) + hoverLocation = location + case .ended: + hoveredBucket = nil + hoverLocation = nil + } + } + + if let hoveredBucket, + let hoverLocation, + !compact { + sparklineTooltip(forBucket: hoveredBucket) + .position(tooltipPosition(near: hoverLocation, in: geometry.size)) + .allowsHitTesting(false) + } + } + } + } .accessibilityElement(children: .combine) .accessibilityLabel(Text(presentation.accessibilityLabel)) .accessibilityValue(Text(presentation.accessibilityValue)) } + + private func nearestBucket( + to location: CGPoint, + plotFrame: CGRect, + chartProxy: ChartProxy + ) -> Int? { + guard !presentation.points.isEmpty, + plotFrame.contains(location) else { + return nil + } + + let plotX = location.x - plotFrame.minX + guard let bucket: Double = chartProxy.value(atX: plotX, as: Double.self) else { + return nil + } + return min(max(Int(bucket.rounded()), 0), presentation.points.count - 1) + } + + private func tooltipPosition(near location: CGPoint, in size: CGSize) -> CGPoint { + let x = min(max(location.x, 58), max(size.width - 58, 58)) + let y = max(location.y - 28, 16) + return CGPoint(x: x, y: y) + } + + @ViewBuilder + private func sparklineTooltip(forBucket bucket: Int) -> some View { + Text(presentation.tooltipText(forBucket: bucket)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: true) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: accentColor).opacity(0.35), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.14), radius: 8, y: 3) + } } enum SparklineRenderer { diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatusPopoverView.swift b/brain-bar/Sources/BrainBar/Dashboard/StatusPopoverView.swift index 87ffd573..7dc86980 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/StatusPopoverView.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/StatusPopoverView.swift @@ -370,7 +370,8 @@ final class StatusPopoverView: NSViewController { sparklineChartView.rootView = SparklineChart( presentation: SparklineChartPresentation( label: "Recent activity sparkline", - values: summary.enrichment.values + values: summary.enrichment.values, + activityWindowMinutes: summary.enrichment.activityWindowMinutes ), accentColor: collector.state.color ) diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index 0b9c7bd9..3c77240a 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -130,13 +130,28 @@ final class DashboardTests: XCTestCase { func testSparklineChartPresentationCarriesBucketsAndVoiceOverMetadata() { let presentation = SparklineChartPresentation( label: "Recent activity sparkline", - values: [0, 2, 5, 3] + values: [0, 2, 5, 3], + activityWindowMinutes: 20 ) XCTAssertEqual(presentation.points.map(\.bucket), [0, 1, 2, 3]) XCTAssertEqual(presentation.points.map(\.value), [0, 2, 5, 3]) XCTAssertEqual(presentation.accessibilityLabel, "Recent activity sparkline") XCTAssertEqual(presentation.accessibilityValue, "latest bucket count 3, trending down") + XCTAssertEqual(presentation.bucketLabel(for: 0), "20m-15m ago") + XCTAssertEqual(presentation.bucketLabel(for: 3), "last 5m") + XCTAssertEqual(presentation.tooltipText(forBucket: 2), "10m-5m ago: 5") + } + + func testSparklineChartPresentationLabelsPartialMinuteBucketsLikeDatabase() { + let presentation = SparklineChartPresentation( + label: "Recent activity sparkline", + values: Array(repeating: 0, count: 12), + activityWindowMinutes: 31 + ) + + XCTAssertEqual(presentation.bucketLabel(for: 0), "31m-28m 25s ago") + XCTAssertEqual(presentation.bucketLabel(for: 11), "last 2m 35s") } func testSparklineRendererCompactClassificationMatchesEndpointAndChartPadding() {