Skip to content

Fix stale progress bars and runaway reset countdowns - #2

Open
Jachaganhio wants to merge 1 commit into
dependentsign:masterfrom
Jachaganhio:fix/widget-reset-refresh
Open

Jachaganhio wants to merge 1 commit into
dependentsign:masterfrom
Jachaganhio:fix/widget-reset-refresh

Conversation

@Jachaganhio

@Jachaganhio Jachaganhio commented Sep 10, 2026

Copy link
Copy Markdown

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:

  1. The reset countdown uses 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.
  2. The percentage bar only updates on a real network fetch. getTimeline requested 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 / clearingResetsPast build 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.
  • Once a reset's boundary entry activates, that metric's resetsAt is 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.
  • getTimeline now 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, other ProviderUsage fields preserved.

swift test (14/14) and xcodebuild (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:

  • Prevent reset countdowns from continuing past zero by transitioning affected metrics to a neutral “Refreshing…” state at their reset boundary.
  • Keep widget progress data from remaining stale by scheduling timeline boundary entries and requesting a refresh shortly after upcoming resets.

Enhancements:

  • Add future reset discovery across providers with deduplication and chronological ordering while preserving stale percentages until refreshed.

Documentation:

  • Document refresh requests after metric resets and the neutral state shown while waiting for updated usage data.

Tests:

  • Add regression coverage for future reset filtering, deduplication, ordering, disabled providers, and boundary clearing behavior.

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>
Copilot AI lite review requested due to automatic review settings September 10, 2026 06:39
@sourcery-ai

sourcery-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

The 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 refresh

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Schedule widget timeline entries at each distinct future reset boundary and request a follow-up server refresh.
  • Collect future reset timestamps across enabled metrics, deduplicate them, and sort them.
  • Add boundary entries that clear passed reset times while preserving stale percentages and provider data.
  • Bias the reload policy to shortly after the earliest reset while retaining the five-minute cadence.
ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift
Shared/UsageModels.swift
Replace expired live countdowns with an explicit neutral refresh state.
  • Render “Refreshing…” when a metric has no reset timestamp after its boundary entry activates.
  • Document the stale-data behavior in the English and Chinese README files.
Shared/UsageViews.swift
README.md
README_CN.md
Add regression coverage for reset-boundary scheduling and data preservation.
  • Verify future reset timestamps are filtered, deduplicated, sorted, and exclude disabled providers.
  • Verify only passed resets are cleared and percentages and unrelated provider fields remain unchanged.
Tests/UsageCoreTests/UsageCoreTests.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread Shared/UsageViews.swift
Comment on lines +66 to +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)

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.upcomingResets and clearingResetsPast(_:) (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.

Comment thread Shared/UsageModels.swift
Comment on lines +101 to +103
var upcomingResets: [Date] {
Array(Set([claude, codex].flatMap(\.metrics).compactMap(\.resetsAt).filter { $0 > date })).sorted()
}
Comment thread Shared/UsageViews.swift
Comment on lines +66 to +70
} 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 +118 to +128
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants