mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
feat(bar-app): bar-first quota card with sane time formatting
Replace the prose window lines with an aligned per-window bar list (5h / weekly / Opus / Sonnet), colored by headroom, with the binding window highlighted and a compact reset chip. Humanize durations beyond a day (Nd Nh instead of raw hours) and only show the burn-rate pace clause when a window is projected to exhaust before it resets, so a meaningless 'resets in 44h' / '~110h left' projection no longer appears.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
/// 1h–24h → "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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user