From 882b342d93190bc755e8d3a81bf597803f74d03f Mon Sep 17 00:00:00 2001 From: Eohan G Date: Thu, 25 Jun 2026 11:11:13 +0800 Subject: [PATCH 1/3] fix(forecast): regress only the live window, disambiguate the day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly forecast linear-regressed across quota resets: the 14-day history is a sawtooth (remaining snaps back to ~1 each reset), so the blended slope flattened and the projected zero landed days out — then rendered as a bare weekday that read as the day already past. Trim to the live window, suppress zeros that fall after the window resets, and always spell the day out (today / tomorrow / weekday). Covered by both the self-test and XCTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodingBar/SelfTest.swift | 25 +++++++ Sources/CodingBarCore/Forecast.swift | 79 ++++++++++++++++------ Tests/CodingBarCoreTests/SmokeTests.swift | 49 ++++++++++++++ docs/assets/menubar-2x2.png | Bin 0 -> 40130 bytes docs/assets/panel-composition.png | Bin 77789 -> 113311 bytes docs/assets/panel-insights.png | Bin 108392 -> 101125 bytes docs/assets/panel-light.png | Bin 212762 -> 171837 bytes docs/assets/panel-overview.png | Bin 211887 -> 194848 bytes 8 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 docs/assets/menubar-2x2.png 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/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: " 周额度预计