Skip to content

Commit d20928b

Browse files
fix(usage): keep last-good numbers through token-refresh gaps + per-cause messages
The usage footer blanked to a vague "사용량 표시 불가" whenever the Claude Code OAuth access token briefly expired between Claude Code's own refreshes: getUsageWindows returned 'expired' BEFORE the last-good-cache fallback, discarding perfectly good recent numbers. A 401 (token rejected server-side) was also lumped into 'offline'. - On a locally-expired token, fall back to the last-good cache (the numbers don't change during the few-second refresh gap) instead of blanking; only report 'expired' when nothing is cached at all. - Map a 401 response to 'expired' (re-login), distinct from 429 ('rate-limited') and other failures ('offline'). - Surface each transient cause with its own actionable message instead of one vague line: new pure usageErrorKey() (expired→re-login / rate-limited / offline; null = hide for not-logged-in / non-subscriber). New locale keys usage.bar_expired / usage.bar_ratelimited (en/ko/ja/zh). 289 tests (+5). Diagnosed via systematic-debugging; removes the blink the user hit and makes the rare message say what to do. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 260a84b commit d20928b

11 files changed

Lines changed: 72 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See every repo's state at a glance — git status, how long it's been neglected,
1111
![License](https://img.shields.io/badge/license-MIT-blue)
1212
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6)
1313
![Built with Electron](https://img.shields.io/badge/Electron-31-47848F)
14-
![Tests](https://img.shields.io/badge/tests-284%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-289%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
<img src="docs/demo/demo.gif" width="820" alt="DevDeck demo" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devdeck",
3-
"version": "1.9.1",
3+
"version": "1.9.2",
44
"description": "Project command deck — at-a-glance state + claude -c resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

src/main/claudeUsage.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,23 @@ describe('getUsageWindows', () => {
7878
const r = await getUsageWindows(deps({ fetchUsage: async () => ({ ok: false, status: 0 }) }));
7979
expect(r).toEqual({ enabled: true, error: 'offline' });
8080
});
81+
82+
it('expired token but last-good cache present => serves last-good (no blank "unavailable")', async () => {
83+
// token expired AND the cache is stale (>5min) so the fresh-TTL path doesn't fire — must still
84+
// keep showing the recent numbers through the brief Claude-Code token-refresh gap, not blank out.
85+
const cached = { timestamp: 1000, data: { planName: 'Max', fiveHour: 12, sevenDay: 20, fiveHourResetAt: 1, sevenDayResetAt: 1 } };
86+
const r = await getUsageWindows(deps({ now: () => 1000 + 10 * 60_000, cacheRead: () => cached, readCredentials: () => ({ ...baseCreds, expiresAt: 500 }) }));
87+
expect(r).toEqual({ enabled: true, data: cached.data });
88+
});
89+
90+
it('401 from the usage API => error expired (token rejected → re-login), not offline', async () => {
91+
const r = await getUsageWindows(deps({ fetchUsage: async () => ({ ok: false, status: 401 }) }));
92+
expect(r).toEqual({ enabled: true, error: 'expired' });
93+
});
94+
95+
it('401 but last-good cache present => serves last-good', async () => {
96+
const cached = { timestamp: 1000, data: { planName: 'Max', fiveHour: 4, sevenDay: 6, fiveHourResetAt: 1, sevenDayResetAt: 1 } };
97+
const r = await getUsageWindows(deps({ now: () => 1000 + 10 * 60_000, cacheRead: () => cached, fetchUsage: async () => ({ ok: false, status: 401 }) }));
98+
expect(r).toEqual({ enabled: true, data: cached.data });
99+
});
81100
});

src/main/claudeUsage.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ export async function getUsageWindows(deps: UsageDeps): Promise<UsageResult> {
5151

5252
const creds = deps.readCredentials();
5353
if (!creds) return { enabled: true, error: 'no-credentials' };
54-
if (creds.expiresAt != null && creds.expiresAt <= now) return { enabled: true, error: 'expired' };
54+
// Token expired locally: Claude Code refreshes the file on its own, but there's a brief gap. Keep
55+
// showing the last-good numbers through it (they don't change) instead of blanking the bar; only
56+
// report 'expired' if we have nothing cached at all.
57+
if (creds.expiresAt != null && creds.expiresAt <= now) {
58+
return fresh ? { enabled: true, data: fresh.data } : { enabled: true, error: 'expired' };
59+
}
5560

5661
const plan = planName(creds.subscriptionType);
5762
if (!plan) return { enabled: true, error: 'not-applicable' };
@@ -60,7 +65,10 @@ export async function getUsageWindows(deps: UsageDeps): Promise<UsageResult> {
6065
if (!res.ok) {
6166
// On any failure, fall back to last-good cache (even if stale) so the bar stays useful.
6267
if (fresh) return { enabled: true, data: fresh.data };
63-
return { enabled: true, error: res.status === 429 ? 'rate-limited' : 'offline' };
68+
// 401 = the token was rejected server-side (expired/invalid) → tell the user to re-login, same as
69+
// a locally-expired token; 429 = usage-API rate limit; everything else = offline/transient.
70+
const error = res.status === 401 ? 'expired' : res.status === 429 ? 'rate-limited' : 'offline';
71+
return { enabled: true, error };
6472
}
6573
const parsed = parseUsageResponse(res.body);
6674
if (!parsed) {

src/renderer/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
"usage.bar_week": "Weekly",
134134
"usage.bar_reset": "until reset",
135135
"usage.bar_unavailable": "Usage unavailable",
136+
"usage.bar_expired": "Login expired · re-login",
137+
"usage.bar_ratelimited": "Usage API busy · retry soon",
136138
"usage.reset_soon": "soon",
137139
"usage.reset_d": "Xd Yh",
138140
"usage.reset_h": "Xh Ym",

src/renderer/locales/ja.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
"usage.bar_week": "週間",
134134
"usage.bar_reset": "後にリセット",
135135
"usage.bar_unavailable": "使用量を表示できません",
136+
"usage.bar_expired": "ログイン期限切れ · 再ログイン",
137+
"usage.bar_ratelimited": "使用量API混雑 · 後で",
136138
"usage.reset_soon": "まもなく",
137139
"usage.reset_d": "X日Y時間",
138140
"usage.reset_h": "X時間Y分",

src/renderer/locales/ko.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
"usage.bar_week": "주간",
134134
"usage.bar_reset": "후 리셋",
135135
"usage.bar_unavailable": "사용량 표시 불가",
136+
"usage.bar_expired": "로그인 만료 · 재로그인",
137+
"usage.bar_ratelimited": "사용량 API 혼잡 · 잠시 후",
136138
"usage.reset_soon": "",
137139
"usage.reset_d": "X일 Y시간",
138140
"usage.reset_h": "X시간 Y분",

src/renderer/locales/zh.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
"usage.bar_week": "每周",
134134
"usage.bar_reset": "后重置",
135135
"usage.bar_unavailable": "无法显示用量",
136+
"usage.bar_expired": "登录已过期 · 重新登录",
137+
"usage.bar_ratelimited": "用量API繁忙 · 稍后重试",
136138
"usage.reset_soon": "即将",
137139
"usage.reset_d": "X天Y小时",
138140
"usage.reset_h": "X小时Y分",

src/renderer/usageBar.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { tr } from './i18n-runtime';
2-
import { severity, formatReset, type UsageResult, type UsageWindows } from '../shared/usageWindows';
2+
import { severity, formatReset, usageErrorKey, type UsageResult, type UsageWindows } from '../shared/usageWindows';
33

44
const POLL_MS = 5 * 60_000;
55
let el: HTMLElement;
@@ -18,9 +18,10 @@ export async function refreshUsageBar(): Promise<void> {
1818
try { res = await window.devdeck.usageWindows(); } catch { res = { enabled: true, error: 'offline' }; }
1919
if (!res.enabled) { el.classList.add('hidden'); stopTimer(); return; }
2020
if ('error' in res) {
21-
// Don't nag users who aren't Claude Code subscribers / aren't logged in — just hide the bar.
22-
if (res.error === 'not-applicable' || res.error === 'no-credentials') { el.classList.add('hidden'); }
23-
else { renderMsg('usage.bar_unavailable'); } // expired / offline / rate-limited — transient
21+
// null = not a Claude subscriber / not logged in → hide (no nagging); otherwise a specific,
22+
// actionable message (expired→re-login / rate-limited / offline).
23+
const key = usageErrorKey(res.error);
24+
if (key) renderMsg(key); else el.classList.add('hidden');
2425
startTimer();
2526
return;
2627
}

src/shared/usageWindows.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// src/shared/usageWindows.test.ts
22
import { describe, it, expect } from 'vitest';
3-
import { severity, clampPct, formatReset, parseUsageResponse } from './usageWindows';
3+
import { severity, clampPct, formatReset, parseUsageResponse, usageErrorKey } from './usageWindows';
44

55
describe('severity', () => {
66
it('ok < 70, warn 70..89, crit >= 90', () => {
@@ -41,6 +41,18 @@ describe('formatReset', () => {
4141
});
4242
});
4343

44+
describe('usageErrorKey', () => {
45+
it('hides (null) when not logged in or not applicable — no nagging', () => {
46+
expect(usageErrorKey('no-credentials')).toBeNull();
47+
expect(usageErrorKey('not-applicable')).toBeNull();
48+
});
49+
it('maps each transient failure to a specific, actionable message key', () => {
50+
expect(usageErrorKey('expired')).toBe('usage.bar_expired');
51+
expect(usageErrorKey('rate-limited')).toBe('usage.bar_ratelimited');
52+
expect(usageErrorKey('offline')).toBe('usage.bar_unavailable');
53+
});
54+
});
55+
4456
describe('parseUsageResponse', () => {
4557
it('extracts utilization + resets_at', () => {
4658
const body = {

0 commit comments

Comments
 (0)