diff --git a/Sources/BarUI/LimitHistoryView.swift b/Sources/BarUI/LimitHistoryView.swift index 53be065..25a82ae 100644 --- a/Sources/BarUI/LimitHistoryView.swift +++ b/Sources/BarUI/LimitHistoryView.swift @@ -123,12 +123,26 @@ 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) }) } + /// 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 @@ -175,15 +189,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) { @@ -197,6 +209,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 c13caf0..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) - EventsSection(events: overview.events(range: range, now: now), 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/EventsSection.swift b/Sources/BarUI/ResetsAndPerksSection.swift similarity index 71% rename from Sources/BarUI/EventsSection.swift rename to Sources/BarUI/ResetsAndPerksSection.swift index 1228e25..44242b0 100644 --- a/Sources/BarUI/EventsSection.swift +++ b/Sources/BarUI/ResetsAndPerksSection.swift @@ -1,11 +1,15 @@ 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 + /// 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. @@ -17,9 +21,10 @@ struct EventsSection: View { var body: some View { let events = shown + let chart = ResetsChartData(events: events, range: range, now: now, alignedTo: tokensDomain) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { - Text("Events") + Text("Resets & Perks") .font(.headline) Spacer() Toggle("Show session resets", isOn: $showSessionResets) @@ -29,10 +34,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..7620c94 --- /dev/null +++ b/Sources/BarUI/ResetsChart.swift @@ -0,0 +1,133 @@ +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? + + private static let barWidth = 10.0 + /// Resets at the same moment (ClaudeBar's Weekly and Weekly Fable) would + /// 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 + + private struct PlacedBar: Identifiable { + let bar: ResetsChartData.Bar + let offset: CGFloat + var id: String { bar.id } + } + + private var placedBars: [PlacedBar] { + 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 + let centred = Double(index) - Double(groupSize[bar.date, default: 1] - 1) / 2 + return PlacedBar(bar: bar, offset: centred * (Self.barWidth + Self.sideBySideGap)) + } + } + + private var moment: ResetsChartData.Moment? { + pointerDate.flatMap(data.moment(near:)) + } + + var body: some View { + Chart { + ForEach(placedBars) { placed in + BarMark( + x: .value("Reset", placed.bar.date), + y: .value("Unused", placed.bar.unusedPercent), + 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) + .offset(x: placed.offset) + } + 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 moment { + HoverRule(date: moment.date, extent: data.domain) { + callout(for: moment) + } + } + } + .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 moment: ResetsChartData.Moment) -> some View { + ChartCallout { + ForEach(moment.bars) { bar in + Text("\(bar.app.displayName) · \(bar.limitName) reset") + .fontWeight(.semibold) + Text("\(DisplayFormat.points(Int(bar.unusedPercent.rounded()))) unused") + } + ForEach(moment.perks) { perk in + Text("\(perk.app.displayName) · \(perk.title)") + .fontWeight(.semibold) + Text(perk.detail) + } + Text(moment.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/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/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..98fb165 --- /dev/null +++ b/Sources/BarUICore/ResetsChartData.swift @@ -0,0 +1,94 @@ +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 + } + + /// 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 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, alignedTo tokensDomain: ClosedRange? = nil) { + 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() + if let 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 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 new file mode 100644 index 0000000..3d6043c --- /dev/null +++ b/Tests/BarUICoreTests/ResetsChartDataTests.swift @@ -0,0 +1,147 @@ +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("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("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) + 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: [ + 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")