Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Sources/CodingBar/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ enum SelfTest {
let mixed = claudeWindows + codexWindows
check("tightestRemaining picks most-depleted", abs((mixed.tightestRemaining ?? 1) - 0.26) < 0.0001)

// ── Forecast (provider-agnostic: same path for Claude and Codex) ─────────
let fcCal = Calendar.current
let fcNow = fcCal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 12))! // Wednesday
let fcDay = 86_400.0, fcT0 = fcNow.timeIntervalSince1970
func fcPt(_ d: Double, _ r: Double) -> Forecaster.Point { (t: fcT0 + d * fcDay, r: r) }
// Live window 1.0 → 0.10 over 6 days ⇒ zero ≈ now + 0.667 day (16h).
let fcLive = (0...6).map { fcPt(-6 + Double($0), 1.0 - 0.9 * (Double($0) / 6.0)) }
let fcResetFar = fcNow.addingTimeInterval(3 * fcDay)
let fcLiveZero = Forecaster.predictDepletion(samples: fcLive, resetAt: fcResetFar, now: fcNow)
check("forecast projects clean decline ~16h out",
abs((fcLiveZero?.timeIntervalSince1970 ?? 0) - (fcT0 + (2.0 / 3.0) * fcDay)) < 3600)
// Samples before a reset (the old cross-reset bug) must not shift the projection.
let fcWithReset = [fcPt(-13, 0.30), fcPt(-12, 0.20), fcPt(-11, 0.12), fcPt(-10, 0.05)] + fcLive
check("forecast ignores pre-reset samples",
Forecaster.predictDepletion(samples: fcWithReset, resetAt: fcResetFar, now: fcNow)?.timeIntervalSince1970
== fcLiveZero?.timeIntervalSince1970)
// Window resets before the projected zero ⇒ never runs out ⇒ no forecast.
check("forecast suppressed when reset precedes depletion",
Forecaster.predictDepletion(samples: fcLive, resetAt: fcNow.addingTimeInterval(0.25 * fcDay), now: fcNow) == nil)
// Day always spelled out so a multi-day-out "Mon" can't read as a past weekday.
let fcToday = fcCal.date(bySettingHour: 15, minute: 0, second: 0, of: fcNow)!
let fc3d = fcCal.date(byAdding: .day, value: 3, to: fcNow)! // Wed + 3 = Saturday
check("forecast formats same-day as today", Forecaster.formatDepletion(fcToday, now: fcNow, language: .en) == "today 15:00")
check("forecast formats multi-day as weekday", Forecaster.formatDepletion(fc3d, now: fcNow, language: .en).hasPrefix("Sat "))

print(failures == 0 ? "ALL PASS" : "\(failures) FAILED")
return failures == 0 ? 0 : 1
}
Expand Down
20 changes: 17 additions & 3 deletions Sources/CodingBar/Views/Panel/PanelCharts.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftUI
import CodingBarCore

// MARK: - Spend sparkline (prototype: polyline + 10% area fill + last dot)
// MARK: - Spend sparkline (smooth spline + 10% area fill + last dot)

struct DCSparkline: View {
@Environment(\.dc) private var dc
Expand All @@ -19,15 +19,16 @@ struct DCSparkline: View {
ZStack {
Path { p in
p.move(to: CGPoint(x: pad, y: h))
pts.forEach { p.addLine(to: $0) }
if let first = pts.first { p.addLine(to: first) }
Self.addSmoothCurve(&p, through: pts)
p.addLine(to: CGPoint(x: w - pad, y: h))
p.closeSubpath()
}
.fill(dc.fixedBlue.opacity(0.10))
Path { p in
guard let first = pts.first else { return }
p.move(to: first)
pts.dropFirst().forEach { p.addLine(to: $0) }
Self.addSmoothCurve(&p, through: pts)
}
.stroke(dc.accent, style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
if let last = pts.last {
Expand All @@ -37,6 +38,19 @@ struct DCSparkline: View {
}
.frame(height: 36)
}

// Catmull-Rom → cubic Bézier: smooths the polyline without overshooting the
// data points (tension 1/6). Assumes `p` is already positioned at pts[0];
// shared by the stroke and the area fill so their outlines match exactly.
private static func addSmoothCurve(_ p: inout Path, through pts: [CGPoint]) {
guard pts.count > 1 else { return }
for i in 0..<(pts.count - 1) {
let p0 = pts[max(i - 1, 0)], p1 = pts[i], p2 = pts[i + 1], p3 = pts[min(i + 2, pts.count - 1)]
let c1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6)
let c2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6)
p.addCurve(to: p2, control1: c1, control2: c2)
}
}
}

// MARK: - Code-output add/remove ratio bar (height 5)
Expand Down
29 changes: 28 additions & 1 deletion Sources/CodingBar/Views/Panel/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import CodingBarCore
// Header · active tab (总览 / 构成 / 洞察) · provenance · bottom nav.
struct PanelView: View {
@ObservedObject var store: UsageStore
// Drives the header's "立即更新" pill: Sparkle flips hasAvailableUpdate when a
// background/manual check finds a release, and this re-renders the header.
@ObservedObject private var updater = UpdateManager.shared
@Environment(\.colorScheme) private var systemScheme
@State private var tab: Int
@State private var refreshSpin: Double = 0
Expand Down Expand Up @@ -87,13 +90,15 @@ struct PanelView: View {
HStack(spacing: 8) {
brandMark
Text("CodingBar").font(.system(size: 13, weight: .semibold)).tracking(-0.13).foregroundStyle(dc.fg)
.fixedSize()
HStack(spacing: 5) {
BreathingDot(size: 6, color: burning ? dc.good : dc.fg3, animate: burning)
Text(statusText).font(.system(size: 10.5)).foregroundStyle(dc.fg2)
Text(statusText).font(.system(size: 10.5)).foregroundStyle(dc.fg2).lineLimit(1).fixedSize()
}
.padding(.leading, 6).padding(.trailing, 7).padding(.vertical, 2)
.background(Capsule().fill(dc.hover))
Spacer()
updatePill
metricToggle
Button { doRefresh() } label: {
Image(systemName: "arrow.clockwise").font(.system(size: 9, weight: .regular)).foregroundStyle(dc.fg3)
Expand All @@ -112,6 +117,28 @@ struct PanelView: View {
.padding(.horizontal, 13).padding(.top, 11).padding(.bottom, 10)
}

/// "立即更新" pill — appears in the header only once Sparkle has a verified
/// update in hand (so it never shows in dev builds, where canUpdate is false).
/// Accent-filled so it reads as the one actionable item in the corner cluster;
/// tapping hands off to Sparkle's standard update prompt.
@ViewBuilder private var updatePill: some View {
if updater.canUpdate && updater.hasAvailableUpdate {
Button { updater.checkForUpdates() } label: {
HStack(spacing: 3) {
Image(systemName: "arrow.down.circle.fill").font(.system(size: 9, weight: .semibold))
Text(lang.t("Update", "立即更新")).font(.system(size: 10, weight: .semibold))
}
.fixedSize()
.foregroundStyle(.white)
.padding(.leading, 5).padding(.trailing, 7).padding(.vertical, 2)
.background(Capsule().fill(dc.accent))
.contentShape(Capsule())
}
.buttonStyle(.plain).focusEffectDisabled()
.help(lang.t("A new version is available — update now", "有新版本可用 — 立即更新"))
}
}

/// Metric toggle (花费 ⇄ Token) — same size / weight / color / padding as the
/// refresh button so the two read as a matched pair. The icon shows the
/// *current* metric; tapping flips both the panel and the menu bar.
Expand Down
79 changes: 58 additions & 21 deletions Sources/CodingBarCore/Forecast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,47 +86,79 @@ public enum Forecaster {
public static func recordAndForecast(quota: [QuotaWindow], now: Date, language: AppLanguage = .en) -> Insight? {
let history = appendAndPrune(quota: quota, now: now)

// Forecast for Codex weekly window ("7d")
let weekSamples = history
// Forecast for the Codex weekly window ("7d")
let points = history
.filter { $0.provider == Provider.codex.rawValue && $0.label == "7d" }
.sorted { $0.date < $1.date }
.map { Point(t: $0.date, r: $0.remaining) }
let resetAt = quota.first { $0.provider == .codex && $0.label == "7d" }?.resetAt

guard let tZero = regressZero(weekSamples),
tZero > now.timeIntervalSince1970 else { return nil }
let when = formatDepletion(Date(timeIntervalSince1970: tZero), language: language)
let text = language.t("Weekly quota runs out \(when)", "周额度预计 \(when) 见底")
guard let when = predictDepletion(samples: points, resetAt: resetAt, now: now) else { return nil }
let whenStr = formatDepletion(when, now: now, language: language)
let text = language.t("Weekly quota runs out \(whenStr)", "周额度预计 \(whenStr) 见底")
return Insight(kind: .forecast, text: text)
}

/// For each provider that has a weekly window, forecast when it depletes.
/// Returns `[Provider.rawValue: "<Name> 周额度预计 <weekday> <time> 见底"]`.
/// Returns `[Provider.rawValue: "<Name> 周额度预计 <when> 见底"]`.
/// Reads the history persisted by `recordAndForecast`, so call that first.
public static func forecastByProvider(quota: [QuotaWindow], now: Date, language: AppLanguage = .en) -> [String: String] {
let history = loadHistoryLocked()
var out: [String: String] = [:]
for provider in [Provider.claude, Provider.codex] {
guard quota.contains(where: { $0.provider == provider && $0.label == "7d" }) else { continue }
let samples = history
guard let window = quota.first(where: { $0.provider == provider && $0.label == "7d" }) else { continue }
let points = history
.filter { $0.provider == provider.rawValue && $0.label == "7d" }
.sorted { $0.date < $1.date }
guard let tZero = regressZero(samples), tZero > now.timeIntervalSince1970 else { continue }
.map { Point(t: $0.date, r: $0.remaining) }
guard let when = predictDepletion(samples: points, resetAt: window.resetAt, now: now) else { continue }
let name = provider == .claude ? "Claude" : "Codex"
let when = formatDepletion(Date(timeIntervalSince1970: tZero), language: language)
out[provider.rawValue] = language.t("\(name) weekly quota runs out \(when)", "\(name) 周额度预计 \(when) 见底")
let whenStr = formatDepletion(when, now: now, language: language)
out[provider.rawValue] = language.t("\(name) weekly quota runs out \(whenStr)", "\(name) 周额度预计 \(whenStr) 见底")
}
return out
}

public typealias Point = (t: Double, r: Double) // (epoch seconds, remaining 0...1)

/// Predict when the *current* window hits zero, or nil if it won't run out before it
/// resets. Public so the self-test / XCTest can exercise the pure math without touching
/// the disk-backed history.
public static func predictDepletion(samples: [Point], resetAt: Date?, now: Date) -> Date? {
guard let tZero = regressZero(currentWindow(samples)),
tZero > now.timeIntervalSince1970 else { return nil }
let when = Date(timeIntervalSince1970: tZero)
// A window that resets before the projected zero never actually "runs out" — its
// remaining fraction snaps back to 1 at the reset. Emitting a post-reset date is
// exactly what made the forecast read as nonsense ("runs out next Mon" while the
// window resets tomorrow), so suppress it.
if let resetAt, when >= resetAt { return nil }
return when
}

/// Keep only the samples since the most recent reset. A reset shows up as `remaining`
/// jumping back up versus the prior sample; regressing across that sawtooth blends the
/// spike with the genuine decline, flattening the slope and pushing the zero-crossing
/// days past the truth. The live window is the only one whose depletion we can predict.
private static func currentWindow(_ samples: [Point]) -> [Point] {
guard !samples.isEmpty else { return samples }
var start = 0
// 0.05 is well above the 0.01 quota rounding / sampling noise, so only a real
// reset (≈0 → ≈1) trips it, not a flat or slightly-jittery decline.
for i in 1..<samples.count where samples[i].r > samples[i - 1].r + 0.05 { start = i }
return Array(samples[start...])
}

/// Linear-regress `remaining ~ a + b·t` over the samples and return the time
/// (epoch seconds) at which remaining hits 0, or nil if the trend isn't a
/// clear decline.
private static func regressZero(_ samples: [QuotaSample]) -> Double? {
private static func regressZero(_ samples: [Point]) -> Double? {
guard samples.count >= 2 else { return nil }
let n = Double(samples.count)
let sumT = samples.reduce(0.0) { $0 + $1.date }
let sumR = samples.reduce(0.0) { $0 + $1.remaining }
let sumT2 = samples.reduce(0.0) { $0 + $1.date * $1.date }
let sumTR = samples.reduce(0.0) { $0 + $1.date * $1.remaining }
let sumT = samples.reduce(0.0) { $0 + $1.t }
let sumR = samples.reduce(0.0) { $0 + $1.r }
let sumT2 = samples.reduce(0.0) { $0 + $1.t * $1.t }
let sumTR = samples.reduce(0.0) { $0 + $1.t * $1.r }
let denom = n * sumT2 - sumT * sumT
guard abs(denom) > 1e-9 else { return nil }
let b = (n * sumTR - sumT * sumR) / denom // slope (should be negative)
Expand All @@ -135,13 +167,18 @@ public enum Forecaster {
return -a / b
}

/// "Thu 15:00" / "周四 15:00" — weekday + HH:mm of a predicted depletion date.
private static func formatDepletion(_ date: Date, language: AppLanguage) -> String {
/// "today 09:08" / "tomorrow 08:30" / "Thu 15:00" — the day is always spelled out so a
/// multi-day-out forecast can't be misread as a weekday that just passed (a bare "Mon"
/// five days out reads as the Monday that already went by). Public for tests.
public static func formatDepletion(_ date: Date, now: Date, language: AppLanguage) -> String {
let cal = Calendar.current
let time = String(format: "%02d:%02d", cal.component(.hour, from: date), cal.component(.minute, from: date))
let dayDiff = cal.dateComponents([.day], from: cal.startOfDay(for: now), to: cal.startOfDay(for: date)).day ?? 0
if dayDiff == 0 { return language.t("today \(time)", "今天 \(time)") }
if dayDiff == 1 { return language.t("tomorrow \(time)", "明天 \(time)") }
let names = language.t("Sun Mon Tue Wed Thu Fri Sat", "周日 周一 周二 周三 周四 周五 周六")
.split(separator: " ").map(String.init)
let wd = names[cal.component(.weekday, from: date) - 1]
let h = cal.component(.hour, from: date), m = cal.component(.minute, from: date)
return wd + " " + String(format: "%02d:%02d", h, m)
return wd + " " + time
}
}
49 changes: 49 additions & 0 deletions Tests/CodingBarCoreTests/SmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,55 @@ final class SmokeTests: XCTestCase {
XCTAssertFalse(Pricing.priceIsExact(model: "totally-unknown-model")) // generic fallback rate
}

/// The Codex weekly forecast used to linear-regress across quota *resets*: a 14-day
/// history is a sawtooth (remaining snaps back to ~1 each week), so the blended slope
/// flattened and the projected zero landed ~5 days out — then it rendered as a bare
/// weekday ("Mon"), which read as the Monday that had already passed. The fix regresses
/// only the live window, suppresses zeros falling after the window resets, and always
/// spells out the day. Guards all three so the regression can't silently return.
func testWeeklyForecastIgnoresResetsAndDisambiguatesDay() {
let cal = Calendar.current
let now = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 12))! // a Wednesday
let day = 86_400.0
let t0 = now.timeIntervalSince1970
func pt(_ daysFromNow: Double, _ r: Double) -> (t: Double, r: Double) { (t: t0 + daysFromNow * day, r: r) }

// Live window: declines 1.0 → 0.10 over the last 6 days. Analytic zero: the slope is
// 0.90 over 6 days = 0.15/day, so r hits 0 at 0.10/0.15 = 0.667 day after now.
let live = (0...6).map { pt(-6 + Double($0), 1.0 - 0.9 * (Double($0) / 6.0)) }
let expectedZero = t0 + (2.0 / 3.0) * day

let resetFar = now.addingTimeInterval(3 * day) // resets well after the projected zero
guard let liveZero = Forecaster.predictDepletion(samples: live, resetAt: resetFar, now: now) else {
return XCTFail("a clean declining window must project a depletion")
}
XCTAssertEqual(liveZero.timeIntervalSince1970, expectedZero, accuracy: 3600,
"clean declining window should project ~16h out")

// Prepend a *previous* window (0.30 → 0.05) before the reset jump to 1.0. Regressing
// the whole sawtooth (the old bug) flattens the slope; the fix trims to the live
// window, so the answer must be byte-identical to the live-only projection.
let withReset = [pt(-13, 0.30), pt(-12, 0.20), pt(-11, 0.12), pt(-10, 0.05)] + live
let trimmedZero = Forecaster.predictDepletion(samples: withReset, resetAt: resetFar, now: now)
XCTAssertEqual(trimmedZero?.timeIntervalSince1970, liveZero.timeIntervalSince1970,
"samples before the reset must not shift the projection")

// A window that resets before the projected zero never runs out → no forecast.
let resetSoon = now.addingTimeInterval(0.25 * day) // before the 0.667-day zero
XCTAssertNil(Forecaster.predictDepletion(samples: live, resetAt: resetSoon, now: now),
"depletion after the reset must be suppressed")

// Day is always spelled out: today / tomorrow / weekday — never a bare ambiguous one.
let today15 = cal.date(bySettingHour: 15, minute: 0, second: 0, of: now)!
let tomorrow0830 = cal.date(byAdding: .day, value: 1, to: cal.date(bySettingHour: 8, minute: 30, second: 0, of: now)!)!
let inThreeDays = cal.date(byAdding: .day, value: 3, to: now)! // Wed + 3 = Saturday
XCTAssertEqual(Forecaster.formatDepletion(today15, now: now, language: .en), "today 15:00")
XCTAssertEqual(Forecaster.formatDepletion(today15, now: now, language: .zh), "今天 15:00")
XCTAssertEqual(Forecaster.formatDepletion(tomorrow0830, now: now, language: .en), "tomorrow 08:30")
XCTAssertTrue(Forecaster.formatDepletion(inThreeDays, now: now, language: .en).hasPrefix("Sat "),
"3 days out should fall back to the weekday, not today/tomorrow")
}

func testTokenBreakdownMath() {
var a = TokenBreakdown(input: 10, output: 5, cacheRead: 100)
a += TokenBreakdown(input: 5, cacheWrite: 20)
Expand Down
Binary file added docs/assets/menubar-2x2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/panel-composition.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/panel-insights.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/panel-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/panel-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions release-notes/v1.1.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- The spend sparkline now draws as a smooth curve instead of a jagged polyline.
- When auto-check is on and a new version is available, an "Update" button now
appears directly in the panel header — one click to install, no need to open
Settings first.
- Fixed the weekly quota forecast. It used to regress across quota resets, which
flattened the trend, projected depletion days out, and rendered it as a bare
weekday that read as a day already past. It now fits only the live window,
suppresses any projection that lands after the window resets, and always
spells the day out (today / tomorrow / weekday).

**Full Changelog**: https://github.com/Gnonymous/CodingBar/compare/v1.1.1...v1.1.2
Loading