mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 16:16:46 +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)
|
return plain.date(from: iso)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact reset form for the Opus/Sonnet split + secondary lines:
|
/// Compact reset form for the per-window bar chips and split lines:
|
||||||
/// <24h → "2h 18m"
|
/// <24h → compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m")
|
||||||
/// <7d → weekday, e.g. "Fri"
|
/// <7d → weekday abbreviation (e.g. "Fri")
|
||||||
/// >=7d → "Jun 14"
|
/// >=7d → calendar date (e.g. "Jun 14")
|
||||||
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
|
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
|
||||||
static func shortReset(iso: String?, now: Date) -> String? {
|
static func shortReset(iso: String?, now: Date) -> String? {
|
||||||
guard let iso, let date = isoDate(iso) else { return nil }
|
guard let iso, let date = isoDate(iso) else { return nil }
|
||||||
let secs = date.timeIntervalSince(now)
|
let secs = date.timeIntervalSince(now)
|
||||||
if secs <= 0 { return "due" }
|
if secs <= 0 { return "due" }
|
||||||
if secs < 24 * 3600 {
|
if secs < 24 * 3600 {
|
||||||
let total = Int(secs / 60)
|
// Delegate to Core's authoritative compactDuration so both layers are
|
||||||
let h = total / 60
|
// consistent and the days-tier is automatically handled if ever needed.
|
||||||
let m = total % 60
|
let totalMinutes = Int(secs / 60)
|
||||||
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
return BarQuotaGauge.compactDuration(minutes: totalMinutes)
|
||||||
}
|
}
|
||||||
let fmt = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
|
|
||||||
/// Dedicated, detailed card for a native first-party subscription (Claude Code /
|
/// Dedicated card for a first-party subscription (Claude Code / Codex).
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Why this exists separately from `BarRowView`: the generic row double-labelled
|
/// Design goal: bar-first, glanceable in under a second. Each quota window is
|
||||||
/// the product, showed a confusing "no data" cost cell, and carried pause/solo +
|
/// rendered as an aligned row:
|
||||||
/// tier-lock controls that make no sense for a non-routable flat-rate
|
/// <label> [████████░░░░] 41% 9h 2m
|
||||||
/// subscription. Stripping those four elements and stacking the windows
|
///
|
||||||
/// vertically is the de-cramping fix. All binding/burn math is pulled fresh at
|
/// The binding window (the one the subscription runs out of first) is
|
||||||
/// render from `BarQuotaGauge` (pure Core) so the pace clause reflects `now`.
|
/// 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 {
|
struct BarSubscriptionCard: View {
|
||||||
let row: BarSummaryRow
|
let row: BarSummaryRow
|
||||||
/// Injected so the pace/countdown math is deterministic; defaults to the live
|
/// Injected clock — defaults to live Date() in production, pinned in previews
|
||||||
/// clock in production and is pinned in previews.
|
/// and tests so countdown math is deterministic.
|
||||||
var now: Date = Date()
|
var now: Date = Date()
|
||||||
|
|
||||||
/// Windows decoded from the row; empty for error/reauth rows (no hero gauge).
|
|
||||||
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
|
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? {
|
private var binding: QuotaWindowDetail? {
|
||||||
BarQuotaGauge.selectBindingWindow(windows)
|
BarQuotaGauge.selectBindingWindow(windows)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
titleRow
|
titleRow
|
||||||
if let binding {
|
if windows.isEmpty {
|
||||||
heroBlock(binding)
|
|
||||||
if let secondary = secondaryWindow(excluding: binding) {
|
|
||||||
secondaryLine(secondary)
|
|
||||||
}
|
|
||||||
opusSonnetLines
|
|
||||||
staleFootnote
|
|
||||||
} else {
|
|
||||||
emptyState
|
emptyState
|
||||||
|
} else {
|
||||||
|
windowBarList
|
||||||
|
staleFootnote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
@@ -49,11 +40,10 @@ struct BarSubscriptionCard: View {
|
|||||||
in: RoundedRectangle(cornerRadius: 9))
|
in: RoundedRectangle(cornerRadius: 9))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Title
|
// MARK: Title row
|
||||||
|
|
||||||
/// Product name + tier chip. No provider-chip echo, no "subscription" badge
|
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
|
||||||
/// (the section header carries that), no pause toggle, no overflow menu —
|
/// subscriptions are not routable pool accounts.
|
||||||
/// those four removals are the de-cramping fix.
|
|
||||||
private var titleRow: some View {
|
private var titleRow: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
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 band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
|
||||||
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
|
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
|
||||||
return VStack(alignment: .leading, spacing: 5) {
|
let barColor = color(for: band)
|
||||||
HStack(alignment: .center, spacing: 8) {
|
let isAtRisk = isBinding && BarQuotaGauge.atRisk(
|
||||||
heroGauge(fill: fill, color: color(for: band))
|
usedPercent: w.usedPercent,
|
||||||
Text("\(Int(w.remainingPercent.rounded()))% left")
|
remainingPercent: w.remainingPercent,
|
||||||
.font(.system(.callout, design: .rounded).weight(.semibold))
|
resetAt: w.resetAt,
|
||||||
.foregroundStyle(color(for: band))
|
windowMinutes: w.windowMinutes,
|
||||||
}
|
now: now)
|
||||||
Text(heroFacts(w))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-width hero gauge bar, filled by the remaining fraction and tinted by
|
return HStack(spacing: 0) {
|
||||||
/// the severity band (reuses Core band/fill math).
|
// Short label: max 5 chars to keep alignment tight.
|
||||||
private func heroGauge(fill: Double, color: Color) -> some View {
|
Text(shortLabel(for: w))
|
||||||
GeometryReader { geo in
|
.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) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule().fill(Color.primary.opacity(0.12))
|
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(color)
|
.fill(barColor)
|
||||||
.frame(width: max(3, geo.size.width * fill))
|
.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>".
|
/// Terse window label for the bar list, at most 4-5 chars:
|
||||||
/// When pace is nil the line ends after the reset countdown; when the limit is
|
/// five_hour → "5h"
|
||||||
/// reached the pace clause replaces the bare countdown.
|
/// seven_day → "wk"
|
||||||
private func heroFacts(_ w: QuotaWindowDetail) -> String {
|
/// seven_day_opus → "Son" (sic — this is the Opus sub-budget inside the week)
|
||||||
let pace = BarQuotaGauge.paceClause(
|
/// 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,
|
usedPercent: w.usedPercent,
|
||||||
remainingPercent: w.remainingPercent,
|
remainingPercent: w.remainingPercent,
|
||||||
resetAt: w.resetAt,
|
resetAt: w.resetAt,
|
||||||
windowMinutes: w.windowMinutes,
|
windowMinutes: w.windowMinutes,
|
||||||
status: row.quotaStatus,
|
status: row.quotaStatus,
|
||||||
now: now)
|
now: now),
|
||||||
// The limit-reached pace clause already carries "resets in ...", so it
|
clause.hasPrefix("~")
|
||||||
// stands in for the countdown rather than appending to it.
|
else { return nil }
|
||||||
if let pace, pace.hasPrefix("limit reached") {
|
// Strip "left at this pace" suffix to keep the inline chip terse.
|
||||||
return "\(w.label) window · \(pace)"
|
// "~2h 30m left at this pace" → "⚠ ~2h 30m"
|
||||||
}
|
let core = clause
|
||||||
var parts = ["\(w.label) window"]
|
.replacingOccurrences(of: " left at this pace", with: "")
|
||||||
if let countdown = BarQuotaGauge.resetCountdown(nextReset: w.resetAt, now: now) {
|
return "⚠ \(core)"
|
||||||
parts.append(countdown)
|
|
||||||
}
|
|
||||||
if let pace { parts.append(pace) }
|
|
||||||
return parts.joined(separator: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
/// "as of HH:mm (older session)" caption when the Codex reading came from an
|
||||||
/// thin inline bar plus a compact remaining + reset line. Opus/Sonnet are NOT
|
/// older session. The bar still renders — the data is real, just not live.
|
||||||
/// 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.
|
|
||||||
@ViewBuilder private var staleFootnote: some View {
|
@ViewBuilder private var staleFootnote: some View {
|
||||||
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
|
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "clock.arrow.circlepath")
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Text("as of \(clock) (older session)")
|
Text("as of \(clock), older session")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Empty / error
|
// MARK: Empty / error state
|
||||||
|
|
||||||
/// No quota windows (reauth / error row): no hero gauge, just the honest
|
/// No quota windows (reauth / error row): plain status text, no bar.
|
||||||
/// tri-state quota label. Never shows a "no data" cost cell.
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
Text(
|
Text(
|
||||||
row.needsReauth
|
row.needsReauth
|
||||||
@@ -224,15 +240,7 @@ struct BarSubscriptionCard: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Shared visuals
|
// MARK: Shared helpers
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var healthColor: Color {
|
private var healthColor: Color {
|
||||||
switch row.health {
|
switch row.health {
|
||||||
|
|||||||
@@ -392,6 +392,17 @@ do {
|
|||||||
check(a.monthToDate.cost != a.last30d.cost, "monthToDate is distinct from rolling last30d")
|
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
|
// MARK: BarQuotaGauge band + fillFraction + countdown
|
||||||
|
|
||||||
check(BarQuotaGauge.band(percentage: 82, status: "ok") == .green, "band >50 -> green")
|
check(BarQuotaGauge.band(percentage: 82, status: "ok") == .green, "band >50 -> green")
|
||||||
@@ -426,6 +437,17 @@ do {
|
|||||||
check(
|
check(
|
||||||
BarQuotaGauge.resetCountdown(nextReset: iso12, now: now) == "resets in 12m",
|
BarQuotaGauge.resetCountdown(nextReset: iso12, now: now) == "resets in 12m",
|
||||||
"resetCountdown formats minutes-only")
|
"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 past = now.addingTimeInterval(-60)
|
||||||
let isoPast = ISO8601DateFormatter().string(from: past)
|
let isoPast = ISO8601DateFormatter().string(from: past)
|
||||||
check(
|
check(
|
||||||
@@ -1106,14 +1128,25 @@ do {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
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(
|
let finite = BarQuotaGauge.paceClause(
|
||||||
usedPercent: 50, remainingPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
|
usedPercent: 75, remainingPercent: 25, resetAt: resetIn200, windowMinutes: 300, now: now)
|
||||||
check(finite == "~2h 30m left at this pace", "pace: finite -> '~2h 30m left at this pace'")
|
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".
|
// Lots of headroom -> "plenty at this pace".
|
||||||
|
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
|
||||||
let plenty = BarQuotaGauge.paceClause(
|
let plenty = BarQuotaGauge.paceClause(
|
||||||
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
|
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||||
check(plenty == "plenty at this pace", "pace: >=85% remaining -> 'plenty at this pace'")
|
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 ...'")
|
"pace: 0% remaining -> 'limit reached, resets in ...'")
|
||||||
|
|
||||||
// < 5 min floor -> limit-reached path, never a scary "~0m".
|
// < 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(
|
let nearFloor = BarQuotaGauge.paceClause(
|
||||||
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
|
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||||
check(
|
check(
|
||||||
@@ -1150,6 +1184,48 @@ do {
|
|||||||
check(past == nil, "pace: past resetAt -> nil pace")
|
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
|
// MARK: headroomLeader
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -36,19 +36,17 @@ public enum BarQuotaGauge {
|
|||||||
return min(1, max(0, pct / 100))
|
return min(1, max(0, pct / 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human countdown to the next quota reset, e.g. "resets in 3h 12m",
|
/// Human countdown to the next quota reset, e.g. "resets in 1d 21h",
|
||||||
/// "resets in 12m", or "resets soon" when the reset time is at/in the past.
|
/// "resets in 3h 12m", "resets in 12m", or "resets soon" when the reset
|
||||||
/// Returns nil for a nil or unparseable timestamp. `now` is injected so the
|
/// time is at/in the past. Returns nil for nil/unparseable timestamps.
|
||||||
/// formatting is deterministic and unit-testable.
|
/// `now` is injected so the formatting is deterministic and unit-testable.
|
||||||
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
|
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
|
||||||
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
|
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
|
||||||
let secs = reset.timeIntervalSince(now)
|
let secs = reset.timeIntervalSince(now)
|
||||||
if secs <= 0 { return "resets soon" }
|
if secs <= 0 { return "resets soon" }
|
||||||
let totalMinutes = Int(secs / 60)
|
let totalMinutes = Int(secs / 60)
|
||||||
let hours = totalMinutes / 60
|
// Three-tier: days (>=24h) → hours+minutes (1h-24h) → minutes-only (<1h).
|
||||||
let minutes = totalMinutes % 60
|
return "resets in \(compactDuration(minutes: totalMinutes))"
|
||||||
if hours > 0 { return "resets in \(hours)h \(minutes)m" }
|
|
||||||
return "resets in \(minutes)m"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Burn-rate projection (single-window, no history)
|
// 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.
|
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
|
||||||
///
|
///
|
||||||
/// Phrasing rules (in order):
|
/// Phrasing rules (in order):
|
||||||
@@ -120,11 +149,12 @@ public enum BarQuotaGauge {
|
|||||||
/// resets in <countdown>". This REPLACES the bare reset countdown.
|
/// resets in <countdown>". This REPLACES the bare reset countdown.
|
||||||
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil) →
|
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil) →
|
||||||
/// "plenty at this pace".
|
/// "plenty at this pace".
|
||||||
/// - a finite projection m >= 5 → "~<Hh Mm> left at this pace".
|
/// - at-risk (projected exhaustion BEFORE reset): a finite projection m >= 5
|
||||||
/// - m < 5 → routed to the limit-reached path (never a scary "~0m").
|
/// → "~<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).
|
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) → nil (omit).
|
||||||
/// - resetAt already in the past (clock skew / stale) → nil pace; the
|
/// - resetAt already in the past (clock skew / stale) → nil pace.
|
||||||
/// countdown elsewhere shows "reset due".
|
|
||||||
public static func paceClause(
|
public static func paceClause(
|
||||||
usedPercent: Double,
|
usedPercent: Double,
|
||||||
remainingPercent: Double,
|
remainingPercent: Double,
|
||||||
@@ -165,14 +195,32 @@ public enum BarQuotaGauge {
|
|||||||
}
|
}
|
||||||
return "limit reached"
|
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"
|
return "~\(compactDuration(minutes: m)) left at this pace"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact "Hh Mm" / "Mm" duration for pace phrasing (e.g. "2h 5m", "35m").
|
/// Compact terse duration with a three-tier scale:
|
||||||
static func compactDuration(minutes: Int) -> String {
|
/// >= 24h → "Nd Nh" (e.g. 1590m → "1d 2h", 2678m → "1d 21h")
|
||||||
let h = minutes / 60
|
/// 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
|
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"
|
return "\(m)m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user