Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions Sources/BarUI/LimitHistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
GordonBeeming marked this conversation as resolved.
Resolved
return row.resetMarker(near: pointerDate, within: reach)
}

private var bridgedDates: Set<Date> {
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<Date>? {
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
Expand Down Expand Up @@ -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]))
Comment thread
GordonBeeming marked this conversation as resolved.
Resolved
.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) {
Expand All @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion Sources/BarUI/OverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Date>?

/// Off by default: a 5-hour session resets several times a day, and those
/// rows would bury the weekly resets and everything else.
Expand All @@ -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)
Expand All @@ -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.")
Expand Down
133 changes: 133 additions & 0 deletions Sources/BarUI/ResetsChart.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion Sources/BarUICore/ArchivePage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/BarUICore/LimitHistoryRow+Hover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion Sources/BarUICore/OverviewEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading