diff --git a/CHANGELOG.md b/CHANGELOG.md index d69640b69..12d8acf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Added (macOS) +- **The Capacity Dock and the agent-tab quota card now say whether a window will last to its reset, not just when it resets.** Each quota window gets one line under the bar: `Lasts until reset` while the linear projection lands at or under 100% at reset, `Runs out in 2d 8h` when it overflows on a window long enough for a whole-window rate to be defensible, and `Won't last until reset` when it overflows on a window of 6 hours or less, which keeps the run-out ETA suppressed there rather than crying wolf over one burst. The verdict is the same projection the Plan tab's deficit/reserve caption uses, so the two surfaces cannot disagree, and it stays silent in the first 3% of a window, on an exhausted window, with no reset time, or when the window's own label does not imply a length (a Copilot premium-requests bucket, say). The dock reserves the line's height whether or not a column has a verdict, so a projection crossing in or out of silence cannot resize the bubble under the pointer. (#1215) + ## 0.9.24 - 2026-09-04 ### Added diff --git a/docs/design/capacity-dock.md b/docs/design/capacity-dock.md index 0d7bc1729..df04a2383 100644 --- a/docs/design/capacity-dock.md +++ b/docs/design/capacity-dock.md @@ -101,6 +101,13 @@ V1 does not include: countdown. The most constrained available window supplies the ring value; this matches the reference's glance-first use and avoids understating a provider whose secondary window is closer to exhaustion. +- Under that, one line says whether the window lasts: `Lasts until reset`, + `Runs out in 2d 8h`, or `Won't last until reset` on windows of 6 hours or + less, where a linear run-out ETA is not defensible. It is the same projection + the Plan tab's pace caption uses, and it is silent early in a window, on an + exhausted window, without a reset time, or when the window's label implies no + length. Its height is reserved whether or not a column has a verdict, because + the panel's frame is computed rather than fitted. - Stale or retrying data remains visible and is labeled/dimmed. A terminal authentication/configuration failure provides a Connect/Reconnect action in the bubble itself. Network, rate-limit, parse, and provider outages remain diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift index 01ff031f4..1ead2a96c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift @@ -61,3 +61,116 @@ enum QuotaPace { ) } } + +extension QuotaPace { + /// The one-line "am I going to make it?" answer for surfaces with no room + /// for the Plan tab's deficit/reserve caption: the Capacity Dock's window + /// columns and the agent-tab hover card (#1215). Same projection as #726 — + /// `evaluate` stays the only math here — reduced to a verdict. + struct Verdict: Equatable { + let text: String + /// Drives the warning tint; the text alone already says which way it went. + let willOverflow: Bool + } + + static func verdict( + usedPercent: Double, + resetsAt: Date?, + windowSeconds: Int, + now: Date = Date() + ) -> Verdict? { + guard let result = evaluate( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + now: now + ) else { return nil } + guard result.willOverflow else { + return Verdict(text: "Lasts until reset", willOverflow: false) + } + // An overflowing window without an ETA is one `evaluate` suppressed for + // being too short to extrapolate (<= 6h). Say it won't last rather than + // invent a minute-precision deadline off a single burst. + guard let hitsLimitAt = result.hitsLimitAt else { + return Verdict(text: "Won't last until reset", willOverflow: true) + } + let countdown = countdownLabel(seconds: hitsLimitAt.timeIntervalSince(now)) + return Verdict(text: "Runs out in \(countdown)", willOverflow: true) + } + + /// "2d 3h" / "4h 12m" / "8m" / "now" — the same shape as the reset + /// countdown both surfaces already print beside the bar. + static func countdownLabel(seconds: TimeInterval) -> String { + let seconds = max(0, seconds) + if seconds < 60 { return "now" } + let minutes = Int(seconds / 60) + let hours = minutes / 60 + let days = hours / 24 + if days > 0 { return "\(days)d \(hours % 24)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } + + /// Window length implied by a normalized provider label ("Weekly", + /// "5-hour", "Weekly · Opus", "Monthly usage limit"). The adapters derive + /// those labels from the exact API durations, so the round trip is lossless + /// except for calendar months, which are measured back from the reset date + /// in UTC the way the Codex spend window is (a local calendar would make + /// pace timezone-dependent). An unrecognized label returns nil, which keeps + /// the verdict silent rather than guessing a window. + static func inferredWindowSeconds(label: String, resetsAt: Date?) -> Int? { + for component in label.components(separatedBy: "·") { + if let seconds = windowSeconds(forComponent: component, resetsAt: resetsAt) { + return seconds + } + } + return nil + } + + private static func windowSeconds(forComponent component: String, resetsAt: Date?) -> Int? { + let token = component.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Only the leading word carries the duration: the credit window's label + // is "Monthly usage limit", not "Monthly". + guard let head = token.components(separatedBy: " ").first, !head.isEmpty else { return nil } + switch head { + case "minutely": return 60 + case "hourly", "hour": return 3600 + case "daily": return 86_400 + case "weekly": return 604_800 + case "monthly": return calendarMonthSeconds(endingAt: resetsAt, months: 1) + default: break + } + // "5-hour", "3-day", "2-week", "90-min", and the spelled-out form some + // providers send ("Five-hour"). + let parts = head.components(separatedBy: "-") + guard parts.count == 2, let count = wholeNumber(parts[0]), count > 0 else { return nil } + switch parts[1] { + case "min", "mins", "minute", "minutes": return count * 60 + case "hour", "hours": return count * 3600 + case "day", "days": return count * 86_400 + case "week", "weeks": return count * 604_800 + case "month", "months": return calendarMonthSeconds(endingAt: resetsAt, months: count) + default: return nil + } + } + + private static let spelledNumbers = [ + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12 + ] + + private static func wholeNumber(_ text: String) -> Int? { + Int(text) ?? spelledNumbers[text] + } + + /// Mirrors `CodexSubscriptionService.monthlyWindowSeconds`: the window is + /// the month preceding the reset, measured in UTC. + private static func calendarMonthSeconds(endingAt resetsAt: Date?, months: Int) -> Int? { + guard let resetsAt, months > 0 else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + guard let start = calendar.date(byAdding: .month, value: -months, to: resetsAt) else { return nil } + let seconds = Int(resetsAt.timeIntervalSince(start)) + return seconds > 0 ? seconds : nil + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 70a21b359..42b8f741f 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -100,14 +100,24 @@ extension QuotaSummary.Window { /// Human-readable countdown like "2h 11m" or "3d 14h" or "now". var resetsInLabel: String { guard let resetsAt else { return "" } - let seconds = max(0, resetsAt.timeIntervalSinceNow) - if seconds < 60 { return "now" } - let minutes = Int(seconds / 60) - let hours = minutes / 60 - let days = hours / 24 - if days > 0 { return "\(days)d \(hours % 24)h" } - if hours > 0 { return "\(hours)h \(minutes % 60)m" } - return "\(minutes)m" + return QuotaPace.countdownLabel(seconds: resetsAt.timeIntervalSinceNow) + } + + /// One-line pace verdict drawn under the bar in the Capacity Dock and the + /// agent-tab hover card (#1215). Nil means say nothing: no reset time, a + /// label with no inferable window length, too early in the window, the + /// window already exhausted, or clock skew — see `QuotaPace`. + func paceVerdict(now: Date = Date()) -> QuotaPace.Verdict? { + guard let windowSeconds = QuotaPace.inferredWindowSeconds( + label: label, + resetsAt: resetsAt + ) else { return nil } + return QuotaPace.verdict( + usedPercent: percent * 100, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + now: now + ) } var percentLabel: String { diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index a166cedda..fe8f4acf2 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -447,28 +447,46 @@ private struct QuotaDetailPopover: View { private struct QuotaDetailRow: View { let window: QuotaSummary.Window + /// The label column's width. The verdict hangs under the bar, so it is + /// indented by this plus the row's own spacing. + private static let labelWidth: CGFloat = 92 + private static let rowSpacing: CGFloat = 8 + var body: some View { - HStack(spacing: 8) { - Text(window.label) - .font(.system(size: 10.5)) - .frame(width: 92, alignment: .leading) - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.secondary.opacity(0.18)) - Capsule() - .fill(barColor) - .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: Self.rowSpacing) { + Text(window.label) + .font(.system(size: 10.5)) + .frame(width: Self.labelWidth, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + } + } + .frame(height: 4) + Text(window.percentLabel) + .font(.codeMono(size: 10.5, weight: .medium)) + .frame(width: 36, alignment: .trailing) + if !window.resetsInLabel.isEmpty { + Text(window.resetsInLabel) + .font(.codeMono(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 50, alignment: .trailing) } } - .frame(height: 4) - Text(window.percentLabel) - .font(.codeMono(size: 10.5, weight: .medium)) - .frame(width: 36, alignment: .trailing) - if !window.resetsInLabel.isEmpty { - Text(window.resetsInLabel) - .font(.codeMono(size: 10)) - .foregroundStyle(.secondary) - .frame(width: 50, alignment: .trailing) + // Will this window last to its reset (#1215)? The card's height is + // fitted, so a silent verdict collapses rather than leaving a gap. + if let verdict = window.paceVerdict() { + Text(verdict.text) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(verdict.willOverflow + ? AnyShapeStyle(Color.orange) + : AnyShapeStyle(.tertiary)) + .lineLimit(1) + .padding(.leading, Self.labelWidth + Self.rowSpacing) } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index e3f758421..204da8b19 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -138,8 +138,12 @@ enum CapacityDockGlance { /// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the /// 17pt burned figure beside it, so it sets the row. static let todayContentHeight: CGFloat = 44 - /// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom. - static let windowsHeight: CGFloat = 77 + /// A 9.5pt verdict's line box. Reserved on every window column whether or + /// not that column has a verdict to print, so the panel keeps one height + /// while the projection crosses in and out of silence (#1215). + static let verdictLine: CGFloat = 12 + /// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 2 + 12 verdict + 16 bottom. + static let windowsHeight: CGFloat = 91 /// 8 top + one secondary line + 16 bottom. static let windowsEmptyHeight: CGFloat = 37 /// The staleness or reconnect line under the header. It is a section like any @@ -1012,6 +1016,7 @@ struct CapacityDockDetailView: View { alignment: HorizontalAlignment ) -> some View { let s = model.detailScale + let verdict = window.paceVerdict() VStack(alignment: alignment, spacing: 0) { PercentGaugeText( label: window.percentLabel, @@ -1032,6 +1037,19 @@ struct CapacityDockDetailView: View { .lineLimit(1) .frame(height: 12 * s) .padding(.top, 2 * s) + // Will this window last to its reset (#1215)? The panel's height is + // computed rather than fitted, so the line keeps its box even when + // there is no verdict: a column that went quiet must not resize the + // bubble under the pointer. + Text(verdict?.text ?? "") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(verdict?.willOverflow == true + ? AnyShapeStyle(Color.orange) + : AnyShapeStyle(Color.capacityDockText.opacity(0.45))) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(height: CapacityDockGlance.verdictLine * s) + .padding(.top, 2 * s) } .frame( maxWidth: .infinity, diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift index 60834bf11..b0aa5c40a 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift @@ -208,14 +208,14 @@ struct CapacityDockGlanceTests { + CapacityDockGlance.todayHeight + CapacityDockGlance.windowsHeight ) - // 44 header + 83 sessions + 81 today + 77 windows - #expect(full == 285) + // 44 header + 83 sessions + 81 today + 91 windows + #expect(full == 299) // The panel opens and closes on the same 16pt inset it uses sideways. let headerParts: CGFloat = CapacityDockGlance.contentInset + 20 + 8 #expect(CapacityDockGlance.headerHeight == headerParts) #expect( CapacityDockGlance.windowsHeight - == CapacityDockGlance.sectionPadTop + 53 + CapacityDockGlance.contentInset + == CapacityDockGlance.sectionPadTop + 67 + CapacityDockGlance.contentInset ) // Today is three stacked lines (13 + 3 + 13 + 3 + 12) inside its padding. #expect(CapacityDockGlance.todayContentHeight == 44) @@ -223,7 +223,7 @@ struct CapacityDockGlanceTests { // Past four sessions the list scrolls, so the panel stops growing. let capped = height(4, hasToday: true, windows: three) #expect(height(12, hasToday: true, windows: three) == capped) - #expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 77) + #expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 91) // Each section is independently droppable. #expect(full - height(nil, hasToday: true, windows: three) == CapacityDockGlance.sessionsHeight(count: 1)) #expect(full - height(1, hasToday: false, windows: three) == CapacityDockGlance.todayHeight) diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaPaceVerdictTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaPaceVerdictTests.swift new file mode 100644 index 000000000..e9c2599c7 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaPaceVerdictTests.swift @@ -0,0 +1,182 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +/// The one-line verdict the Capacity Dock columns and the agent-tab hover card +/// draw under the bar (#1215). Pure mapping of (utilization, elapsed fraction, +/// window length) to a string, so every outcome and every silence is testable +/// without a view. +final class QuotaPaceVerdictTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private let week = 7 * 24 * 3600 + private let fiveHours = 5 * 3600 + private let sixHours = 6 * 3600 + private let twelveHours = 12 * 3600 + + /// resetsAt such that `fraction` of the window has elapsed at `now`. + private func resets(afterElapsedFraction fraction: Double, windowSeconds: Int) -> Date { + now.addingTimeInterval(TimeInterval(windowSeconds) * (1 - fraction)) + } + + private func verdict( + usedPercent: Double, + elapsedFraction: Double, + windowSeconds: Int + ) -> QuotaPace.Verdict? { + QuotaPace.verdict( + usedPercent: usedPercent, + resetsAt: resets(afterElapsedFraction: elapsedFraction, windowSeconds: windowSeconds), + windowSeconds: windowSeconds, + now: now + ) + } + + // MARK: - The four outcomes + + func testUnderPaceLastsUntilReset() { + let v = verdict(usedPercent: 40, elapsedFraction: 0.5, windowSeconds: week) + XCTAssertEqual(v?.text, "Lasts until reset") + XCTAssertEqual(v?.willOverflow, false) + } + + func testExactlyOnPaceStillLastsUntilReset() { + // Projected 100% at reset is not an overflow: the window holds. + let v = verdict(usedPercent: 50, elapsedFraction: 0.5, windowSeconds: week) + XCTAssertEqual(v?.text, "Lasts until reset") + XCTAssertEqual(v?.willOverflow, false) + } + + func testWeeklyOverflowGetsTheRunOutETA() { + // 60% at half a week: 40% left at 60%/3.5d runs out 2d 8h from now. + let v = verdict(usedPercent: 60, elapsedFraction: 0.5, windowSeconds: week) + XCTAssertEqual(v?.text, "Runs out in 2d 8h") + XCTAssertEqual(v?.willOverflow, true) + } + + func testMonthlyOverflowGetsTheRunOutETA() { + // A calendar month measured back from the reset, as the credit window is. + let resetsAt = now.addingTimeInterval(15 * 86_400) + let windowSeconds = QuotaPace.inferredWindowSeconds( + label: "Monthly usage limit", + resetsAt: resetsAt + ) + XCTAssertNotNil(windowSeconds) + let v = QuotaPace.verdict( + usedPercent: 90, + resetsAt: resetsAt, + windowSeconds: windowSeconds ?? 0, + now: now + ) + XCTAssertEqual(v?.willOverflow, true) + XCTAssertTrue(v?.text.hasPrefix("Runs out in ") == true, "got \(v?.text ?? "nil")") + } + + func testOverflowJustOverTheSuppressionBoundaryStillGetsTheETA() { + // 12h window: long enough for a linear ETA, so it reads as a deadline. + let v = verdict(usedPercent: 60, elapsedFraction: 0.5, windowSeconds: twelveHours) + XCTAssertEqual(v?.text, "Runs out in 4h 0m") + XCTAssertEqual(v?.willOverflow, true) + } + + func testShortWindowOverflowWontLastAndKeepsTheETASuppressed() { + let v = verdict(usedPercent: 90, elapsedFraction: 0.5, windowSeconds: fiveHours) + XCTAssertEqual(v?.text, "Won't last until reset") + XCTAssertEqual(v?.willOverflow, true) + } + + func testSixHourWindowIsStillInsideTheSuppressionGuard() { + let v = verdict(usedPercent: 90, elapsedFraction: 0.5, windowSeconds: sixHours) + XCTAssertEqual(v?.text, "Won't last until reset") + } + + // MARK: - Silence + + func testEarlyInTheWindowSaysNothing() { + // Same 3% threshold the Plan tab's caption uses: projecting a whole + // week off the first few minutes is noise. + XCTAssertNil(verdict(usedPercent: 5, elapsedFraction: 0.02, windowSeconds: week)) + XCTAssertNil(verdict(usedPercent: 80, elapsedFraction: 0.01, windowSeconds: week)) + // Just past it the verdict appears. + XCTAssertNotNil(verdict(usedPercent: 5, elapsedFraction: 0.05, windowSeconds: week)) + } + + func testExhaustedUnknownResetAndSkewSayNothing() { + XCTAssertNil(verdict(usedPercent: 100, elapsedFraction: 0.5, windowSeconds: week)) + XCTAssertNil(QuotaPace.verdict( + usedPercent: 50, resetsAt: nil, windowSeconds: week, now: now + )) + XCTAssertNil(QuotaPace.verdict( + usedPercent: 50, resetsAt: now.addingTimeInterval(-60), windowSeconds: week, now: now + )) + } + + // MARK: - Countdown wording + + func testCountdownLabelShape() { + XCTAssertEqual(QuotaPace.countdownLabel(seconds: 30), "now") + XCTAssertEqual(QuotaPace.countdownLabel(seconds: -10), "now") + XCTAssertEqual(QuotaPace.countdownLabel(seconds: 8 * 60), "8m") + XCTAssertEqual(QuotaPace.countdownLabel(seconds: 4 * 3600 + 12 * 60), "4h 12m") + XCTAssertEqual(QuotaPace.countdownLabel(seconds: 2 * 86_400 + 3 * 3600), "2d 3h") + } + + // MARK: - Window length inference from the provider's own label + + func testLabelInference() { + func seconds(_ label: String, resetsAt: Date? = nil) -> Int? { + QuotaPace.inferredWindowSeconds(label: label, resetsAt: resetsAt) + } + XCTAssertEqual(seconds("Weekly"), 604_800) + XCTAssertEqual(seconds("Weekly · Opus"), 604_800) + XCTAssertEqual(seconds("5-hour"), 5 * 3600) + XCTAssertEqual(seconds("GPT-5.3-Codex-Spark · 5-hour"), 5 * 3600) + XCTAssertEqual(seconds("Gemini Models · Five-hour"), 5 * 3600) + XCTAssertEqual(seconds("Daily"), 86_400) + XCTAssertEqual(seconds("Hourly"), 3600) + XCTAssertEqual(seconds("30-day"), 30 * 86_400) + XCTAssertEqual(seconds("90-min"), 90 * 60) + // Calendar months come back from the reset date in UTC, so February is + // shorter than March, and no reset means no window. + let march = Date(timeIntervalSince1970: 1_772_323_200) // 2026-03-01T00:00:00Z + XCTAssertEqual(seconds("Monthly", resetsAt: march), 28 * 86_400) + XCTAssertEqual(seconds("Monthly usage limit · limit reached", resetsAt: march), 28 * 86_400) + XCTAssertNil(seconds("Monthly")) + // Labels that name a bucket, not a duration, stay silent. + XCTAssertNil(seconds("Premium requests")) + XCTAssertNil(seconds("Team pool")) + XCTAssertNil(seconds("Credits")) + XCTAssertNil(seconds("gemini-3-pro")) + XCTAssertNil(seconds("Rate Limit")) + } + + // MARK: - The window the two surfaces actually hand it + + func testQuotaSummaryWindowVerdict() { + // percent is 0...1 on the presentation type, not 0...100. + let overflowing = QuotaSummary.Window( + label: "Weekly", + percent: 0.60, + resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week) + ) + XCTAssertEqual(overflowing.paceVerdict(now: now)?.text, "Runs out in 2d 8h") + let comfortable = QuotaSummary.Window( + label: "Weekly · Sonnet", + percent: 0.30, + resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: week) + ) + XCTAssertEqual(comfortable.paceVerdict(now: now)?.text, "Lasts until reset") + let short = QuotaSummary.Window( + label: "5-hour", + percent: 0.90, + resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: fiveHours) + ) + XCTAssertEqual(short.paceVerdict(now: now)?.text, "Won't last until reset") + // No window length to infer, so the row keeps its reset countdown only. + let unlabelled = QuotaSummary.Window( + label: "Premium requests", + percent: 0.90, + resetsAt: resets(afterElapsedFraction: 0.5, windowSeconds: 30 * 86_400) + ) + XCTAssertNil(unlabelled.paceVerdict(now: now)) + } +}