From e4d524bf9c176f076cf9d2565425812f49bdac00 Mon Sep 17 00:00:00 2001 From: Jachaganhio Date: Thu, 10 Sep 2026 14:38:37 +0800 Subject: [PATCH] Fix stale progress bars and runaway reset countdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in how the widget's reset time behaves once a usage window actually rolls over: - The reset countdown used Text(date, style: .relative), which keeps ticking on its own once its target time passes — with no way to know the window actually reset, it just counts elapsed time upward forever (e.g. "1 sec", "2 sec", ...). - The percentage bar only updates on a real network fetch, which was requested on a fixed 5-minute cadence with no awareness of when resets actually happen; macOS's background refresh budget can delay that well past the reset, so the bar can sit stale far longer than 5 minutes. Fix: - UsageSnapshot.upcomingResets / clearingResetsPast build additional timeline entries, one per distinct future reset timestamp. WidgetKit switches to these automatically at their exact time, entirely client-side, with no dependency on the OS's opportunistic reload timing. - Once a reset entry's boundary passes, that metric's countdown is cleared and the row shows "Refreshing…" instead of a live counter. The percentage itself is left as-is rather than faked to 0, since we haven't confirmed the real post-reset value from the server yet. - getTimeline also biases its reload policy to request a real refresh shortly after the soonest reset (in addition to the existing 5-min cadence), so accurate data replaces the stale bar as soon as the OS allows. New regression tests for upcomingResets (future-only, deduped, sorted) and clearingResetsPast (clears past resets, leaves later ones and percentages untouched, other provider fields preserved). Co-Authored-By: Claude Opus 5 --- .../ClaudeUsageWidget.swift | 11 ++++- README.md | 3 +- README_CN.md | 3 +- Shared/UsageModels.swift | 30 ++++++++++++++ Shared/UsageViews.swift | 6 +++ Tests/UsageCoreTests/UsageCoreTests.swift | 41 +++++++++++++++++++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift b/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift index aebf87e..c59dde9 100644 --- a/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift +++ b/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift @@ -18,7 +18,16 @@ struct ClaudeUsageProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { let entry = await load() - completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(300)))) + // Reset boundaries get their own future entries so WidgetKit can switch to a + // neutral "Refreshing…" state exactly on time, without waiting on a real reload + // (which macOS may delay well past the reset). See UsageSnapshot.upcomingResets. + let boundaries = entry.snapshot.upcomingResets + let entries = [entry] + boundaries.map { ClaudeUsageEntry(snapshot: entry.snapshot.clearingResetsPast($0)) } + let regularReload = Date().addingTimeInterval(300) + // Also ask the system to try a real reload shortly after the soonest reset, so + // fresh percentages replace the stale ones as quickly as the OS allows. + let nextReload = boundaries.first.map { min(regularReload, $0.addingTimeInterval(5)) } ?? regularReload + completion(Timeline(entries: entries, policy: .after(nextReload))) } } diff --git a/README.md b/README.md index 2bc70c4..efba768 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ A macOS desktop widget (WidgetKit) that monitors Claude, Claude Fable, and Codex - **Color-coded** green → yellow → orange → red - **Three widget sizes** — small, medium, large - **Dual auth** — OAuth token or session key -- **Auto-refresh** requested every 5 minutes; macOS controls actual scheduling +- **Auto-refresh** requested every 5 minutes, and right after each metric's reset time; macOS controls actual scheduling +- **No stale countdowns** — once a reset time passes, the countdown stops and shows "Refreshing…" instead of counting upward past zero while waiting for fresh data - **Original app icon** included under MIT, with no third-party icon assets --- diff --git a/README_CN.md b/README_CN.md index ceff901..616ee08 100644 --- a/README_CN.md +++ b/README_CN.md @@ -27,7 +27,8 @@ macOS 桌面小组件(WidgetKit),同时监控你的 Claude、Claude Fable - **颜色随用量变化** 绿 → 黄 → 橙 → 红 - **三种尺寸** small、medium、large - **双重认证** OAuth token 或 session key -- **自动刷新** 每 5 分钟请求更新,实际调度由 macOS 决定 +- **自动刷新** 每 5 分钟请求更新,并在每项额度的重置时刻后额外请求一次;实际调度由 macOS 决定 +- **不会有"失真"的倒计时** 重置时间一过,倒计时会停下来显示"Refreshing…",不会在等待新数据期间一直往上跳 - **原创应用图标** 随项目按 MIT 许可提供,无第三方图标素材 --- diff --git a/Shared/UsageModels.swift b/Shared/UsageModels.swift index 0352604..ae0e3bb 100644 --- a/Shared/UsageModels.swift +++ b/Shared/UsageModels.swift @@ -67,6 +67,14 @@ struct UsageMetric: Identifiable, Sendable { guard let percent, percent.isFinite else { return "—" } return percent.formatted(.number.precision(.fractionLength(0...1))) + "%" } + + /// A copy with the countdown cleared once its target has passed `boundary`. Percent is + /// left as-is: we know it's stale at that point, but we don't fabricate a 0% reset + /// without confirming it from the server. + func clearingResetIfPast(_ boundary: Date) -> UsageMetric { + guard let resetsAt, resetsAt <= boundary else { return self } + return UsageMetric(id: id, title: title, percent: percent, resetsAt: nil) + } } struct ProviderUsage: Sendable { @@ -74,6 +82,12 @@ struct ProviderUsage: Sendable { var metrics: [UsageMetric] = [] var error: String? var isEnabled = true + + func clearingResetsPast(_ boundary: Date) -> ProviderUsage { + var copy = self + copy.metrics = metrics.map { $0.clearingResetIfPast(boundary) } + return copy + } } struct UsageSnapshot: Sendable { @@ -81,6 +95,22 @@ struct UsageSnapshot: Sendable { let claude: ProviderUsage let codex: ProviderUsage + /// Distinct future reset timestamps across both providers, ascending. Used to schedule + /// timeline entries exactly at each boundary instead of on a fixed reload cadence. A + /// disabled provider always has an empty metrics array, so it naturally contributes none. + var upcomingResets: [Date] { + Array(Set([claude, codex].flatMap(\.metrics).compactMap(\.resetsAt).filter { $0 > date })).sorted() + } + + /// A copy dated at `boundary` where any metric whose reset has already passed by then + /// has its countdown cleared. `Text(_:style:.relative)` keeps counting past its target + /// with no way to know the window actually rolled over, so once we schedule this entry + /// the row falls back to a neutral "Refreshing…" state instead of counting upward. + func clearingResetsPast(_ boundary: Date) -> UsageSnapshot { + UsageSnapshot(date: boundary, claude: claude.clearingResetsPast(boundary), + codex: codex.clearingResetsPast(boundary)) + } + static var preview: UsageSnapshot { let now = Date() return UsageSnapshot(date: now, claude: ProviderUsage(name: "Claude", metrics: [ diff --git a/Shared/UsageViews.swift b/Shared/UsageViews.swift index b0e22d9..23115f8 100644 --- a/Shared/UsageViews.swift +++ b/Shared/UsageViews.swift @@ -63,6 +63,12 @@ struct UsageRow: View { Text("Not reported by account") .font(.system(size: 9)) .foregroundStyle(.secondary) + } else { + // Reset time passed but a fresh fetch hasn't landed yet — say so instead + // of leaving a blank row or letting the countdown count upward forever. + Text("Refreshing…") + .font(.system(size: 9)) + .foregroundStyle(.secondary) } } } diff --git a/Tests/UsageCoreTests/UsageCoreTests.swift b/Tests/UsageCoreTests/UsageCoreTests.swift index 49006c9..ffbfb4e 100644 --- a/Tests/UsageCoreTests/UsageCoreTests.swift +++ b/Tests/UsageCoreTests/UsageCoreTests.swift @@ -94,6 +94,47 @@ final class UsageCoreTests: XCTestCase { XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) } + func testUpcomingResetsAreFutureDedupedAndSorted() { + let now = Date() + let past = now.addingTimeInterval(-10) + let soon = now.addingTimeInterval(100) + let later = now.addingTimeInterval(200) + let claude = ProviderUsage(name: "Claude", metrics: [ + UsageMetric(id: "a", title: "A", percent: 10, resetsAt: past), + UsageMetric(id: "b", title: "B", percent: 20, resetsAt: later) + ]) + let codex = ProviderUsage(name: "Codex", metrics: [ + UsageMetric(id: "c", title: "C", percent: 30, resetsAt: soon), + UsageMetric(id: "d", title: "D", percent: 40, resetsAt: later) // duplicate of claude's + ]) + let snapshot = UsageSnapshot(date: now, claude: claude, codex: codex) + XCTAssertEqual(snapshot.upcomingResets, [soon, later]) + + let disabledCodex = UsageSnapshot(date: now, claude: claude, codex: ProviderUsage(name: "Codex", isEnabled: false)) + XCTAssertEqual(disabledCodex.upcomingResets, [later]) + } + + func testClearingResetsPastBoundaryLeavesLaterResetsAndPercentagesIntact() { + let now = Date() + let justPassed = now.addingTimeInterval(5) + let stillFuture = now.addingTimeInterval(1000) + let claude = ProviderUsage(name: "Claude", metrics: [ + UsageMetric(id: "five_hour", title: "5h Session", percent: 42, resetsAt: justPassed), + UsageMetric(id: "seven_day", title: "Weekly", percent: 10, resetsAt: stillFuture), + UsageMetric(id: "fable", title: "Fable · Weekly", percent: nil, resetsAt: nil) + ], error: nil) + let snapshot = UsageSnapshot(date: now, claude: claude, codex: ProviderUsage(name: "Codex", isEnabled: false)) + let boundary = justPassed.addingTimeInterval(5) + let cleared = snapshot.clearingResetsPast(boundary) + + XCTAssertEqual(cleared.date, boundary) + XCTAssertNil(cleared.claude.metrics[0].resetsAt, "Passed reset should be cleared") + XCTAssertEqual(cleared.claude.metrics[0].percent, 42, "Percent is left as-is, not fabricated to 0") + XCTAssertEqual(cleared.claude.metrics[1].resetsAt, stillFuture, "Future reset must not be touched") + XCTAssertNil(cleared.claude.metrics[2].resetsAt, "Already-nil reset stays nil") + XCTAssertFalse(cleared.codex.isEnabled, "Other provider fields are preserved untouched") + } + func testInvalidConfigIsNotSilentlyOverwritten() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: url) }