Skip to content
Open
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
11 changes: 10 additions & 1 deletion ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ struct ClaudeUsageProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<ClaudeUsageEntry>) -> 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)))
}
}

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
3 changes: 2 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ macOS 桌面小组件(WidgetKit),同时监控你的 Claude、Claude Fable
- **颜色随用量变化** 绿 → 黄 → 橙 → 红
- **三种尺寸** small、medium、large
- **双重认证** OAuth token 或 session key
- **自动刷新** 每 5 分钟请求更新,实际调度由 macOS 决定
- **自动刷新** 每 5 分钟请求更新,并在每项额度的重置时刻后额外请求一次;实际调度由 macOS 决定
- **不会有"失真"的倒计时** 重置时间一过,倒计时会停下来显示"Refreshing…",不会在等待新数据期间一直往上跳
- **原创应用图标** 随项目按 MIT 许可提供,无第三方图标素材

---
Expand Down
30 changes: 30 additions & 0 deletions Shared/UsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,50 @@ 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 {
let name: String
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 {
let date: Date
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()
}
Comment on lines +101 to +103

/// 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: [
Expand Down
6 changes: 6 additions & 0 deletions Shared/UsageViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +66 to +70
.foregroundStyle(.secondary)
Comment on lines +66 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The new fallback labels every metric with a non-nil percentage and nil resetsAt as “Refreshing…”, including metrics that never had a reset boundary. Parsers already produce such metrics when the server omits or returns an invalid reset timestamp, and no timeline boundary will ever clear them, so those rows remain incorrectly marked as refreshing indefinitely.

Triggers: When the server reports a usable percentage but no valid reset timestamp.

Suggested fix: Track whether the metric was cleared by a scheduled boundary, or preserve the prior neutral/empty rendering for metrics that originally had no reset time.

Suggested change
} 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)
}

}
}
}
Expand Down
41 changes: 41 additions & 0 deletions Tests/UsageCoreTests/UsageCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +118 to +128

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) }
Expand Down