Fix stale progress bars and runaway reset countdowns - #2
Jachaganhio wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com>
Reviewer's GuideThe PR fixes stale widget state by generating client-side timeline entries at reset boundaries, clearing expired countdown targets to show “Refreshing…” without fabricating percentages, and requesting a real reload soon after the earliest reset; model behavior is covered by regression tests and documented in both README files. Sequence diagram for reset-aware widget timeline refreshsequenceDiagram
participant WidgetKit
participant TimelineProvider
participant UsageSnapshot
participant Server
WidgetKit->>TimelineProvider: getTimeline(context, completion)
TimelineProvider->>Server: load()
Server-->>TimelineProvider: ClaudeUsageEntry
TimelineProvider->>UsageSnapshot: upcomingResets
UsageSnapshot-->>TimelineProvider: sorted distinct future dates
loop Each reset boundary
TimelineProvider->>UsageSnapshot: clearingResetsPast(boundary)
UsageSnapshot-->>TimelineProvider: entry with expired resetsAt cleared
end
TimelineProvider-->>WidgetKit: Timeline(entries, policy after nextReload)
WidgetKit->>WidgetKit: Activate boundary entry
WidgetKit-->>WidgetKit: Show Refreshing… and preserve percentage
WidgetKit->>TimelineProvider: Request reload after soonest reset
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="Shared/UsageViews.swift" line_range="66-71" />
<code_context>
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)
}
}
}
</code_context>
<issue_to_address>
**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.
```suggestion
}
```
</issue_to_address>Sourcery assessment
Needs a human reviewer. 1 finding to address first, and the change only affects widget timeline scheduling and what reset states are displayed; it does not write durable data or trigger irreversible actions. If the boundary handling is wrong, an already-generated timeline can temporarily show stale percentages or “Refreshing…” until a later reload, but that state is bounded and can be corrected by rerunning the widget or reverting the change.
Blocking findings: Shared/UsageViews.swift:71
| } 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) |
There was a problem hiding this comment.
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.
| } 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) | |
| } |
There was a problem hiding this comment.
🟡 Changes recommended
The new “Refreshing…” state conflates “reset time unknown/missing” with “reset passed awaiting refresh” (reachable today via Codex parsing without reset_at), which can mislead users unless the model/view distinguishes those cases.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates the widget timeline/model logic so usage rows transition cleanly when a usage window reset time passes, avoiding stale progress bars and countdowns that run past zero while waiting for the next network refresh.
Changes:
- Add
UsageSnapshot.upcomingResetsandclearingResetsPast(_:)(plus per-provider/metric helpers) to generate timeline “boundary” entries at reset times. - Update the widget provider to emit boundary timeline entries and request a real reload shortly after the soonest upcoming reset (while preserving the 5-minute cadence).
- Add regression tests and update README/README_CN to document the new behavior.
File summaries
| File | Description |
|---|---|
| Tests/UsageCoreTests/UsageCoreTests.swift | Adds regression tests for future reset discovery/deduping and reset-boundary clearing behavior. |
| Shared/UsageViews.swift | Introduces a “Refreshing…” UI fallback when a reset countdown is cleared/absent. |
| Shared/UsageModels.swift | Adds reset-boundary timeline support via upcoming reset discovery and clearing helpers. |
| README.md | Documents refresh scheduling after resets and the non-stale countdown behavior. |
| README_CN.md | Chinese documentation update for the new refresh/reset behavior. |
| ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift | Builds timelines with boundary entries and adjusts reload policy to refresh soon after the earliest reset. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var upcomingResets: [Date] { | ||
| Array(Set([claude, codex].flatMap(\.metrics).compactMap(\.resetsAt).filter { $0 > date })).sorted() | ||
| } |
| } 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)) |
| 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) |
Bug
Reported behavior: after a usage window's reset time passes, the progress bar doesn't update, and the "Resets" countdown keeps counting upward past zero (1 sec, 2 sec, ...) instead of reflecting the new window.
Root cause
Two related issues:
Text(date, style: .relative), a live SwiftUI view that keeps ticking on its own once its target time passes. It has no way to know the window actually reset server-side — it just shows elapsed time counting upward indefinitely.getTimelinerequested a reload on a fixed 5-minute cadence with no awareness of when resets actually happen, and macOS's background refresh budget for widgets can delay the actual fetch well past that request — so the bar can sit stale for much longer than 5 minutes.Fix
UsageSnapshot.upcomingResets/clearingResetsPastbuild one extra timeline entry per distinct future reset timestamp across both providers. WidgetKit switches to these automatically at their exact scheduled time — entirely client-side, with no dependency on the OS's opportunistic reload timing.resetsAtis cleared and the row shows a neutral "Refreshing…" label instead of a live counter. The percentage itself is left as-is rather than faked to 0%, since the real post-reset value hasn't been confirmed from the server yet.getTimelinenow also biases its reload policy to request a real refresh shortly after the soonest upcoming reset (in addition to the existing 5-minute cadence), so accurate data replaces the stale bar as soon as the OS allows it.Tests
New regression tests:
testUpcomingResetsAreFutureDedupedAndSorted— future-only, deduped across providers, sorted ascending; disabled providers contribute nothing (empty metrics).testClearingResetsPastBoundaryLeavesLaterResetsAndPercentagesIntact— clears only resets at/before the boundary, leaves later resets and all percentages untouched, otherProviderUsagefields preserved.swift test(14/14) andxcodebuild(Debug, macOS) both succeed.🤖 Generated with Claude Code
Summary by Sourcery
Ensure usage widgets transition cleanly at reset boundaries and refresh stale progress data promptly.
Bug Fixes:
Enhancements:
Documentation:
Tests: