diff --git a/Sources/CodingBar/SelfTest.swift b/Sources/CodingBar/SelfTest.swift index 9129fbd..55b4dfc 100644 --- a/Sources/CodingBar/SelfTest.swift +++ b/Sources/CodingBar/SelfTest.swift @@ -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 } diff --git a/Sources/CodingBar/Views/Panel/PanelCharts.swift b/Sources/CodingBar/Views/Panel/PanelCharts.swift index 155bfe7..97db8c0 100644 --- a/Sources/CodingBar/Views/Panel/PanelCharts.swift +++ b/Sources/CodingBar/Views/Panel/PanelCharts.swift @@ -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 @@ -19,7 +19,8 @@ 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() } @@ -27,7 +28,7 @@ struct DCSparkline: View { 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 { @@ -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) diff --git a/Sources/CodingBar/Views/Panel/PanelView.swift b/Sources/CodingBar/Views/Panel/PanelView.swift index 3576673..3fe9ab7 100644 --- a/Sources/CodingBar/Views/Panel/PanelView.swift +++ b/Sources/CodingBar/Views/Panel/PanelView.swift @@ -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 @@ -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) @@ -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. diff --git a/Sources/CodingBarCore/Forecast.swift b/Sources/CodingBarCore/Forecast.swift index 34b4fe0..2f13d4a 100644 --- a/Sources/CodingBarCore/Forecast.swift +++ b/Sources/CodingBarCore/Forecast.swift @@ -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: " 周额度预计