From e95c17347ef9e62e1b75b785d75be81fbcb472d8 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 28 Oct 2026 07:56:32 +1000 Subject: [PATCH 1/4] Show resets and Perks as a chart, and call the section Resets & Perks The Overview's Events list was the only way to see resets, and its name didn't say what was in it. Above the list there's now a bar per reset, as tall as the share of the Window left unused, coloured by Bar App, with a diamond for each Perk change; it spans the same range as the Tokens chart, follows the Show session resets switch, and hovering names the reset or Perk with its figures. The section and the sidebar page are now Resets & Perks. OverviewEvent carries a reset's unused share and Limit name for the chart, and ResetsChartData builds the bars and markers from the same events the list shows. --- Sources/BarUI/OverviewView.swift | 2 +- ...tion.swift => ResetsAndPerksSection.swift} | 29 +++- Sources/BarUI/ResetsChart.swift | 152 ++++++++++++++++++ Sources/BarUICore/ArchivePage.swift | 2 +- Sources/BarUICore/OverviewEvent.swift | 9 +- Sources/BarUICore/ResetsChartData.swift | 63 ++++++++ .../BarUICoreTests/ResetsChartDataTests.swift | 103 ++++++++++++ Tests/BarUICoreTests/SidebarTests.swift | 2 +- 8 files changed, 352 insertions(+), 10 deletions(-) rename Sources/BarUI/{EventsSection.swift => ResetsAndPerksSection.swift} (73%) create mode 100644 Sources/BarUI/ResetsChart.swift create mode 100644 Sources/BarUICore/ResetsChartData.swift create mode 100644 Tests/BarUICoreTests/ResetsChartDataTests.swift diff --git a/Sources/BarUI/OverviewView.swift b/Sources/BarUI/OverviewView.swift index c13caf0..ed3b0d0 100644 --- a/Sources/BarUI/OverviewView.swift +++ b/Sources/BarUI/OverviewView.swift @@ -57,7 +57,7 @@ struct OverviewView: View { } LimitsStrip(cards: cards) TokensSection(overview: overview, now: now, range: $range) - EventsSection(events: overview.events(range: range, now: now), now: now) + ResetsAndPerksSection(events: overview.events(range: range, now: now), range: range, now: now) } .padding(.horizontal, 32) .padding(.vertical, 16) diff --git a/Sources/BarUI/EventsSection.swift b/Sources/BarUI/ResetsAndPerksSection.swift similarity index 73% rename from Sources/BarUI/EventsSection.swift rename to Sources/BarUI/ResetsAndPerksSection.swift index 1228e25..1a6f38c 100644 --- a/Sources/BarUI/EventsSection.swift +++ b/Sources/BarUI/ResetsAndPerksSection.swift @@ -1,10 +1,12 @@ import BarUICore import SwiftUI -/// What happened to the Bar Apps' Limits, Perks and Tracking in the range the -/// Overview is showing, newest first. -struct EventsSection: View { +/// The Bar Apps' resets and Perk changes in the range the Overview is showing: +/// a chart of how much each Window left unused, then the list, newest first, +/// which also carries Limits running low and Tracking changes. +struct ResetsAndPerksSection: View { let events: [OverviewEvent] + let range: TimeRange let now: Date /// Off by default: a 5-hour session resets several times a day, and those @@ -17,9 +19,10 @@ struct EventsSection: View { var body: some View { let events = shown + let chart = ResetsChartData(events: events, range: range, now: now) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { - Text("Events") + Text("Resets & Perks") .font(.headline) Spacer() Toggle("Show session resets", isOn: $showSessionResets) @@ -29,10 +32,24 @@ struct EventsSection: View { .foregroundStyle(.secondary) } + if !chart.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ResetsChartLegend() + ResetsChart(data: chart, unit: range.bucketComponent) + .frame(height: 220) + } + .padding(18) + .background(.background, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.separator, lineWidth: 1) + } + } + if events.isEmpty { ContentUnavailableView( - "No events in this range", - systemImage: "list.bullet", + "No resets or Perks in this range", + systemImage: "chart.bar", description: Text(showSessionResets ? "Resets, Perks and Tracking changes your Bar Apps record show up here." : "Resets, Perks and Tracking changes your Bar Apps record show up here. Session resets are hidden.") diff --git a/Sources/BarUI/ResetsChart.swift b/Sources/BarUI/ResetsChart.swift new file mode 100644 index 0000000..e6e75b9 --- /dev/null +++ b/Sources/BarUI/ResetsChart.swift @@ -0,0 +1,152 @@ +import BarUICore +import Charts +import SwiftUI + +/// A bar per reset, as tall as the share of the Window left unused, so a tall +/// bar is a Window that was barely touched; Perk changes sit as diamonds above +/// the bars. Spans the same range as the Tokens chart. +struct ResetsChart: View { + let data: ResetsChartData + let unit: Calendar.Component + + @State private var pointerDate: Date? + + /// Resets at the same moment (ClaudeBar's Weekly and Weekly Fable) would + /// draw on top of each other; each after the first moves right by this + /// share of the range so they stand side by side. + private static let sideBySideShare = 0.008 + /// Diamonds sit above the 100% line, clear of the tallest bar. + private static let perkRowValue = 112.0 + /// How close, as a share of the range, the pointer must be to pick a mark. + private static let pickShare = 0.03 + + private struct PlacedBar: Identifiable { + let bar: ResetsChartData.Bar + let x: Date + var id: String { bar.id } + } + + private var placedBars: [PlacedBar] { + let span = data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) + var seen: [Date: Int] = [:] + return data.bars.map { bar in + let index = seen[bar.date, default: 0] + seen[bar.date] = index + 1 + return PlacedBar(bar: bar, x: bar.date.addingTimeInterval(Double(index) * span * Self.sideBySideShare)) + } + } + + private enum Picked { + case bar(ResetsChartData.Bar) + case perk(ResetsChartData.PerkMarker) + + var date: Date { + switch self { + case .bar(let bar): bar.date + case .perk(let perk): perk.date + } + } + } + + private var picked: Picked? { + guard let pointerDate else { + return nil + } + let reach = data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) * Self.pickShare + let candidates = data.bars.map(Picked.bar) + data.perks.map(Picked.perk) + return candidates + .map { ($0, abs($0.date.timeIntervalSince(pointerDate))) } + .filter { $0.1 <= reach } + .min { $0.1 < $1.1 }? + .0 + } + + var body: some View { + Chart { + ForEach(placedBars) { placed in + BarMark( + x: .value("Reset", placed.x), + y: .value("Unused", placed.bar.unusedPercent), + width: .fixed(10) + ) + .foregroundStyle(by: .value("Bar App", placed.bar.app.displayName)) + .cornerRadius(3) + } + ForEach(data.perks) { perk in + PointMark(x: .value("Perk", perk.date), y: .value("Perk", Self.perkRowValue)) + .symbol(.diamond) + .symbolSize(70) + .foregroundStyle(perk.app.brandColor) + } + if let picked { + HoverRule(date: picked.date, extent: data.domain) { + callout(for: picked) + } + } + } + .chartXScale(domain: data.domain) + .chartYScale(domain: 0...(Self.perkRowValue + 8)) + .chartYAxis { + AxisMarks(position: .trailing, values: [0, 50, 100]) { value in + AxisGridLine() + AxisValueLabel { + if let percent = value.as(Double.self) { + Text("\(Int(percent))%") + } + } + } + } + .edgeSafeTimeAxis(unit: unit, span: data.domain, desiredCount: 6) + .chartForegroundStyleScale( + domain: BarApp.allCases.map(\.displayName), + range: BarApp.allCases.map(\.brandColor) + ) + .chartLegend(.hidden) + .chartXSelection(value: $pointerDate) + .accessibilityLabel("Unused at each reset, \(data.bars.count) resets and \(data.perks.count) Perk changes") + } + + private func callout(for picked: Picked) -> some View { + ChartCallout { + switch picked { + case .bar(let bar): + Text("\(bar.app.displayName) · \(bar.limitName) reset") + .fontWeight(.semibold) + Text("\(DisplayFormat.points(Int(bar.unusedPercent.rounded()))) unused") + Text(bar.date.formatted(date: .abbreviated, time: .shortened)) + .foregroundStyle(.secondary) + case .perk(let perk): + Text("\(perk.app.displayName) · \(perk.title)") + .fontWeight(.semibold) + Text(perk.detail) + Text(perk.date.formatted(date: .abbreviated, time: .shortened)) + .foregroundStyle(.secondary) + } + } + } +} + +/// The chart's key: each Bar App's colour and the Perk diamond. +struct ResetsChartLegend: View { + var body: some View { + HStack(spacing: 16) { + Text("How much of each Window went unused when it reset") + .foregroundStyle(.secondary) + Spacer(minLength: 8) + ForEach(BarApp.allCases, id: \.self) { app in + Label { + Text(app.displayName) + } icon: { + RoundedRectangle(cornerRadius: 2).fill(app.brandColor).frame(width: 10, height: 10) + } + } + Label { + Text("Perk change") + } icon: { + Image(systemName: "diamond.fill").foregroundStyle(.secondary) + } + } + .font(.caption) + .labelStyle(.titleAndIcon) + } +} diff --git a/Sources/BarUICore/ArchivePage.swift b/Sources/BarUICore/ArchivePage.swift index 3243b06..387d052 100644 --- a/Sources/BarUICore/ArchivePage.swift +++ b/Sources/BarUICore/ArchivePage.swift @@ -4,7 +4,7 @@ public enum ArchivePage: String, CaseIterable, Sendable, Hashable { public var title: String { switch self { - case .events: "Events" + case .events: "Resets & Perks" case .imports: "Imports" } } diff --git a/Sources/BarUICore/OverviewEvent.swift b/Sources/BarUICore/OverviewEvent.swift index ccf0c95..1b1799e 100644 --- a/Sources/BarUICore/OverviewEvent.swift +++ b/Sources/BarUICore/OverviewEvent.swift @@ -22,6 +22,11 @@ public struct OverviewEvent: Sendable, Hashable, Identifiable { public let date: Date /// The reset Limit's Window length; nil for every other kind of event. public var windowMinutes: Int? = nil + /// How much of the Window went unused at a reset, 0 to 100; nil for every + /// other kind of event. The Resets & Perks chart plots it. + public var unusedPercent: Double? = nil + /// The reset Limit's name ("Weekly"); nil for every other kind of event. + public var limitName: String? = nil /// A Window shorter than this resets often enough to drown out the rest /// of the list, so its resets are hidden unless asked for. @@ -65,7 +70,9 @@ public struct OverviewEvent: Sendable, Hashable, Identifiable { title: "\(reset.limit.name) reset", detail: "\(Int((100 - reset.utilizationLast).rounded()))% unused at reset", date: Date(unixNano: reset.timeUnixNano), - windowMinutes: history.limit.windowDurationMinutes + windowMinutes: history.limit.windowDurationMinutes, + unusedPercent: 100 - reset.utilizationLast, + limitName: reset.limit.name )) } events += crossings(in: history) diff --git a/Sources/BarUICore/ResetsChartData.swift b/Sources/BarUICore/ResetsChartData.swift new file mode 100644 index 0000000..2f554c1 --- /dev/null +++ b/Sources/BarUICore/ResetsChartData.swift @@ -0,0 +1,63 @@ +import Foundation + +/// What the Resets & Perks chart draws, from the same events the list below +/// it shows: a bar per reset, as tall as the share of the Window left unused, +/// and a marker per Perk change. Running low, Limits appearing and Tracking +/// starting stay in the list only. +public struct ResetsChartData: Sendable, Equatable { + public struct Bar: Sendable, Equatable, Identifiable { + public let id: String + public let app: BarApp + public let limitName: String + public let date: Date + public let unusedPercent: Double + } + + public struct PerkMarker: Sendable, Equatable, Identifiable { + public let id: String + public let app: BarApp + public let title: String + public let detail: String + public let date: Date + } + + public let bars: [Bar] + public let perks: [PerkMarker] + /// The range, ending now, so the chart lines up with the Tokens chart; + /// All time reaches back to the oldest thing drawn. + public let domain: ClosedRange + + public var isEmpty: Bool { + bars.isEmpty && perks.isEmpty + } + + public init(events: [OverviewEvent], range: TimeRange, now: Date) { + var bars: [Bar] = [] + var perks: [PerkMarker] = [] + for event in events { + switch event.kind { + case .limitReset: + guard let unused = event.unusedPercent else { + continue + } + bars.append(Bar( + id: event.id, + app: event.app, + limitName: event.limitName ?? event.title, + date: event.date, + unusedPercent: min(max(unused, 0), 100) + )) + case .perkAvailable, .perkGone: + perks.append(PerkMarker(id: event.id, app: event.app, title: event.title, detail: event.detail, date: event.date)) + case .limitAppeared, .limitDisappeared, .runningLow, .nearLimit, .trackingStarted: + continue + } + } + self.bars = bars.sorted { $0.date < $1.date } + self.perks = perks.sorted { $0.date < $1.date } + + let oldest = (bars.map(\.date) + perks.map(\.date)).min() + let start = range.start(now: now) ?? oldest ?? now + domain = min(start, now)...now + } +} diff --git a/Tests/BarUICoreTests/ResetsChartDataTests.swift b/Tests/BarUICoreTests/ResetsChartDataTests.swift new file mode 100644 index 0000000..a8fe51b --- /dev/null +++ b/Tests/BarUICoreTests/ResetsChartDataTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import BarUICore + +@Suite("Resets & Perks chart") +struct ResetsChartDataTests { + private static let now = Date(timeIntervalSince1970: 1_792_000_000) + private static let weekly = LimitDescriptor(id: "codex:10080", name: "Weekly", windowDurationMinutes: 10_080, modelID: nil) + private static let session = LimitDescriptor(id: "session", name: "Session (5h)", windowDurationMinutes: 300, modelID: nil) + + private static func reset(_ limit: LimitDescriptor, app: BarApp, used: Double, hoursAgo: Double) -> LimitReset { + LimitReset( + app: app, + limit: limit, + timeUnixNano: Int64((now.timeIntervalSince1970 - hoursAgo * 3_600) * 1_000_000_000), + utilizationLast: used, + nextResetsAt: nil + ) + } + + private static func timeline(resets: [LimitReset], gone: [GonePerk] = [], tracking: [TrackingStartedEvent] = []) -> [OverviewEvent] { + let histories = Dictionary(grouping: resets) { "\($0.app.rawValue)/\($0.limit.id)" }.values.map { group in + LimitHistory(app: group[0].app, limit: group[0].limit, utilization: [], latest: nil, resets: group) + } + return OverviewEvent.timeline( + histories: histories, + limitEvents: [], + trackingStarts: tracking, + perkAvailable: [], + gonePerks: gone, + locale: Locale(identifier: "en_US"), + timeZone: TimeZone(identifier: "UTC") ?? .gmt + ) + } + + @Test("A reset event carries how much went unused and which Limit it was") + func resetCarriesUnusedAndLimit() throws { + let events = Self.timeline(resets: [Self.reset(Self.weekly, app: .codexBar, used: 71.5, hoursAgo: 5)]) + + let reset = try #require(events.first) + #expect(reset.unusedPercent == 28.5) + #expect(reset.limitName == "Weekly") + } + + @Test("Resets become bars and Perk changes become markers; other events stay list-only") + func barsAndMarkers() { + let gone = GonePerk( + app: .codexBar, + kind: .resetCredit, + currency: nil, + quantity: 1, + perkID: "a", + expiresAt: Self.now.addingTimeInterval(-48 * 3_600), + goneAt: Self.now.addingTimeInterval(-48 * 3_600), + reason: .expired + ) + let tracking = TrackingStartedEvent(app: .codexBar, intervalMinutes: 5, timeUnixNano: Int64((Self.now.timeIntervalSince1970 - 3_600) * 1_000_000_000)) + let events = Self.timeline( + resets: [Self.reset(Self.weekly, app: .codexBar, used: 2, hoursAgo: 5)], + gone: [gone], + tracking: [tracking] + ) + + let data = ResetsChartData(events: events, range: .week, now: Self.now) + + #expect(data.bars.count == 1) + #expect(data.bars.first?.unusedPercent == 98) + #expect(data.bars.first?.app == .codexBar) + #expect(data.bars.first?.limitName == "Weekly") + #expect(data.perks.count == 1) + #expect(data.perks.first?.date == gone.goneAt) + } + + @Test("The chart spans the range, ending now, so it lines up with the Tokens chart") + func domainIsTheRange() { + let data = ResetsChartData(events: [], range: .week, now: Self.now) + + #expect(data.domain == Self.now.addingTimeInterval(-7 * 86_400)...Self.now) + #expect(data.isEmpty) + } + + @Test("All time spans from the oldest reset or Perk to now") + func allTimeDomain() { + let events = Self.timeline(resets: [Self.reset(Self.weekly, app: .codexBar, used: 50, hoursAgo: 400)]) + + let data = ResetsChartData(events: events, range: .all, now: Self.now) + + #expect(data.domain.lowerBound == Self.now.addingTimeInterval(-400 * 3_600)) + #expect(data.domain.upperBound == Self.now) + } + + @Test("Session resets the list hides aren't bars either") + func hiddenSessionResets() { + let events = Self.timeline(resets: [ + Self.reset(Self.session, app: .claudeBar, used: 10, hoursAgo: 3), + Self.reset(Self.weekly, app: .codexBar, used: 2, hoursAgo: 5) + ]) + + let data = ResetsChartData(events: OverviewEvent.hidingShortWindowResets(events), range: .week, now: Self.now) + + #expect(data.bars.map(\.limitName) == ["Weekly"]) + } +} diff --git a/Tests/BarUICoreTests/SidebarTests.swift b/Tests/BarUICoreTests/SidebarTests.swift index 53d0f1a..2f82d1b 100644 --- a/Tests/BarUICoreTests/SidebarTests.swift +++ b/Tests/BarUICoreTests/SidebarTests.swift @@ -24,7 +24,7 @@ struct SidebarTests { func itemTitles() { let titles = SidebarSection.all.flatMap(\.items).map(\.title) - #expect(titles == ["Overview", "ClaudeBar", "CodexBar", "Events", "Imports"]) + #expect(titles == ["Overview", "ClaudeBar", "CodexBar", "Resets & Perks", "Imports"]) } @Test("Item ids are unique and stable") From c5daf364cb26ac7e7d557136ca0d71b89bb16a82 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 28 Oct 2026 07:56:34 +1000 Subject: [PATCH 2/4] Drop the per-reset labels on Limit History A Session Window resets several times a day, so across a week the "N% unused" label over every reset marker piled up into unreadable text. The dashed markers stay; hovering near one already shows how much went unused at that reset. --- Sources/BarUI/LimitHistoryView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/BarUI/LimitHistoryView.swift b/Sources/BarUI/LimitHistoryView.swift index 53be065..fd1c833 100644 --- a/Sources/BarUI/LimitHistoryView.swift +++ b/Sources/BarUI/LimitHistoryView.swift @@ -175,15 +175,13 @@ private struct LimitHistoryRowView: View { Chart { runMarks bridgeMarks + // Unlabelled: a Session Window resets several times a day, and a + // label per marker piled up into unreadable text across a week. + // Hovering near a reset shows how much went unused. ForEach(row.resetMarkers, id: \.date) { marker in RuleMark(x: .value("Reset", marker.date)) .foregroundStyle(.secondary.opacity(0.6)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) - .annotation(position: .top, spacing: 2, overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) { - Text("\(Int(marker.unusedPercent.rounded()))% unused") - .font(.caption2) - .foregroundStyle(.secondary) - } } if let hover, let extent = dataExtent { HoverRule(date: hover.point.date, extent: extent) { From 815a131a32df1af5b9b15da1567307a0b4aca7dd Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 28 Oct 2026 10:24:21 +1000 Subject: [PATCH 3/4] Keep coincident resets at their real time and list them all on hover Bars that share a moment spread sideways in points instead of shifting in time, so none lands past now. Hover picks the nearest moment and lists every reset and Perk at it. The chart takes the Tokens chart's span so the two line up, and a reset inside a Tracking gap on Limit History shows its figure when hovered. --- Sources/BarUI/LimitHistoryView.swift | 18 +++++ Sources/BarUI/OverviewView.swift | 7 +- Sources/BarUI/ResetsAndPerksSection.swift | 4 +- Sources/BarUI/ResetsChart.swift | 66 +++++++------------ Sources/BarUICore/LimitHistoryRow+Hover.swift | 9 +++ Sources/BarUICore/ResetsChartData.swift | 40 +++++++++-- Tests/BarUICoreTests/LimitHoverTests.swift | 8 +++ .../BarUICoreTests/ResetsChartDataTests.swift | 34 ++++++++++ 8 files changed, 134 insertions(+), 52 deletions(-) diff --git a/Sources/BarUI/LimitHistoryView.swift b/Sources/BarUI/LimitHistoryView.swift index fd1c833..e008539 100644 --- a/Sources/BarUI/LimitHistoryView.swift +++ b/Sources/BarUI/LimitHistoryView.swift @@ -123,6 +123,16 @@ private struct LimitHistoryRowView: View { pointerDate.flatMap { row.hover(at: $0) } } + /// A reset inside a Tracking gap has no sample to hover, so near its + /// marker the readout shows the reset on its own. + private var gapReset: LimitHistoryRow.ResetMarker? { + guard hover == nil, let pointerDate, let extent = dataExtent else { + return nil + } + let reach = extent.upperBound.timeIntervalSince(extent.lowerBound) * 0.02 + return row.resetMarker(near: pointerDate, within: reach) + } + private var bridgedDates: Set { Set(row.bridges.flatMap { $0.map(\.date) }) } @@ -195,6 +205,14 @@ private struct LimitHistoryRowView: View { } PointMark(x: .value("Time", hover.point.date), y: .value("Used", hover.point.percent)) .foregroundStyle(brand) + } else if let gapReset, let extent = dataExtent { + HoverRule(date: gapReset.date, extent: extent) { + ChartCallout { + Text("Reset · \(gapReset.date.formatted(Self.hoverFormat))") + Text("\(DisplayFormat.points(gapReset.unusedPercent)) unused at reset") + .foregroundStyle(.secondary) + } + } } } .chartXSelection(value: $pointerDate) diff --git a/Sources/BarUI/OverviewView.swift b/Sources/BarUI/OverviewView.swift index ed3b0d0..e8c25bc 100644 --- a/Sources/BarUI/OverviewView.swift +++ b/Sources/BarUI/OverviewView.swift @@ -57,7 +57,12 @@ struct OverviewView: View { } LimitsStrip(cards: cards) TokensSection(overview: overview, now: now, range: $range) - ResetsAndPerksSection(events: overview.events(range: range, now: now), range: range, now: now) + ResetsAndPerksSection( + events: overview.events(range: range, now: now), + range: range, + now: now, + tokensDomain: overview.tokenUsage(range: range, now: now).timeDomain(component: range.bucketComponent) + ) } .padding(.horizontal, 32) .padding(.vertical, 16) diff --git a/Sources/BarUI/ResetsAndPerksSection.swift b/Sources/BarUI/ResetsAndPerksSection.swift index 1a6f38c..44242b0 100644 --- a/Sources/BarUI/ResetsAndPerksSection.swift +++ b/Sources/BarUI/ResetsAndPerksSection.swift @@ -8,6 +8,8 @@ struct ResetsAndPerksSection: View { let events: [OverviewEvent] let range: TimeRange let now: Date + /// The Tokens chart's span, so this chart's days sit under the same days. + let tokensDomain: ClosedRange? /// Off by default: a 5-hour session resets several times a day, and those /// rows would bury the weekly resets and everything else. @@ -19,7 +21,7 @@ struct ResetsAndPerksSection: View { var body: some View { let events = shown - let chart = ResetsChartData(events: events, range: range, now: now) + let chart = ResetsChartData(events: events, range: range, now: now, alignedTo: tokensDomain) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Text("Resets & Perks") diff --git a/Sources/BarUI/ResetsChart.swift b/Sources/BarUI/ResetsChart.swift index e6e75b9..85bfb28 100644 --- a/Sources/BarUI/ResetsChart.swift +++ b/Sources/BarUI/ResetsChart.swift @@ -11,66 +11,46 @@ struct ResetsChart: View { @State private var pointerDate: Date? + private static let barWidth = 10.0 /// Resets at the same moment (ClaudeBar's Weekly and Weekly Fable) would - /// draw on top of each other; each after the first moves right by this - /// share of the range so they stand side by side. - private static let sideBySideShare = 0.008 + /// draw on top of each other. They keep their real time on the axis and + /// spread sideways in points, so none is pushed past now or the range. + private static let sideBySideGap = 2.0 /// Diamonds sit above the 100% line, clear of the tallest bar. private static let perkRowValue = 112.0 - /// How close, as a share of the range, the pointer must be to pick a mark. - private static let pickShare = 0.03 private struct PlacedBar: Identifiable { let bar: ResetsChartData.Bar - let x: Date + let offset: CGFloat var id: String { bar.id } } private var placedBars: [PlacedBar] { - let span = data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) + let groupSize = Dictionary(grouping: data.bars, by: \.date).mapValues(\.count) var seen: [Date: Int] = [:] return data.bars.map { bar in let index = seen[bar.date, default: 0] seen[bar.date] = index + 1 - return PlacedBar(bar: bar, x: bar.date.addingTimeInterval(Double(index) * span * Self.sideBySideShare)) + let centred = Double(index) - Double(groupSize[bar.date, default: 1] - 1) / 2 + return PlacedBar(bar: bar, offset: centred * (Self.barWidth + Self.sideBySideGap)) } } - private enum Picked { - case bar(ResetsChartData.Bar) - case perk(ResetsChartData.PerkMarker) - - var date: Date { - switch self { - case .bar(let bar): bar.date - case .perk(let perk): perk.date - } - } - } - - private var picked: Picked? { - guard let pointerDate else { - return nil - } - let reach = data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) * Self.pickShare - let candidates = data.bars.map(Picked.bar) + data.perks.map(Picked.perk) - return candidates - .map { ($0, abs($0.date.timeIntervalSince(pointerDate))) } - .filter { $0.1 <= reach } - .min { $0.1 < $1.1 }? - .0 + private var moment: ResetsChartData.Moment? { + pointerDate.flatMap(data.moment(near:)) } var body: some View { Chart { ForEach(placedBars) { placed in BarMark( - x: .value("Reset", placed.x), + x: .value("Reset", placed.bar.date), y: .value("Unused", placed.bar.unusedPercent), - width: .fixed(10) + width: .fixed(Self.barWidth) ) .foregroundStyle(by: .value("Bar App", placed.bar.app.displayName)) .cornerRadius(3) + .offset(x: placed.offset) } ForEach(data.perks) { perk in PointMark(x: .value("Perk", perk.date), y: .value("Perk", Self.perkRowValue)) @@ -78,9 +58,9 @@ struct ResetsChart: View { .symbolSize(70) .foregroundStyle(perk.app.brandColor) } - if let picked { - HoverRule(date: picked.date, extent: data.domain) { - callout(for: picked) + if let moment { + HoverRule(date: moment.date, extent: data.domain) { + callout(for: moment) } } } @@ -106,22 +86,20 @@ struct ResetsChart: View { .accessibilityLabel("Unused at each reset, \(data.bars.count) resets and \(data.perks.count) Perk changes") } - private func callout(for picked: Picked) -> some View { + private func callout(for moment: ResetsChartData.Moment) -> some View { ChartCallout { - switch picked { - case .bar(let bar): + ForEach(moment.bars) { bar in Text("\(bar.app.displayName) · \(bar.limitName) reset") .fontWeight(.semibold) Text("\(DisplayFormat.points(Int(bar.unusedPercent.rounded()))) unused") - Text(bar.date.formatted(date: .abbreviated, time: .shortened)) - .foregroundStyle(.secondary) - case .perk(let perk): + } + ForEach(moment.perks) { perk in Text("\(perk.app.displayName) · \(perk.title)") .fontWeight(.semibold) Text(perk.detail) - Text(perk.date.formatted(date: .abbreviated, time: .shortened)) - .foregroundStyle(.secondary) } + Text(moment.date.formatted(date: .abbreviated, time: .shortened)) + .foregroundStyle(.secondary) } } } diff --git a/Sources/BarUICore/LimitHistoryRow+Hover.swift b/Sources/BarUICore/LimitHistoryRow+Hover.swift index e4052e4..c03bf2a 100644 --- a/Sources/BarUICore/LimitHistoryRow+Hover.swift +++ b/Sources/BarUICore/LimitHistoryRow+Hover.swift @@ -22,6 +22,15 @@ extension LimitHistoryRow { return Hover(point: point, reset: reset) } + /// The reset closest to `date` within `reach`, whatever samples sit near + /// it. A reset inside a Tracking gap has no sample to carry its figure, + /// so hovering near its marker has to find it on its own. + public func resetMarker(near date: Date, within reach: TimeInterval) -> ResetMarker? { + resetMarkers + .filter { abs($0.date.timeIntervalSince(date)) <= reach } + .min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } + } + static func nearestPoint(in segments: [[Point]], to date: Date) -> Point? { guard let firstDate = segments.first?.first?.date, let lastDate = segments.last?.last?.date else { return nil diff --git a/Sources/BarUICore/ResetsChartData.swift b/Sources/BarUICore/ResetsChartData.swift index 2f554c1..4222fee 100644 --- a/Sources/BarUICore/ResetsChartData.swift +++ b/Sources/BarUICore/ResetsChartData.swift @@ -21,17 +21,25 @@ public struct ResetsChartData: Sendable, Equatable { public let date: Date } + /// Everything drawn at one instant, so a hover can list resets and Perk + /// changes that share a timestamp instead of hiding all but one. + public struct Moment: Sendable, Equatable { + public let date: Date + public let bars: [Bar] + public let perks: [PerkMarker] + } + public let bars: [Bar] public let perks: [PerkMarker] - /// The range, ending now, so the chart lines up with the Tokens chart; - /// All time reaches back to the oldest thing drawn. + /// The Tokens chart's span when given, so the two line up; otherwise the + /// range ending now, with All time reaching back to the oldest mark. public let domain: ClosedRange public var isEmpty: Bool { bars.isEmpty && perks.isEmpty } - public init(events: [OverviewEvent], range: TimeRange, now: Date) { + public init(events: [OverviewEvent], range: TimeRange, now: Date, alignedTo tokensDomain: ClosedRange? = nil) { var bars: [Bar] = [] var perks: [PerkMarker] = [] for event in events { @@ -56,8 +64,28 @@ public struct ResetsChartData: Sendable, Equatable { self.bars = bars.sorted { $0.date < $1.date } self.perks = perks.sorted { $0.date < $1.date } - let oldest = (bars.map(\.date) + perks.map(\.date)).min() - let start = range.start(now: now) ?? oldest ?? now - domain = min(start, now)...now + if let tokensDomain { + domain = tokensDomain + } else { + let oldest = (bars.map(\.date) + perks.map(\.date)).min() + let start = range.start(now: now) ?? oldest ?? now + domain = min(start, now)...now + } + } + + /// The marks at the instant nearest `date`, if one is within 3% of the + /// chart's span; further than that the pointer isn't on anything. + public func moment(near date: Date) -> Moment? { + let reach = domain.upperBound.timeIntervalSince(domain.lowerBound) * 0.03 + let dates = bars.map(\.date) + perks.map(\.date) + guard let nearest = dates.min(by: { abs($0.timeIntervalSince(date)) < abs($1.timeIntervalSince(date)) }), + abs(nearest.timeIntervalSince(date)) <= reach else { + return nil + } + return Moment( + date: nearest, + bars: bars.filter { $0.date == nearest }, + perks: perks.filter { $0.date == nearest } + ) } } diff --git a/Tests/BarUICoreTests/LimitHoverTests.swift b/Tests/BarUICoreTests/LimitHoverTests.swift index b3ed82b..5f7d963 100644 --- a/Tests/BarUICoreTests/LimitHoverTests.swift +++ b/Tests/BarUICoreTests/LimitHoverTests.swift @@ -66,6 +66,14 @@ struct LimitHoverTests { #expect(row.hover(at: Self.date(21_300))?.reset == nil) } + @Test("Hovering near a reset in a gap still finds it, so its figure is never out of reach") + func resetInGapIsReachable() { + let row = Self.row(resets: [(10_000, 40)]) + + #expect(row.resetMarker(near: Self.date(10_300), within: 600)?.unusedPercent == 60) + #expect(row.resetMarker(near: Self.date(12_000), within: 600) == nil) + } + private static func nano(_ seconds: Int) -> Int64 { 1_791_000_000_000_000_000 + Int64(seconds) * 1_000_000_000 } diff --git a/Tests/BarUICoreTests/ResetsChartDataTests.swift b/Tests/BarUICoreTests/ResetsChartDataTests.swift index a8fe51b..3ff49d1 100644 --- a/Tests/BarUICoreTests/ResetsChartDataTests.swift +++ b/Tests/BarUICoreTests/ResetsChartDataTests.swift @@ -89,6 +89,40 @@ struct ResetsChartDataTests { #expect(data.domain.upperBound == Self.now) } + @Test("Given the Tokens chart's span, the chart uses exactly that, so the two line up") + func alignedToTokens() { + let tokens = Self.now.addingTimeInterval(-7 * 86_400 - 3_600)...Self.now.addingTimeInterval(9 * 3_600) + + let data = ResetsChartData(events: [], range: .week, now: Self.now, alignedTo: tokens) + + #expect(data.domain == tokens) + } + + @Test("Resets and a Perk at the same moment are one hover stop, listing them all") + func coincidentMarksShareAMoment() { + let fable = LimitDescriptor(id: "weekly_scoped:Fable", name: "Weekly — Fable", windowDurationMinutes: 10_080, modelID: nil) + let all = LimitDescriptor(id: "weekly_all", name: "Weekly (all models)", windowDurationMinutes: 10_080, modelID: nil) + let events = Self.timeline(resets: [ + Self.reset(all, app: .claudeBar, used: 27, hoursAgo: 80), + Self.reset(fable, app: .claudeBar, used: 15, hoursAgo: 80), + Self.reset(Self.weekly, app: .codexBar, used: 2, hoursAgo: 5) + ]) + let data = ResetsChartData(events: events, range: .week, now: Self.now) + + let moment = data.moment(near: Self.now.addingTimeInterval(-80 * 3_600 + 600)) + + #expect(Set(moment?.bars.map(\.limitName) ?? []) == ["Weekly (all models)", "Weekly — Fable"]) + #expect(moment?.date == Self.now.addingTimeInterval(-80 * 3_600)) + } + + @Test("Hovering far from every mark picks nothing") + func nothingNearby() { + let events = Self.timeline(resets: [Self.reset(Self.weekly, app: .codexBar, used: 2, hoursAgo: 5)]) + let data = ResetsChartData(events: events, range: .week, now: Self.now) + + #expect(data.moment(near: Self.now.addingTimeInterval(-100 * 3_600)) == nil) + } + @Test("Session resets the list hides aren't bars either") func hiddenSessionResets() { let events = Self.timeline(resets: [ From 16e3fa7d97ac4bb6744d9b47f87126eb620a9d24 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 28 Oct 2026 10:29:07 +1000 Subject: [PATCH 4/4] Keep every reset reachable on both charts Side-by-side reset bars no longer stack onto each other. All time reaches back to resets older than the first tokens, and Limit History's hover works for resets in a range with no samples. --- Sources/BarUI/LimitHistoryView.swift | 6 +++++- Sources/BarUI/ResetsChart.swift | 5 ++++- Sources/BarUICore/ResetsChartData.swift | 7 +++++-- Tests/BarUICoreTests/ResetsChartDataTests.swift | 10 ++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/BarUI/LimitHistoryView.swift b/Sources/BarUI/LimitHistoryView.swift index e008539..25a82ae 100644 --- a/Sources/BarUI/LimitHistoryView.swift +++ b/Sources/BarUI/LimitHistoryView.swift @@ -137,8 +137,12 @@ private struct LimitHistoryRowView: View { Set(row.bridges.flatMap { $0.map(\.date) }) } + /// Covers reset markers too: a Limit can log resets in a range with no + /// samples, and those markers still need an axis and a hover readout. private var dataExtent: ClosedRange? { - guard let first = row.segments.first?.first?.date, let last = row.segments.last?.last?.date else { + let dates = [row.segments.first?.first?.date, row.segments.last?.last?.date].compactMap { $0 } + + row.resetMarkers.map(\.date) + guard let first = dates.min(), let last = dates.max() else { return nil } return first...last diff --git a/Sources/BarUI/ResetsChart.swift b/Sources/BarUI/ResetsChart.swift index 85bfb28..7620c94 100644 --- a/Sources/BarUI/ResetsChart.swift +++ b/Sources/BarUI/ResetsChart.swift @@ -46,7 +46,10 @@ struct ResetsChart: View { BarMark( x: .value("Reset", placed.bar.date), y: .value("Unused", placed.bar.unusedPercent), - width: .fixed(Self.barWidth) + width: .fixed(Self.barWidth), + // Coincident bars share an x value, and stacking would + // lift each onto the one before it. + stacking: .unstacked ) .foregroundStyle(by: .value("Bar App", placed.bar.app.displayName)) .cornerRadius(3) diff --git a/Sources/BarUICore/ResetsChartData.swift b/Sources/BarUICore/ResetsChartData.swift index 4222fee..98fb165 100644 --- a/Sources/BarUICore/ResetsChartData.swift +++ b/Sources/BarUICore/ResetsChartData.swift @@ -64,10 +64,13 @@ public struct ResetsChartData: Sendable, Equatable { self.bars = bars.sorted { $0.date < $1.date } self.perks = perks.sorted { $0.date < $1.date } + let oldest = (bars.map(\.date) + perks.map(\.date)).min() if let tokensDomain { - domain = tokensDomain + // All time's token history can start after the first reset; the + // chart still has to reach back to every mark it draws. + let start = min(tokensDomain.lowerBound, oldest ?? tokensDomain.lowerBound) + domain = start...tokensDomain.upperBound } else { - let oldest = (bars.map(\.date) + perks.map(\.date)).min() let start = range.start(now: now) ?? oldest ?? now domain = min(start, now)...now } diff --git a/Tests/BarUICoreTests/ResetsChartDataTests.swift b/Tests/BarUICoreTests/ResetsChartDataTests.swift index 3ff49d1..3d6043c 100644 --- a/Tests/BarUICoreTests/ResetsChartDataTests.swift +++ b/Tests/BarUICoreTests/ResetsChartDataTests.swift @@ -98,6 +98,16 @@ struct ResetsChartDataTests { #expect(data.domain == tokens) } + @Test("A reset older than the first tokens still fits, so token history never hides it") + func alignedReachesOlderMarks() { + let tokens = Self.now.addingTimeInterval(-24 * 3_600)...Self.now + let events = Self.timeline(resets: [Self.reset(Self.weekly, app: .codexBar, used: 50, hoursAgo: 400)]) + + let data = ResetsChartData(events: events, range: .all, now: Self.now, alignedTo: tokens) + + #expect(data.domain == Self.now.addingTimeInterval(-400 * 3_600)...Self.now) + } + @Test("Resets and a Perk at the same moment are one hover stop, listing them all") func coincidentMarksShareAMoment() { let fable = LimitDescriptor(id: "weekly_scoped:Fable", name: "Weekly — Fable", windowDurationMinutes: 10_080, modelID: nil)