Skip to content

Commit f65e0b5

Browse files
authored
feat(quota): optimize quota refresh logic, add relative countdowns, parallelize card refreshes (#45)
1 parent a896283 commit f65e0b5

13 files changed

Lines changed: 98 additions & 95 deletions

File tree

src-tauri/shared/front/actions.ts

Lines changed: 23 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ let pendingLoginRetry: (() => Promise<void>) | null = null;
9494
let cancelledLoginProfile: string | null = null;
9595

9696
function isRefreshPending(profile: string): boolean {
97-
return state.refreshActiveProfile === profile || state.refreshQueue.includes(profile);
97+
return state.refreshActiveProfiles.includes(profile);
9898
}
9999

100100
function clearDialogError(element: HTMLParagraphElement): void {
@@ -261,71 +261,30 @@ async function handleSwitchProfile(profile: string): Promise<void> {
261261
}
262262
}
263263

264-
async function drainRefreshQueue(): Promise<void> {
265-
if (state.refreshWorkerActive) {
266-
return;
267-
}
268-
269-
state.refreshWorkerActive = true;
264+
async function performProfileRefresh(profile: string): Promise<void> {
265+
state.refreshActiveProfiles.push(profile);
266+
rerenderDashboard();
270267
try {
271-
while (state.refreshQueue.length > 0) {
272-
const profile = state.refreshQueue.shift();
273-
if (!profile) {
274-
continue;
275-
}
276-
277-
state.refreshActiveProfile = profile;
278-
rerenderDashboard();
279-
try {
280-
await refreshProfile(profile);
281-
showToast(t(state.locale, "refreshedProfile", { profile }));
282-
// The backend already wrote the new quota / plan into the
283-
// profiles index. Re-reading the snapshot picks those up
284-
// for every card without paying for a JSONL scan.
285-
//
286-
// `getCurrentLiveQuota` only matters when the refreshed
287-
// profile is also the active one — the live JSONL session
288-
// count can be newer than the API value we just persisted
289-
// (an in-flight `codex` session keeps appending
290-
// `token_count` events) and `select_current_quota` picks
291-
// the newer of the two. For non-active refreshes we skip
292-
// it; the active card panel for that profile is rebuilt
293-
// when the user switches to it, and the 15s ticker keeps
294-
// the panel honest in the meantime.
295-
try {
296-
const snapshot = await getProfilesSnapshot();
297-
applySnapshot(snapshot);
298-
if (snapshot.current_card?.folder_name === profile) {
299-
applyCurrentQuota(await getCurrentLiveQuota());
300-
}
301-
} catch (error) {
302-
// Best-effort: a transient snapshot fetch failure leaves
303-
// the cards on their pre-refresh state, which matches the
304-
// pre-PR `refreshAllData(false)` behavior. Surface only
305-
// to the console so a systematic failure is debuggable
306-
// without spamming the user with a toast they can't act
307-
// on.
308-
console.warn("Snapshot refresh after profile refresh failed:", error);
309-
}
310-
} catch (error) {
311-
showToast(refreshProfileErrorMessage(error), true);
312-
} finally {
313-
state.refreshActiveProfile = null;
314-
rerenderDashboard();
268+
await refreshProfile(profile);
269+
showToast(t(state.locale, "refreshedProfile", { profile }));
270+
try {
271+
const snapshot = await getProfilesSnapshot();
272+
applySnapshot(snapshot);
273+
if (snapshot.current_card?.folder_name === profile) {
274+
applyCurrentQuota(await getCurrentLiveQuota());
315275
}
276+
} catch (error) {
277+
console.warn("Snapshot refresh after profile refresh failed:", error);
316278
}
279+
} catch (error) {
280+
showToast(refreshProfileErrorMessage(error), true);
317281
} finally {
318-
state.refreshWorkerActive = false;
282+
state.refreshActiveProfiles = state.refreshActiveProfiles.filter(p => p !== profile);
319283
rerenderDashboard();
320284
}
321285
}
322286

323287
function handleRefreshProfile(profile: string): void {
324-
// Mirror `handleLoginProfile`'s `isRefreshPending(profile)` guard in
325-
// the opposite direction: when the same profile already has a login
326-
// in flight, both flows would otherwise race on writing per-profile
327-
// `auth.json`. Cross-profile refresh during a login is still allowed
328-
// (different sandbox + different `auth.json`).
329288
if (
330289
state.loading
331290
|| state.loginActiveProfile === profile
@@ -334,9 +293,7 @@ function handleRefreshProfile(profile: string): void {
334293
return;
335294
}
336295

337-
state.refreshQueue.push(profile);
338-
rerenderDashboard();
339-
void drainRefreshQueue();
296+
void performProfileRefresh(profile);
340297
}
341298

342299
function loginErrorCode(error: unknown): string | undefined {
@@ -1007,6 +964,12 @@ export function bootstrap(): void {
1007964
void refreshActiveQuotaSilently();
1008965
}, 5 * 60_000);
1009966

967+
// Relative countdown timer tick: rerender the dashboard every 15 seconds
968+
// to update the remaining relative countdown times.
969+
window.setInterval(() => {
970+
rerenderDashboard();
971+
}, 15_000);
972+
1010973
// Bulk plan refresh: forces an OAuth refresh on every OAuth profile so
1011974
// the cached id_token claims (plan tier, subscription expiry) move
1012975
// forward even for inactive profiles that the 5-min ticker never

src-tauri/shared/front/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const enMessages = {
5252
weeklyAllowance: "Weekly allowance",
5353
refresh: "Refresh {value}",
5454
refreshButton: "Refresh",
55+
resetsIn: "Resets in {value}",
56+
resetting: "Resetting...",
5557
loginButton: "Login",
5658
baseButton: "Base",
5759
planUnknownPaid: "Unknown paid plan",
@@ -299,6 +301,8 @@ const messages: Record<Locale, Messages> = {
299301
weeklyAllowance: "周额度",
300302
refresh: "刷新时间 {value}",
301303
refreshButton: "刷新",
304+
resetsIn: "{value} 后重置",
305+
resetting: "重置中...",
302306
loginButton: "登录",
303307
baseButton: "Base",
304308
planUnknownPaid: "未知付费",

src-tauri/shared/front/render.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,28 @@ function formatPercent(value: number | null): string {
148148
return value == null ? "--" : `${value}%`;
149149
}
150150

151-
function formatRefresh(value: string | null): string {
152-
return value || "--";
151+
function formatRefresh(entry: QuotaWindow | undefined): string {
152+
if (!entry) {
153+
return "--";
154+
}
155+
if (entry.reset_at_timestamp != null) {
156+
const diff = entry.reset_at_timestamp - Math.floor(Date.now() / 1000);
157+
if (diff > 0) {
158+
const h = Math.floor(diff / 3600);
159+
const m = Math.floor((diff % 3600) / 60);
160+
if (h > 0) {
161+
return t(state.locale, "resetsIn", { value: `${h}h ${m}m` });
162+
} else if (m > 0) {
163+
const s = diff % 60;
164+
return t(state.locale, "resetsIn", { value: `${m}m ${s}s` });
165+
} else {
166+
return t(state.locale, "resetsIn", { value: `${diff}s` });
167+
}
168+
} else {
169+
return t(state.locale, "resetting");
170+
}
171+
}
172+
return entry.refresh_at || "--";
153173
}
154174

155175
function escapeHtml(value: string): string {
@@ -333,7 +353,7 @@ function buildMetricLineMarkup(
333353
<section class="${metricClass}${unavailable ? " is-unavailable" : ""}">
334354
<div class="${lineClass}">
335355
<span class="${titleClass}">${escapeHtml(label)}</span>
336-
<span class="${refreshClass}">${escapeHtml(formatRefresh(entry?.refresh_at ?? null))}</span>
356+
<span class="${refreshClass}">${escapeHtml(formatRefresh(entry))}</span>
337357
<span class="${valueClass}">${escapeHtml(formatPercent(unavailable ? null : entry?.remaining_percent ?? null))}</span>
338358
</div>
339359
<div class="quota-track">
@@ -508,10 +528,8 @@ export function renderProfiles(
508528

509529
elements.profilesGrid.innerHTML = dashboard.profiles
510530
.map((profile) => {
511-
const refreshRunning = state.refreshActiveProfile === profile.folder_name;
512-
const refreshQueued =
513-
!refreshRunning && state.refreshQueue.includes(profile.folder_name);
514-
const refreshPending = refreshRunning || refreshQueued;
531+
const refreshRunning = state.refreshActiveProfiles.includes(profile.folder_name);
532+
const refreshPending = refreshRunning;
515533
const loginRunning = state.loginActiveProfile === profile.folder_name;
516534
// Any in-flight login (on this card or any other) blocks new logins
517535
// because the OAuth port and `.switch.lock` are global resources.
@@ -526,20 +544,16 @@ export function renderProfiles(
526544
const baseDisabled = state.loading || cardBusy;
527545
const switchDisabled =
528546
!profile.auth_present || state.loading || cardBusy || loginPending || profile.status === "current";
529-
// The login button stays clickable while *this* card's login is in
530-
// flight so the user can cancel the codex login process when they
531-
// close the OAuth tab without finishing. It's still disabled when
532-
// some other card holds the global login lock.
547+
548+
const refreshTitle = refreshRunning
549+
? t(state.locale, "profileRefreshRunning")
550+
: refreshDisabled
551+
? t(state.locale, "profileRefreshDisabled")
552+
: t(state.locale, "profileRefreshReady");
553+
533554
const loginDisabled =
534555
state.loading || refreshPending || (loginPending && !loginRunning);
535556
const unavailable = isProfileUnavailable(profile);
536-
const refreshTitle = refreshRunning
537-
? t(state.locale, "profileRefreshRunning")
538-
: refreshQueued
539-
? t(state.locale, "profileRefreshQueued")
540-
: refreshDisabled
541-
? t(state.locale, "profileRefreshDisabled")
542-
: t(state.locale, "profileRefreshReady");
543557

544558
const planTooltip = planFreshnessTitle(profile.plan_name, profile.last_plan_check_ms);
545559
const planClasses = [

src-tauri/shared/front/state.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import type { ThemeId } from "@front-shared/theme";
55
export const state = {
66
page: 1,
77
loading: false,
8-
refreshQueue: [] as string[],
9-
refreshActiveProfile: null as string | null,
10-
refreshWorkerActive: false,
8+
refreshActiveProfiles: [] as string[],
119
loginActiveProfile: null as string | null,
1210
currentProfile: null as string | null,
1311
route: "dashboard" as ShellRoute,

src-tauri/shared/front/tauri.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ function quota(
4040
five_hour: {
4141
remaining_percent: fiveHourPercent,
4242
refresh_at: fiveHourRefresh,
43+
reset_at_timestamp: null,
4344
},
4445
weekly: {
4546
remaining_percent: weeklyPercent,
4647
refresh_at: weeklyRefresh,
48+
reset_at_timestamp: null,
4749
},
4850
};
4951
}

src-tauri/shared/front/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface QuotaWindow {
22
remaining_percent: number | null;
33
refresh_at: string | null;
4+
reset_at_timestamp: number | null;
45
}
56

67
export interface QuotaSummary {

src-tauri/shared/runtime/chatgpt_api.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ pub fn looks_like_relogin_required(error_code: &str, message: &str) -> bool {
137137
}
138138
/// Refresh the access token a little before its actual expiry so a 401
139139
/// in-flight does not bubble up to the caller.
140-
const EXPIRY_SKEW_SECONDS: i64 = 60;
140+
const EXPIRY_SKEW_SECONDS: i64 = 300;
141141

142142
/// Outcome of a single ChatGPT-API refresh round-trip.
143143
///
@@ -626,6 +626,7 @@ fn quota_window_from_rate_limit(window: &RateLimitWindow) -> QuotaWindow {
626626
QuotaWindow {
627627
remaining_percent,
628628
refresh_at,
629+
reset_at_timestamp: window.reset_at,
629630
}
630631
}
631632

src-tauri/shared/runtime/codex_app_server.rs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -386,22 +386,21 @@ fn quota_window_from_app_server(window: &Value) -> QuotaWindow {
386386
});
387387
let remaining_percent =
388388
used_percent.map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8);
389-
let refresh_at = window
390-
.get("resetsAt")
391-
.and_then(Value::as_i64)
392-
.and_then(|seconds| {
393-
Utc.timestamp_opt(seconds, 0)
394-
.single()
395-
.map(|datetime| {
396-
datetime
397-
.with_timezone(&Local)
398-
.format("%Y-%m-%d %H:%M")
399-
.to_string()
400-
})
401-
});
389+
let reset_at_timestamp = window.get("resetsAt").and_then(Value::as_i64);
390+
let refresh_at = reset_at_timestamp.and_then(|seconds| {
391+
Utc.timestamp_opt(seconds, 0)
392+
.single()
393+
.map(|datetime| {
394+
datetime
395+
.with_timezone(&Local)
396+
.format("%Y-%m-%d %H:%M")
397+
.to_string()
398+
})
399+
});
402400
QuotaWindow {
403401
remaining_percent,
404402
refresh_at,
403+
reset_at_timestamp,
405404
}
406405
}
407406

src-tauri/shared/runtime/metadata.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ mod tests {
420420
five_hour: QuotaWindow {
421421
remaining_percent: Some(99),
422422
refresh_at: Some("2026-04-21 13:37".to_string()),
423+
..QuotaWindow::default()
423424
},
424425
..QuotaSummary::default()
425426
}
@@ -565,6 +566,7 @@ mod tests {
565566
weekly: QuotaWindow {
566567
remaining_percent: Some(82),
567568
refresh_at: Some("2026-05-15 12:00".to_string()),
569+
..QuotaWindow::default()
568570
},
569571
},
570572
..ProfileMetadata::default()

src-tauri/shared/runtime/models.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
55
pub struct QuotaWindow {
66
pub remaining_percent: Option<u8>,
77
pub refresh_at: Option<String>,
8+
pub reset_at_timestamp: Option<i64>,
89
}
910

1011
#[derive(Debug, Clone, Serialize, Deserialize, Default)]

0 commit comments

Comments
 (0)