codeburn/mac/Sources/CodeBurnMenubar/Data/CapacityDockGlanceWindow.swift
ozymandiashh 5b5dc5aceb feat(menubar): switch the Capacity Dock gauge between usage windows
The resting rail shows one number per provider, and that number was always
the weekly (else monthly) billing window. A user watching Claude's 5-hour
limit had to expand the dock and read the glance card every time, which
defeats the point of an always-visible usage monitor.

Clicking the provider that already rests in the rail now switches its gauge
to the provider's short rolling window and back. The preference is stored per
provider rather than as a Claude-only switch: the dock's collapsed row is
provider-agnostic, and Codex publishes a 5-hour and a weekly window too, so
the question is "which horizon for this provider", stored under
CodeBurnCapacityDockGlanceWindows alongside the other dock preferences.

The two horizons are named rather than labelled, so the policy works for any
adapter: billing reuses QuotaSummary.headlineWindow, the app's single
definition of the weekly/monthly glance, and burst matches the short rolling
rows the adapters emit (5-hour, Hourly, Daily, session) while excluding
anything weekly or monthly, so Claude's per-model "Weekly · Opus" row can
never be read as a short window. CapacityDockGlanceWindow keeps that policy
pure and testable, including the fallbacks: a provider with one window shows
it whichever horizon is stored, and a stored horizon the provider stops
reporting resolves to the one it does report instead of blanking the gauge.

The click cannot fight the hover expansion: this branch only ever pins, never
collapses, and it is reached only when the provider actually has two windows,
so a single-window provider keeps the existing click-to-pin toggle. Escape
and an outside click still unpin. Keyboard and VoiceOver users get the same
switch as a named accessibility action on the provider cell, and the cell's
accessibility value now names the window feeding the ring. The rail's
geometry is untouched.

Closes #1243
2026-09-12 06:38:34 -07:00

123 lines
5.1 KiB
Swift

import Foundation
/// Which usage horizon one Capacity Dock gauge reports. Several providers
/// publish both a short rolling window and a longer billing window — Claude's
/// 5-hour and weekly limits, Codex's primary and secondary rate windows — and
/// the resting rail has room for exactly one number. The kinds are deliberately
/// provider-agnostic: the dock's collapsed row is one control for every
/// provider, so the preference is "which horizon" rather than "which label".
enum CapacityDockGlanceWindowKind: String, CaseIterable, Sendable {
/// The weekly (else monthly) billing horizon. The dock's original glance,
/// and the default for every provider.
case billing
/// The provider's short rolling window: 5-hour, session, hourly, daily.
case burst
var other: Self {
switch self {
case .billing: .burst
case .burst: .billing
}
}
/// Generic name for the horizon, used where no concrete window label is at
/// hand. Prefer the resolved window's own label when one exists.
var displayName: String {
switch self {
case .billing: "weekly"
case .burst: "5-hour"
}
}
}
/// Pure policy for the Capacity Dock glance: which of a provider's quota
/// windows its gauge reports, given the stored preference and the windows the
/// provider actually publishes, and what a click on the gauge switches to.
///
/// Kept out of the views so the fallbacks are testable: a stored preference can
/// outlive the window it named (a plan change, a provider that stops reporting
/// its 5-hour limit), and the gauge must never go blank because of it.
enum CapacityDockGlanceWindow {
/// Labels that mark a billing horizon. Checked first, so Claude's
/// "Weekly · Opus" is never mistaken for a short window.
private static let billingNeedles = ["week", "month"]
/// Labels that mark a short rolling window across the provider adapters:
/// "5-hour", "Hourly", "Daily", "Current session".
private static let burstNeedles = ["hour", "session", "daily", "today", "minute"]
/// Every window the provider published, `primary` included once.
static func candidates(_ quota: QuotaSummary?) -> [QuotaSummary.Window] {
guard let quota else { return [] }
var candidates = quota.details
if let primary = quota.primary, !candidates.contains(primary) {
candidates.append(primary)
}
return candidates
}
/// The window a kind names, or nil when the provider publishes nothing for
/// that horizon.
static func window(
_ kind: CapacityDockGlanceWindowKind,
quota: QuotaSummary?
) -> QuotaSummary.Window? {
switch kind {
// The billing horizon has one definition for the whole app; reuse it
// rather than teaching the dock a second idea of "the headline".
case .billing: quota?.headlineWindow
case .burst: burstWindow(quota)
}
}
/// The kind the gauge actually draws. The stored choice wins when the
/// provider reports that horizon; otherwise the other one does, so a
/// provider with a single window still shows a number instead of `--`.
static func resolvedKind(
preferred: CapacityDockGlanceWindowKind,
quota: QuotaSummary?
) -> CapacityDockGlanceWindowKind {
if window(preferred, quota: quota) != nil { return preferred }
if window(preferred.other, quota: quota) != nil { return preferred.other }
return preferred
}
static func resolvedWindow(
preferred: CapacityDockGlanceWindowKind,
quota: QuotaSummary?
) -> QuotaSummary.Window? {
window(resolvedKind(preferred: preferred, quota: quota), quota: quota)
}
/// Whether this provider has two distinct horizons to switch between. A
/// provider that reports one window — or whose short window *is* its
/// billing window — offers nothing to switch, so the gauge keeps its
/// existing click behaviour there.
static func isSwitchable(quota: QuotaSummary?) -> Bool {
guard let billing = window(.billing, quota: quota),
let burst = window(.burst, quota: quota) else { return false }
return billing != burst
}
/// What a click on the gauge selects. A provider with one usable horizon
/// keeps the kind it is already drawing, so the click is a no-op rather
/// than a stored change that redraws nothing.
static func next(
after preferred: CapacityDockGlanceWindowKind,
quota: QuotaSummary?
) -> CapacityDockGlanceWindowKind {
let resolved = resolvedKind(preferred: preferred, quota: quota)
guard isSwitchable(quota: quota) else { return resolved }
return resolved.other
}
private static func burstWindow(_ quota: QuotaSummary?) -> QuotaSummary.Window? {
candidates(quota).first { window in
guard !matches(window.label, billingNeedles) else { return false }
return matches(window.label, burstNeedles)
}
}
private static func matches(_ label: String, _ needles: [String]) -> Bool {
needles.contains { label.range(of: $0, options: .caseInsensitive) != nil }
}
}