Merge pull request #1483 from kaitranntt/kai/feat/ccs-bar-quota-card-visual

feat(bar): bar-first quota card + sane time formatting
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-09 15:07:42 -04:00
committed by GitHub
4 changed files with 309 additions and 177 deletions
@@ -17,20 +17,20 @@ enum BarCardFormatting {
return plain.date(from: iso)
}
/// Compact reset form for the Opus/Sonnet split + secondary lines:
/// <24h "2h 18m"
/// <7d weekday, e.g. "Fri"
/// >=7d "Jun 14"
/// Compact reset form for the per-window bar chips and split lines:
/// <24h compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m")
/// <7d weekday abbreviation (e.g. "Fri")
/// >=7d calendar date (e.g. "Jun 14")
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
static func shortReset(iso: String?, now: Date) -> String? {
guard let iso, let date = isoDate(iso) else { return nil }
let secs = date.timeIntervalSince(now)
if secs <= 0 { return "due" }
if secs < 24 * 3600 {
let total = Int(secs / 60)
let h = total / 60
let m = total % 60
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
// Delegate to Core's authoritative compactDuration so both layers are
// consistent and the days-tier is automatically handled if ever needed.
let totalMinutes = Int(secs / 60)
return BarQuotaGauge.compactDuration(minutes: totalMinutes)
}
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
@@ -1,44 +1,35 @@
import SwiftUI
import CCSBarCore
/// Dedicated, detailed card for a native first-party subscription (Claude Code /
/// Codex). Replaces the cramped generic `BarRowView` for native rows so the
/// user's own plan quota reads as the cockpit hero: a single-column vertical
/// stack, one fact per line, no 2-column flex wrap.
/// Dedicated card for a first-party subscription (Claude Code / Codex).
///
/// Why this exists separately from `BarRowView`: the generic row double-labelled
/// the product, showed a confusing "no data" cost cell, and carried pause/solo +
/// tier-lock controls that make no sense for a non-routable flat-rate
/// subscription. Stripping those four elements and stacking the windows
/// vertically is the de-cramping fix. All binding/burn math is pulled fresh at
/// render from `BarQuotaGauge` (pure Core) so the pace clause reflects `now`.
/// Design goal: bar-first, glanceable in under a second. Each quota window is
/// rendered as an aligned row:
/// <label> [] 41% 9h 2m
///
/// The binding window (the one the subscription runs out of first) is
/// highlighted and is the only place where the at-risk pace warning appears.
/// Verbose prose lines ("week window · resets in ...") are removed entirely.
struct BarSubscriptionCard: View {
let row: BarSummaryRow
/// Injected so the pace/countdown math is deterministic; defaults to the live
/// clock in production and is pinned in previews.
/// Injected clock defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
/// Windows decoded from the row; empty for error/reauth rows (no hero gauge).
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
/// The window the subscription runs out of first (lowest remaining). Drives the
/// hero gauge; nil when there is no quota data (error/reauth row).
private var binding: QuotaWindowDetail? {
BarQuotaGauge.selectBindingWindow(windows)
}
var body: some View {
VStack(alignment: .leading, spacing: 7) {
VStack(alignment: .leading, spacing: 6) {
titleRow
if let binding {
heroBlock(binding)
if let secondary = secondaryWindow(excluding: binding) {
secondaryLine(secondary)
}
opusSonnetLines
staleFootnote
} else {
if windows.isEmpty {
emptyState
} else {
windowBarList
staleFootnote
}
}
.padding(.vertical, 9)
@@ -49,11 +40,10 @@ struct BarSubscriptionCard: View {
in: RoundedRectangle(cornerRadius: 9))
}
// MARK: Title
// MARK: Title row
/// Product name + tier chip. No provider-chip echo, no "subscription" badge
/// (the section header carries that), no pause toggle, no overflow menu
/// those four removals are the de-cramping fix.
/// Health dot + product name + reauth chip + tier chip. No pause toggle
/// subscriptions are not routable pool accounts.
private var titleRow: some View {
HStack(spacing: 6) {
Circle()
@@ -72,148 +62,174 @@ struct BarSubscriptionCard: View {
}
}
// MARK: Hero (binding window)
// MARK: Window bar list
private func heroBlock(_ w: QuotaWindowDetail) -> some View {
/// All quota windows rendered as aligned bar rows, binding window highlighted.
private var windowBarList: some View {
// Ordered display: core windows first (5h, week), then Opus/Sonnet.
let ordered = orderedWindows
let bindingKey = binding?.key
return VStack(alignment: .leading, spacing: 3) {
ForEach(ordered) { w in
windowBarRow(w, isBinding: w.key == bindingKey)
}
}
}
/// Stable display order: five_hour seven_day seven_day_opus seven_day_sonnet.
private var orderedWindows: [QuotaWindowDetail] {
windows.sorted { a, b in
keyRank(a.key) < keyRank(b.key)
}
}
private func keyRank(_ key: String) -> Int {
switch key {
case "five_hour": return 0
case "seven_day": return 1
case "seven_day_opus": return 2
case "seven_day_sonnet": return 3
default: return 4
}
}
/// One bar row: short label | bar | remaining% | reset chip | [atRisk warning].
///
/// Layout uses fixed column widths so bars across rows align vertically,
/// making headroom comparisons instant.
private func windowBarRow(_ w: QuotaWindowDetail, isBinding: Bool) -> some View {
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
return VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .center, spacing: 8) {
heroGauge(fill: fill, color: color(for: band))
Text("\(Int(w.remainingPercent.rounded()))% left")
.font(.system(.callout, design: .rounded).weight(.semibold))
.foregroundStyle(color(for: band))
}
Text(heroFacts(w))
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
let barColor = color(for: band)
let isAtRisk = isBinding && BarQuotaGauge.atRisk(
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
now: now)
/// Full-width hero gauge bar, filled by the remaining fraction and tinted by
/// the severity band (reuses Core band/fill math).
private func heroGauge(fill: Double, color: Color) -> some View {
GeometryReader { geo in
return HStack(spacing: 0) {
// Short label: max 5 chars to keep alignment tight.
Text(shortLabel(for: w))
.font(
isBinding
? .system(.caption2, design: .monospaced).weight(.semibold)
: .system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? .primary : .secondary)
.frame(width: 28, alignment: .leading)
// Horizontal fill bar wider than the old secondary thinBar so fine
// gradations are visible. Remaining fraction fills from the left so a
// full bar = healthy, an empty bar = exhausted.
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.12))
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
Capsule()
.fill(color)
.frame(width: max(3, geo.size.width * fill))
.fill(barColor)
.frame(width: max(2, 88 * fill))
}
.frame(width: 88, height: isBinding ? 6 : 4)
Spacer(minLength: 5)
// Remaining percentage monospaced so digits are column-stable.
Text("\(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? barColor : .secondary)
.frame(width: 28, alignment: .trailing)
Spacer(minLength: 5)
// Compact reset chip terse duration or calendar date.
resetChip(for: w, isBinding: isBinding)
// At-risk warning: shown only on the binding window when pace says we
// will exhaust before the reset. Kept compact ( + duration) so the
// row does not blow out to a second line.
if isAtRisk, let pace = paceWarningText(for: w) {
Text(pace)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.orange)
.lineLimit(1)
.padding(.leading, 5)
}
}
.frame(height: 7)
.frame(maxWidth: .infinity)
}
/// One wrapped facts line: "<label> window · resets <countdown> · <pace>".
/// When pace is nil the line ends after the reset countdown; when the limit is
/// reached the pace clause replaces the bare countdown.
private func heroFacts(_ w: QuotaWindowDetail) -> String {
let pace = BarQuotaGauge.paceClause(
/// Terse window label for the bar list, at most 4-5 chars:
/// five_hour "5h"
/// seven_day "wk"
/// seven_day_opus "Son" (sic this is the Opus sub-budget inside the week)
/// seven_day_sonnet "Son"
///
/// Fall back to the backend-supplied label truncated to 5 chars so unknown
/// future keys still render acceptably.
private func shortLabel(for w: QuotaWindowDetail) -> String {
switch w.key {
case "five_hour": return "5h"
case "seven_day": return "wk"
case "seven_day_opus": return "Opus"
case "seven_day_sonnet": return "Son"
default:
let s = w.label
return s.count <= 5 ? s : String(s.prefix(4)) + ""
}
}
/// Compact reset chip: muted small text showing how long until the window
/// refreshes. Uses BarCardFormatting.shortReset which delegates to Core's
/// compactDuration for <24h durations (days-tier included).
private func resetChip(for w: QuotaWindowDetail, isBinding: Bool) -> some View {
Group {
if let t = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
Text(t)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? .secondary : .tertiary)
}
}
.frame(width: 42, alignment: .trailing)
}
/// Extract the "~Th Mm" part from paceClause for the at-risk inline warning.
/// Returns nil when paceClause returns nil or the limit-reached path fires
/// (those are handled by the bar color, not an extra label).
private func paceWarningText(for w: QuotaWindowDetail) -> String? {
guard let clause = BarQuotaGauge.paceClause(
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
status: row.quotaStatus,
now: now)
// The limit-reached pace clause already carries "resets in ...", so it
// stands in for the countdown rather than appending to it.
if let pace, pace.hasPrefix("limit reached") {
return "\(w.label) window · \(pace)"
}
var parts = ["\(w.label) window"]
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
parts.append(countdown)
}
if let pace { parts.append(pace) }
return parts.joined(separator: " · ")
now: now),
clause.hasPrefix("~")
else { return nil }
// Strip "left at this pace" suffix to keep the inline chip terse.
// "~2h 30m left at this pace" " ~2h 30m"
let core = clause
.replacingOccurrences(of: " left at this pace", with: "")
return "\(core)"
}
// MARK: Secondary window
// MARK: Stale footnote (Codex older-session data)
/// The other core window (the non-binding of five_hour / seven_day) shown as a
/// thin inline bar plus a compact remaining + reset line. Opus/Sonnet are NOT
/// secondary candidates they have their own split lines.
private func secondaryWindow(excluding binding: QuotaWindowDetail) -> QuotaWindowDetail? {
let core = windows.filter { $0.key == "five_hour" || $0.key == "seven_day" }
return core.first { $0.key != binding.key }
}
private func secondaryLine(_ w: QuotaWindowDetail) -> some View {
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
return HStack(spacing: 6) {
thinBar(fill: fill, color: color(for: band))
Text("\(w.label) \(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
Text("· \(countdown)")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
// MARK: Opus / Sonnet split (Claude Max only)
/// Indented Opus/Sonnet weekly sub-lines, present only when the row carries the
/// seven_day_opus / seven_day_sonnet windows (Claude Max). Omitted gracefully
/// for Pro / Codex.
@ViewBuilder private var opusSonnetLines: some View {
let opus = windows.first { $0.key == "seven_day_opus" }
let sonnet = windows.first { $0.key == "seven_day_sonnet" }
if opus != nil || sonnet != nil {
VStack(alignment: .leading, spacing: 2) {
if let opus { splitLine(title: "Opus", w: opus) }
if let sonnet { splitLine(title: "Sonnet", w: sonnet) }
}
}
}
private func splitLine(title: String, w: QuotaWindowDetail) -> some View {
HStack(spacing: 4) {
Text("\(title)")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("\(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if let short = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
Text("· resets \(short)")
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
// MARK: Stale footnote (Codex only)
/// Muted "as of <HH:mm> (older session)" caption when the Codex reading came
/// from an older session. The gauge/percent still render normally the data is
/// real, only its freshness is qualified. Never fakes a "live" badge.
/// "as of HH:mm (older session)" caption when the Codex reading came from an
/// older session. The bar still renders the data is real, just not live.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 9))
.foregroundStyle(.tertiary)
Text("as of \(clock) (older session)")
Text("as of \(clock), older session")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: Empty / error
// MARK: Empty / error state
/// No quota windows (reauth / error row): no hero gauge, just the honest
/// tri-state quota label. Never shows a "no data" cost cell.
/// No quota windows (reauth / error row): plain status text, no bar.
private var emptyState: some View {
Text(
row.needsReauth
@@ -224,15 +240,7 @@ struct BarSubscriptionCard: View {
.foregroundStyle(.secondary)
}
// MARK: Shared visuals
private func thinBar(fill: Double, color: Color) -> some View {
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.10))
Capsule().fill(color).frame(width: max(2, 40 * fill))
}
.frame(width: 40, height: 4)
}
// MARK: Shared helpers
private var healthColor: Color {
switch row.health {
+81 -5
View File
@@ -392,6 +392,17 @@ do {
check(a.monthToDate.cost != a.last30d.cost, "monthToDate is distinct from rolling last30d")
}
// MARK: compactDuration three-tier formatting
check(BarQuotaGauge.compactDuration(minutes: 35) == "35m", "compactDuration <1h -> minutes only")
check(BarQuotaGauge.compactDuration(minutes: 60) == "1h 0m", "compactDuration 1h exactly")
check(BarQuotaGauge.compactDuration(minutes: 195) == "3h 15m", "compactDuration hours+minutes")
// 44h 38m = 2678 min 1d 20h (44%24=20, 44/24=1)
check(BarQuotaGauge.compactDuration(minutes: 2678) == "1d 20h", "compactDuration 44h38m -> '1d 20h'")
// 110h 27m = 6627 min 4d 14h (110/24=4, 110%24=14)
check(BarQuotaGauge.compactDuration(minutes: 6627) == "4d 14h", "compactDuration 110h27m -> '4d 14h'")
check(BarQuotaGauge.compactDuration(minutes: 1440) == "1d 0h", "compactDuration exactly 24h -> '1d 0h'")
// MARK: BarQuotaGauge band + fillFraction + countdown
check(BarQuotaGauge.band(percentage: 82, status: "ok") == .green, "band >50 -> green")
@@ -426,6 +437,17 @@ do {
check(
BarQuotaGauge.resetCountdown(nextReset: iso12, now: now) == "resets in 12m",
"resetCountdown formats minutes-only")
// Days tier: >=24h -> "Nd Nh" 44h 38m -> "1d 20h", 110h 27m -> "4d 14h".
let in44h38m = now.addingTimeInterval(44 * 3600 + 38 * 60)
let iso44h38m = ISO8601DateFormatter().string(from: in44h38m)
check(
BarQuotaGauge.resetCountdown(nextReset: iso44h38m, now: now) == "resets in 1d 20h",
"resetCountdown >=24h -> days tier '1d 20h'")
let in110h27m = now.addingTimeInterval(110 * 3600 + 27 * 60)
let iso110h27m = ISO8601DateFormatter().string(from: in110h27m)
check(
BarQuotaGauge.resetCountdown(nextReset: iso110h27m, now: now) == "resets in 4d 14h",
"resetCountdown large duration '4d 14h'")
let past = now.addingTimeInterval(-60)
let isoPast = ISO8601DateFormatter().string(from: past)
check(
@@ -1106,14 +1128,25 @@ do {
do {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
// Finite projection -> "~Hh Mm left at this pace".
// At-risk scenario: window=300min, reset in 200min elapsed=100min.
// usedPercent=75 burn = (100-75)*100/75 33 min. 33 < 200 at-risk shows pace.
let resetIn200 = ISO8601DateFormatter().string(from: now.addingTimeInterval(200 * 60))
let finite = BarQuotaGauge.paceClause(
usedPercent: 50, remainingPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
check(finite == "~2h 30m left at this pace", "pace: finite -> '~2h 30m left at this pace'")
usedPercent: 75, remainingPercent: 25, resetAt: resetIn200, windowMinutes: 300, now: now)
check(finite?.hasPrefix("~") == true && finite?.hasSuffix("left at this pace") == true,
"pace: at-risk (burn < reset) -> shows pace clause")
// NOT at-risk: burn > minutesToReset pace must be nil (the core fix).
// window=300min, reset in 50min elapsed=250min. usedPercent=50
// burn = (100-50)*250/50 = 250 min. 250 > 50 NOT at risk nil.
let resetIn50 = ISO8601DateFormatter().string(from: now.addingTimeInterval(50 * 60))
let notAtRisk = BarQuotaGauge.paceClause(
usedPercent: 50, remainingPercent: 50, resetAt: resetIn50, windowMinutes: 300, now: now)
check(notAtRisk == nil, "pace: burn > reset (not at risk) -> nil (omit — core bug fix)")
// Lots of headroom -> "plenty at this pace".
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
let plenty = BarQuotaGauge.paceClause(
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
check(plenty == "plenty at this pace", "pace: >=85% remaining -> 'plenty at this pace'")
@@ -1131,7 +1164,8 @@ do {
"pace: 0% remaining -> 'limit reached, resets in ...'")
// < 5 min floor -> limit-reached path, never a scary "~0m".
// usedPercent 99 over 150min elapsed -> ~1.5 min remaining (<5).
// window=300min, reset in 150min elapsed=150min. usedPercent=99
// burn = (100-99)*150/99 1.5 min floor hit "limit reached".
let nearFloor = BarQuotaGauge.paceClause(
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
check(
@@ -1150,6 +1184,48 @@ do {
check(past == nil, "pace: past resetAt -> nil pace")
}
// MARK: atRisk boolean
do {
let now = Date(timeIntervalSince1970: 1_700_000_000)
// At-risk: burn(33m) < minutesToReset(200m) true.
let resetIn200 = ISO8601DateFormatter().string(from: now.addingTimeInterval(200 * 60))
let isAtRisk = BarQuotaGauge.atRisk(
usedPercent: 75, remainingPercent: 25, resetAt: resetIn200, windowMinutes: 300, now: now)
check(isAtRisk == true, "atRisk: burn < reset -> true")
// NOT at-risk: burn(250m) > minutesToReset(50m) false.
let resetIn50 = ISO8601DateFormatter().string(from: now.addingTimeInterval(50 * 60))
let notAtRisk = BarQuotaGauge.atRisk(
usedPercent: 50, remainingPercent: 50, resetAt: resetIn50, windowMinutes: 300, now: now)
check(notAtRisk == false, "atRisk: burn > reset -> false")
// Exhausted (remaining=0) false (handled by limit-reached path, not atRisk).
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
check(
BarQuotaGauge.atRisk(
usedPercent: 100, remainingPercent: 0, resetAt: resetIn150, windowMinutes: 300, now: now)
== false,
"atRisk: exhausted -> false")
// Unknown window false.
check(
BarQuotaGauge.atRisk(
usedPercent: 75, remainingPercent: 25, resetAt: nil, windowMinutes: nil, now: now)
== false,
"atRisk: unknown window -> false")
// Large weekly burn in screenshot scenario: weekly window resets in ~9h 2m (542 min),
// usedPercent=59 over elapsed=(10080-542)=9538 min burn (41*9538)/59 6626 min.
// 6626 > 542 NOT at risk (this was the nonsensical "~110h left" shown to user).
let resetIn542m = ISO8601DateFormatter().string(from: now.addingTimeInterval(542 * 60))
let weeklyNotAtRisk = BarQuotaGauge.atRisk(
usedPercent: 59, remainingPercent: 41, resetAt: resetIn542m, windowMinutes: 10080, now: now)
check(weeklyNotAtRisk == false,
"atRisk: weekly window resetting in 9h, burn projecting 100+h -> false (screenshot fix)")
}
// MARK: headroomLeader
do {
@@ -36,19 +36,17 @@ public enum BarQuotaGauge {
return min(1, max(0, pct / 100))
}
/// Human countdown to the next quota reset, e.g. "resets in 3h 12m",
/// "resets in 12m", or "resets soon" when the reset time is at/in the past.
/// Returns nil for a nil or unparseable timestamp. `now` is injected so the
/// formatting is deterministic and unit-testable.
/// Human countdown to the next quota reset, e.g. "resets in 1d 21h",
/// "resets in 3h 12m", "resets in 12m", or "resets soon" when the reset
/// time is at/in the past. Returns nil for nil/unparseable timestamps.
/// `now` is injected so the formatting is deterministic and unit-testable.
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
let secs = reset.timeIntervalSince(now)
if secs <= 0 { return "resets soon" }
let totalMinutes = Int(secs / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 { return "resets in \(hours)h \(minutes)m" }
return "resets in \(minutes)m"
// Three-tier: days (>=24h) hours+minutes (1h-24h) minutes-only (<1h).
return "resets in \(compactDuration(minutes: totalMinutes))"
}
// MARK: Burn-rate projection (single-window, no history)
@@ -113,6 +111,37 @@ public enum BarQuotaGauge {
}
}
/// Whether a window is GENUINELY at risk of exhaustion before it resets.
///
/// Returns true only when the projected exhaustion time (burn rate × remaining
/// headroom) is LESS than the time remaining until the next reset i.e. the
/// user will hit the wall before the window refreshes. When the projection is
/// larger than the reset countdown the warning is meaningless (the quota will
/// reset before running out), so atRisk returns false and no scary number is
/// shown. Inputs mirror `paceClause`; `now` is injected for testability.
public static func atRisk(
usedPercent: Double,
remainingPercent: Double,
resetAt: String?,
windowMinutes: Int?,
status: String = "ok",
now: Date
) -> Bool {
guard status != "rejected", remainingPercent > 0 else { return false }
guard let resetDateStr = resetAt,
let resetDate = BarFormatting.isoDate(resetDateStr)
else { return false }
let minutesToReset = resetDate.timeIntervalSince(now) / 60
guard minutesToReset > 0 else { return false }
guard let burn = burnMinutesRemaining(
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
else { return false }
// burn == 0 means already exhausted that is handled by the exhausted path, not atRisk.
guard burn > 0 else { return false }
// Only at-risk when we will exhaust BEFORE the window resets.
return Double(burn) < minutesToReset
}
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
///
/// Phrasing rules (in order):
@@ -120,11 +149,12 @@ public enum BarQuotaGauge {
/// resets in <countdown>". This REPLACES the bare reset countdown.
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil)
/// "plenty at this pace".
/// - a finite projection m >= 5 "~<Hh Mm> left at this pace".
/// - m < 5 routed to the limit-reached path (never a scary "~0m").
/// - at-risk (projected exhaustion BEFORE reset): a finite projection m >= 5
/// "~<Hh Mm> left at this pace". m < 5 limit-reached path.
/// - NOT at-risk (burn > minutesToReset): the projection is beyond the reset,
/// so showing the number is misleading return nil (omit entirely).
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) nil (omit).
/// - resetAt already in the past (clock skew / stale) nil pace; the
/// countdown elsewhere shows "reset due".
/// - resetAt already in the past (clock skew / stale) nil pace.
public static func paceClause(
usedPercent: Double,
remainingPercent: Double,
@@ -165,14 +195,32 @@ public enum BarQuotaGauge {
}
return "limit reached"
}
// Core at-risk gate: only show the projection when exhaustion is BEFORE the
// reset. If burn >= minutesToReset the quota will outlast the window and the
// number would be larger than the reset countdown meaningless and confusing.
let minutesToReset = resetDate.map { $0.timeIntervalSince(now) / 60 } ?? 0
guard Double(m) < minutesToReset else { return nil }
return "~\(compactDuration(minutes: m)) left at this pace"
}
/// Compact "Hh Mm" / "Mm" duration for pace phrasing (e.g. "2h 5m", "35m").
static func compactDuration(minutes: Int) -> String {
let h = minutes / 60
/// Compact terse duration with a three-tier scale:
/// >= 24h "Nd Nh" (e.g. 1590m "1d 2h", 2678m "1d 21h")
/// 1h24h "Hh Mm" (e.g. 195m "3h 15m")
/// < 1h "Mm" (e.g. 35m "35m")
///
/// Named `compactDuration` and `public` so it is reusable from App-side
/// formatting (BarCardFormatting) without duplicating the logic.
public static func compactDuration(minutes: Int) -> String {
let totalHours = minutes / 60
let m = minutes % 60
if h > 0 { return "\(h)h \(m)m" }
if totalHours >= 24 {
let d = totalHours / 24
let h = totalHours % 24
return "\(d)d \(h)h"
}
if totalHours > 0 { return "\(totalHours)h \(m)m" }
return "\(m)m"
}