From b6396bef1504442db43a8fa2a1a864f126e4acfd Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:12:30 +0800 Subject: [PATCH] Simplify quota warnings and historical readings --- docs/design.md | 18 +++++--- docs/testing.md | 10 ++++- src/App.test.tsx | 111 +++++++++++++++++++++++++++++++++++++++++++++-- src/App.tsx | 95 ++++++++++++++++++++++++++++++---------- src/i18n.ts | 10 ++--- src/styles.css | 4 +- 6 files changed, 207 insertions(+), 41 deletions(-) diff --git a/docs/design.md b/docs/design.md index 53fccfd..31706f4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -137,12 +137,16 @@ is installed without a click. notice because the card itself updates. Each card shows how long ago its quota was read next to its refresh button. Automatic reads leave switching available while waiting for the provider and never refresh a saved - credential. A failed card keeps a visible, - compact warning icon beside quota; - its hover/focus explanation gives the safe reason and last successful update. - Old percentages say "Last" and use muted meters; an unavailable result stays - unknown. Actions stay in the account menu, not in the warning tooltip. Batch - feedback describes quota results only. + credential. A failed card has one compact warning icon beside its first quota + title, with no permanent failure sentence or stale badge. Hover, keyboard + focus, or click opens the safe reason and last successful update; Escape, + focus leaving, or an outside click closes it. Old percentages say "Last" and + use muted meters without reset countdowns or replacement explanation lines. + An unavailable result stays unknown. Stale data without a failure says + "Quota awaiting update" in its details rather than claiming a failed refresh. + A successful refresh clears the warning and historical labels. Actions stay + in the account menu, not in the warning details. Batch feedback describes + quota results only. - A saved email can be copied from its card menu. Every saved ChatGPT account also keeps **Sign in again** in that menu, so recovery never depends on GSwitch diagnosing a failure correctly. After a confirmed authentication @@ -161,7 +165,7 @@ is installed without a click. such as `4h 55m · 09/27 16:30` or `4小时55分 · 09/27 16:30`, without a visible introductory label or trailing “in/后”. Show the year only across years and allow wrapping at the separator on narrow cards without truncating the date. - Stale data shows its stale state instead of a live countdown; an elapsed + Stale data omits the live countdown; an elapsed fresh reset marker says to refresh. Hover, keyboard focus, and assistive text provide the full reset meaning and date. Reset credits use two aligned lines: count and action above, compact relative diff --git a/docs/testing.md b/docs/testing.md index 3f5a198..c958453 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -187,7 +187,15 @@ as applicable: "updated" age; - a one-account quota refresh updating its own projection without rebuilding the workspace, and that account's busy state leaving unrelated cards usable; -- quota bucket normalization, zero remaining, and stale-cache labeling; +- quota bucket normalization and zero remaining; +- cached and uncached refresh failures showing at most one quota warning per + card, historical percentages and muted meters without a stale badge or + countdown, safe failure details and the last successful time, hover/click/ + keyboard access and Escape/blur/outside dismissal, stale data without a + failure described as awaiting an update, authentication recovery, and a + successful retry clearing the warning and historical labels; +- reopening with a successful reading older than five minutes keeping its + normal quota presentation and existing refresh policy; - a quota projection failure leaving the saved-account workspace and Switch action available; - provider refresh before reset-credit selection; diff --git a/src/App.test.tsx b/src/App.test.tsx index cc77873..4711fa5 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -630,7 +630,7 @@ describe("GSwitch account workspace", () => { render(); expect(await screen.findByText("Personal")).toBeInTheDocument(); - expect(screen.getByText("Stale")).toBeInTheDocument(); + expect(screen.queryByText("Stale")).not.toBeInTheDocument(); expect(screen.getByText("Last 30%")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Details" })); @@ -1192,7 +1192,7 @@ describe("GSwitch account workspace", () => { ); expect(await screen.findByText("Could not refresh quota for 1 of your accounts. See the affected cards for their latest result.")).toBeInTheDocument(); const failedCard = screen.getByRole("heading", { name: "person3@example.com" }).closest(".account-card"); - expect(failedCard).toHaveTextContent(/Last result is stale|Not available/); + expect(failedCard).not.toHaveTextContent("Last result is stale"); expect(within(failedCard as HTMLElement).getByRole("button", { name: "Quota update failed for person3@example.com" })).toBeInTheDocument(); expect(failedCard).toHaveTextContent("GSwitch could not update this quota. Try again later."); expect(failedCard).toHaveTextContent("Last 30%"); @@ -1210,8 +1210,12 @@ describe("GSwitch account workspace", () => { const alert = await screen.findByRole("button", { name: "Quota update failed for person@example.com" }); expect(alert).toHaveAttribute("aria-describedby", "quota-error-account-1"); expect(screen.getByText("Last 30%")).toBeInTheDocument(); - expect(screen.getByText("This saved sign-in was rejected. Sign in to this account again.")).toBeInTheDocument(); - expect(screen.getByText(/Last successful update:/)).toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await userEvent.click(alert); + const details = screen.getByRole("tooltip"); + expect(details).toHaveTextContent("This saved sign-in was rejected. Sign in to this account again."); + expect(details).toHaveTextContent(/Last successful update:/); + expect(within(details).queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByText("Quota could not be refreshed. The last result is shown when available.")).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText("More actions for person@example.com")); @@ -1223,6 +1227,105 @@ describe("GSwitch account workspace", () => { expect(await screen.findByRole("dialog", { name: "Sign in again · person@example.com" })).toBeInTheDocument(); }); + it("shows one compact warning for a failed cached reading and opens it by hover, click, or keyboard focus", async () => { + const user = userEvent.setup(); + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue(staleQuota); + mocks.refreshAccountQuota.mockRejectedValue({ code: "network", message: "private provider payload" }); + render(); + + const warning = await screen.findByRole("button", { name: "Quota update failed for person@example.com" }); + const card = warning.closest("article")!; + expect(within(card).getAllByRole("button", { name: /Quota update failed/ })).toHaveLength(1); + expect(card).toHaveTextContent("Last 30%"); + expect(card).toHaveTextContent("Last 85%"); + expect(card).not.toHaveTextContent(/Stale|Last result is stale|private provider payload/); + expect(card.querySelectorAll(".quota-meter small")).toHaveLength(0); + expect(within(card).queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + await user.hover(warning); + expect(screen.getByRole("tooltip")).toHaveTextContent("The quota service could not be reached. Check your connection."); + await user.unhover(warning); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + await user.click(warning); + expect(screen.getByRole("tooltip")).toHaveTextContent(/Last successful update:/); + await user.keyboard("{Escape}"); + expect(warning).toHaveFocus(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await user.click(warning); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + await user.tab(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await user.tab({ shift: true }); + expect(warning).toHaveFocus(); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + await user.click(screen.getByRole("heading", { name: "GSwitch" })); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + mocks.refreshAccountQuota.mockResolvedValue({ ...staleQuota, status: "fresh" }); + await user.click(screen.getByRole("button", { name: "Refresh person@example.com" })); + expect(await within(card).findByText("30%")).toBeInTheDocument(); + expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument(); + expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument(); + }); + + it("describes stale data without a failure as awaiting an update and omits an invalid last-success time", async () => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue({ + ...staleQuota, + snapshot: { ...staleQuota.snapshot!, fetched_at_unix_ms: 0 }, + }); + mocks.refreshAccountQuota.mockImplementation(() => new Promise(() => undefined)); + render(); + + const warning = await screen.findByRole("button", { name: "Quota awaiting update for person@example.com" }); + expect(screen.queryByRole("button", { name: /Quota update failed/ })).not.toBeInTheDocument(); + await userEvent.click(warning); + const details = screen.getByRole("tooltip"); + expect(details).toHaveTextContent("Quota awaiting update"); + expect(details).not.toHaveTextContent(/failed|Last successful update/); + expect(screen.getByText("Last 30%")).toBeInTheDocument(); + }); + + it("keeps a failed quota without a cache unknown and clears its warning after a successful retry", async () => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.refreshAccountQuota.mockRejectedValueOnce({ code: "network" }); + render(); + + const warning = await screen.findByRole("button", { name: "Quota update failed for person@example.com" }); + const card = warning.closest("article")!; + expect(within(card).getByText("Not available")).toBeInTheDocument(); + expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument(); + expect(card.querySelector(".quota-heading > strong")).toHaveTextContent("—"); + await userEvent.click(warning); + expect(screen.getByRole("tooltip")).not.toHaveTextContent("Last successful update"); + + mocks.refreshAccountQuota.mockResolvedValue({ ...staleQuota, status: "fresh" }); + await userEvent.click(screen.getByRole("button", { name: "Refresh person@example.com" })); + expect(await within(card).findByText("30%")).toBeInTheDocument(); + expect(within(card).getAllByRole("progressbar")).toHaveLength(2); + expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument(); + expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument(); + }); + + it("does not turn a successful reading older than five minutes into stale data on reopening", async () => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue({ + ...staleQuota, + status: "fresh", + snapshot: { ...staleQuota.snapshot!, fetched_at_unix_ms: Date.now() - 10 * 60_000 }, + }); + render(); + + expect(await screen.findByText("30%")).toBeInTheDocument(); + expect(screen.getAllByRole("progressbar")).toHaveLength(2); + expect(screen.queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/Last 30%|Last result is stale|Stale/)).not.toBeInTheDocument(); + expect(mocks.refreshAccountQuota).not.toHaveBeenCalled(); + }); + it("shows a direct recovery action with the saved email and workspace", async () => { mocks.listAccounts.mockResolvedValue([{ ...chatAccount, sign_in_required: true }]); mocks.accountQuota.mockResolvedValue(staleQuota); diff --git a/src/App.tsx b/src/App.tsx index 4e4332f..7242b98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -675,6 +675,72 @@ function QuotaResetTime({ timestamp, t, formatLocale }: { ); } +function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }: { + accountName: string; + failure?: QuotaRefreshFailureCode; + id: string; + lastSuccess?: number; + formatLocale: string; + t: Translator; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const closeOutside = (event: Event) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false); + }; + const closeOnEscape = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + const close = () => setOpen(false); + document.addEventListener("pointerdown", closeOutside); + document.addEventListener("focusin", closeOutside); + document.addEventListener("keydown", closeOnEscape); + window.addEventListener("blur", close); + return () => { + document.removeEventListener("pointerdown", closeOutside); + document.removeEventListener("focusin", closeOutside); + document.removeEventListener("keydown", closeOnEscape); + window.removeEventListener("blur", close); + }; + }, [open]); + + return ( + setOpen(true)} + onMouseLeave={() => { + if (!rootRef.current?.contains(document.activeElement)) setOpen(false); + }} + onFocus={() => setOpen(true)} + onBlur={(event) => { + if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) setOpen(false); + }} + > + + + + ); +} + function QuotaMeter({ accountName, label, @@ -703,20 +769,8 @@ function QuotaMeter({ return (
- {label}{failure ? ( - - - - {t("quota.refreshErrorLabel")} - {quotaFailureMessage(t, failure)} - {lastSuccess ? {t("quota.lastUpdated", { time: formatDateTimeWithRelative(lastSuccess / 1000, formatLocale) })} : null} - - + {label}{failureId && (failure || status === "stale") ? ( + ) : null} {value === undefined || !known ? "—" : status === "stale" ? t("quota.previousValue", { value: formatPercent(value, formatLocale) }) @@ -735,15 +789,13 @@ function QuotaMeter({ style={{ width: String(value ?? 0) + "%" }} />
- - {!known + {status !== "stale" || value === undefined ? + {!known || value === undefined ? t("quota.notAvailable") - : status === "stale" - ? t("quota.lastResultStale") - : window?.resets_at + : window?.resets_at ? : t("quota.resetTimeUnavailable")} - + : null}
); } @@ -851,7 +903,6 @@ function AccountCard({ {accountPlan(account, t)} {signInRequired ? {t("account.signInRequired")} : null} {!signInRequired && active && account.needs_apply ? {t("account.pendingApply")} : null} - {quota?.status === "stale" ? {t("account.stale")} : null} {isApiKey ? ( @@ -869,7 +920,7 @@ function AccountCard({ = { "account.signInRequired": "需登录", "account.pendingApply": "待应用", "account.apply": "应用 {name} 的新登录", - "account.stale": "已过期", "account.savedNoBillableCheck": "已保存 · 未测试密钥", "account.apiKeyDescription": "订阅额度、重置额度和唤醒不适用于 API 密钥账户。", "account.refresh": "刷新 {name}", @@ -427,8 +426,9 @@ const zhCN: Record = { "quota.durationDays": "{count} 天", "quota.durationHours": "{count} 小时", "quota.otherWindow": "Codex 额度", - "quota.notAvailable": "不可用", - "quota.lastResultStale": "上次结果已过期", + "quota.notAvailable": "暂无数据", + "quota.pending": "额度待更新", + "quota.pendingFor": "{name} 的额度待更新", "quota.resets": "重置于 {time}", "quota.resetTimePassed": "待刷新", "quota.resetTimeUnavailable": "重置时间不可用", diff --git a/src/styles.css b/src/styles.css index 85ce685..0a9243f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -858,8 +858,8 @@ summary:focus-visible { } .quota-alert-tooltip strong { font-size: 12px; font-weight: 700; } -.quota-alert:hover .quota-alert-tooltip, -.quota-alert:focus-within .quota-alert-tooltip { opacity: 1; visibility: visible; } +.quota-alert-tooltip[hidden] { display: none; } +.quota-alert[data-open="true"] .quota-alert-tooltip { opacity: 1; visibility: visible; } .quota-meter-stale .quota-heading > strong { color: var(--muted); font-size: 11px; } .quota-meter-stale .quota-track span { opacity: .45; }