diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 2715f48608..7b485eca71 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -26,9 +26,19 @@ function rawCustomWindowRank(rawLabel: string): number { if (rawLabel === "5h") return 0; if (rawLabel === "First-party models") return 2; if (rawLabel === "API usage") return 3; + if (rawLabel === "Total subscription credits") return 4.5; return 5; } +const SUBSCRIPTION_CREDITS_LABEL = "Total subscription credits"; + +function canonicalCustomWindowLabel(rawLabel: string): string { + const trimmed = rawLabel.trim(); + return trimmed.toLowerCase() === SUBSCRIPTION_CREDITS_LABEL.toLowerCase() + ? SUBSCRIPTION_CREDITS_LABEL + : trimmed; +} + function localizeCustomQuotaLabel(rawLabel: string, t: TFn): string { switch (rawLabel) { case "First-party models": @@ -84,11 +94,12 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }); } for (const w of displayQuota.customWindows ?? []) { - const localized = localizeCustomQuotaLabel(w.label, t); + const customLabel = canonicalCustomWindowLabel(w.label); + const localized = localizeCustomQuotaLabel(customLabel, t); ranked.push({ - rank: rawCustomWindowRank(w.label), + rank: rawCustomWindowRank(customLabel), row: { - customLabel: w.label, + customLabel, label: localized, limitLabel: localized, percent: w.percent, @@ -96,6 +107,24 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }, }); } + if (displayQuota.creditsUsd && typeof displayQuota.creditsUsd.percent === "number") { + const hasSubscriptionCreditsCustom = displayQuota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom) { + const localized = localizeCustomQuotaLabel(SUBSCRIPTION_CREDITS_LABEL, t); + ranked.push({ + rank: rawCustomWindowRank(SUBSCRIPTION_CREDITS_LABEL), + row: { + customLabel: SUBSCRIPTION_CREDITS_LABEL, + label: localized, + limitLabel: localized, + percent: displayQuota.creditsUsd.percent, + resetAt: displayQuota.creditsUsd.expiresAt, + }, + }); + } + } return ranked.sort((a, b) => a.rank - b.rank).map(entry => entry.row); } @@ -107,6 +136,12 @@ export function maxQuotaUtilisation(quota: AccountQuota | null): number { for (const w of quota.customWindows ?? []) { if (typeof w.percent === "number") vals.push(w.percent); } + const hasSubscriptionCreditsCustom = quota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom && typeof quota.creditsUsd?.percent === "number") { + vals.push(quota.creditsUsd.percent); + } return vals.length ? Math.max(...vals) : -1; } diff --git a/tests/gui/quota-bars-rows.test.ts b/tests/gui/quota-bars-rows.test.ts index 57ddd47cfd..e4d0f0a24e 100644 --- a/tests/gui/quota-bars-rows.test.ts +++ b/tests/gui/quota-bars-rows.test.ts @@ -69,6 +69,38 @@ describe("buildQuotaRows (WP070)", () => { expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits"]); }); + test("direct creditsUsd renders Total subscription credits with resetAt", () => { + const rows = buildQuotaRows(quota({ + fiveHourPercent: 0, + weeklyPercent: 0, + creditsUsd: { used: 89.96, limit: 90, remaining: 0.04, percent: 99.96, expiresAt: 1790430938000 }, + }), null, t); + expect(rows.map(r => r.limitLabel)).toEqual([ + "quota.fiveHourLimit", + "quota.weeklyLimit", + "quota.totalSubscriptionCredits", + ]); + expect(rows[2]?.percent).toBe(99.96); + expect(rows[2]?.resetAt).toBe(1790430938000); + }); + + test("direct creditsUsd does not duplicate an existing credits custom window", () => { + const rows = buildQuotaRows(quota({ + customWindows: [{ label: " TOTAL SUBSCRIPTION CREDITS ", percent: 50 }], + creditsUsd: { used: 50, limit: 100, remaining: 50, percent: 50 }, + }), null, t); + expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits"]); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + }); + + test("unrelated credit windows do not suppress direct subscription credits", () => { + const rows = buildQuotaRows(quota({ + customWindows: [{ label: "API credits", percent: 20 }], + creditsUsd: { used: 50, limit: 100, remaining: 50, percent: 50 }, + }), null, t); + expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits", "API credits"]); + }); + test("null and empty quotas produce no rows; 30-day plans strip to monthly", () => { expect(buildQuotaRows(null, null, t)).toEqual([]); expect(buildQuotaRows(quota({}), null, t)).toEqual([]); @@ -93,6 +125,18 @@ describe("maxQuotaUtilisation", () => { fiveHourPercent: 10, customWindows: [{ label: "x", percent: 95 }], }))).toBe(95); + expect(maxQuotaUtilisation(quota({ + fiveHourPercent: 0, + weeklyPercent: 0, + creditsUsd: { used: 90, limit: 90, remaining: 0, percent: 100 }, + }))).toBe(100); + }); + + test("subscription-credit urgency follows the visible canonical custom window", () => { + expect(maxQuotaUtilisation(quota({ + customWindows: [{ label: " total subscription credits ", percent: 25 }], + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99 }, + }))).toBe(25); }); });