From 092c866c0822f8f1c24ab38a03162c950e1ba7c9 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 26 May 2026 20:08:56 +0300 Subject: [PATCH 1/2] feat(brainbar): add sparkline hover tooltips --- .../BrainBar/BrainBarWindowRootView.swift | 5 +- .../BrainBar/Dashboard/PipelineState.swift | 3 + .../Dashboard/SparklineRenderer.swift | 96 +++++++++++++++++++ .../Dashboard/StatusPopoverView.swift | 3 +- .../Tests/BrainBarTests/DashboardTests.swift | 6 +- 5 files changed, 110 insertions(+), 3 deletions(-) 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..55b6028b 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,23 @@ 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 bucketWidth = max(1, Int(ceil(Double(activityWindowMinutes) / Double(values.count)))) + let newerMinutesAgo = max(values.count - 1 - clampedBucket, 0) * bucketWidth + let olderMinutesAgo = min(newerMinutesAgo + bucketWidth, activityWindowMinutes) + + if newerMinutesAgo == 0 { + return "last \(olderMinutesAgo)m" + } + return "\(olderMinutesAgo)-\(newerMinutesAgo)m ago" + } + + func tooltipText(for point: SparklineChartPoint) -> String { + "\(bucketLabel(for: point.bucket)): \(point.value)" + } + private var trendDescription: String { guard let last = values.last else { return "no trend" @@ -66,6 +86,8 @@ struct SparklineChart: View { let presentation: SparklineChartPresentation let accentColor: NSColor let compact: Bool + @State private var hoveredPoint: SparklineChartPoint? + @State private var hoverLocation: CGPoint? init( presentation: SparklineChartPresentation, @@ -103,10 +125,84 @@ 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): + hoveredPoint = nearestPoint( + to: location, + plotFrame: plotFrame, + chartProxy: chartProxy + ) + hoverLocation = location + case .ended: + hoveredPoint = nil + hoverLocation = nil + } + } + + if let hoveredPoint, + let hoverLocation, + !compact { + sparklineTooltip(for: hoveredPoint) + .position(tooltipPosition(near: hoverLocation, in: geometry.size)) + .allowsHitTesting(false) + } + } + } + } .accessibilityElement(children: .combine) .accessibilityLabel(Text(presentation.accessibilityLabel)) .accessibilityValue(Text(presentation.accessibilityValue)) } + + private func nearestPoint( + to location: CGPoint, + plotFrame: CGRect, + chartProxy: ChartProxy + ) -> SparklineChartPoint? { + 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 + } + let index = min(max(Int(bucket.rounded()), 0), presentation.points.count - 1) + return presentation.points[index] + } + + 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(for point: SparklineChartPoint) -> some View { + Text(presentation.tooltipText(for: point)) + .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..f1973abc 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -130,13 +130,17 @@ 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), "20-15m ago") + XCTAssertEqual(presentation.bucketLabel(for: 3), "last 5m") + XCTAssertEqual(presentation.tooltipText(for: presentation.points[2]), "10-5m ago: 5") } func testSparklineRendererCompactClassificationMatchesEndpointAndChartPadding() { From 63afc1aca7a000f83596cbe1c5d637a29fed1796 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 26 May 2026 20:19:44 +0300 Subject: [PATCH 2/2] fix(brainbar): keep sparkline tooltips current --- .../Dashboard/SparklineRenderer.swift | 54 ++++++++++++------- .../Tests/BrainBarTests/DashboardTests.swift | 15 +++++- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift b/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift index 55b6028b..109ba560 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/SparklineRenderer.swift @@ -49,18 +49,35 @@ struct SparklineChartPresentation: Equatable, Sendable { func bucketLabel(for bucket: Int) -> String { guard !values.isEmpty else { return "no bucket" } let clampedBucket = min(max(bucket, 0), values.count - 1) - let bucketWidth = max(1, Int(ceil(Double(activityWindowMinutes) / Double(values.count)))) - let newerMinutesAgo = max(values.count - 1 - clampedBucket, 0) * bucketWidth - let olderMinutesAgo = min(newerMinutesAgo + bucketWidth, activityWindowMinutes) + 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 newerMinutesAgo == 0 { - return "last \(olderMinutesAgo)m" + if newerSecondsAgo == 0 { + return "last \(Self.durationLabel(seconds: olderSecondsAgo))" } - return "\(olderMinutesAgo)-\(newerMinutesAgo)m ago" + return "\(Self.durationLabel(seconds: olderSecondsAgo))-\(Self.durationLabel(seconds: newerSecondsAgo)) ago" } - func tooltipText(for point: SparklineChartPoint) -> String { - "\(bucketLabel(for: point.bucket)): \(point.value)" + 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 { @@ -86,7 +103,7 @@ struct SparklineChart: View { let presentation: SparklineChartPresentation let accentColor: NSColor let compact: Bool - @State private var hoveredPoint: SparklineChartPoint? + @State private var hoveredBucket: Int? @State private var hoverLocation: CGPoint? init( @@ -136,22 +153,22 @@ struct SparklineChart: View { .onContinuousHover { phase in switch phase { case .active(let location): - hoveredPoint = nearestPoint( + hoveredBucket = nearestBucket( to: location, plotFrame: plotFrame, chartProxy: chartProxy ) hoverLocation = location case .ended: - hoveredPoint = nil + hoveredBucket = nil hoverLocation = nil } } - if let hoveredPoint, + if let hoveredBucket, let hoverLocation, !compact { - sparklineTooltip(for: hoveredPoint) + sparklineTooltip(forBucket: hoveredBucket) .position(tooltipPosition(near: hoverLocation, in: geometry.size)) .allowsHitTesting(false) } @@ -163,11 +180,11 @@ struct SparklineChart: View { .accessibilityValue(Text(presentation.accessibilityValue)) } - private func nearestPoint( + private func nearestBucket( to location: CGPoint, plotFrame: CGRect, chartProxy: ChartProxy - ) -> SparklineChartPoint? { + ) -> Int? { guard !presentation.points.isEmpty, plotFrame.contains(location) else { return nil @@ -177,8 +194,7 @@ struct SparklineChart: View { guard let bucket: Double = chartProxy.value(atX: plotX, as: Double.self) else { return nil } - let index = min(max(Int(bucket.rounded()), 0), presentation.points.count - 1) - return presentation.points[index] + return min(max(Int(bucket.rounded()), 0), presentation.points.count - 1) } private func tooltipPosition(near location: CGPoint, in size: CGSize) -> CGPoint { @@ -188,8 +204,8 @@ struct SparklineChart: View { } @ViewBuilder - private func sparklineTooltip(for point: SparklineChartPoint) -> some View { - Text(presentation.tooltipText(for: point)) + private func sparklineTooltip(forBucket bucket: Int) -> some View { + Text(presentation.tooltipText(forBucket: bucket)) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index f1973abc..3c77240a 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -138,9 +138,20 @@ final class DashboardTests: XCTestCase { 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), "20-15m ago") + XCTAssertEqual(presentation.bucketLabel(for: 0), "20m-15m ago") XCTAssertEqual(presentation.bucketLabel(for: 3), "last 5m") - XCTAssertEqual(presentation.tooltipText(for: presentation.points[2]), "10-5m ago: 5") + 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() {