From 10e78065201c8387494b8aafabb987da87183b3a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 14 Oct 2026 17:04:35 +0900 Subject: feat(account-usage): sidebar Usage view with Claude/Codex quota bars + on-track pace markers Consumes GET /usage (transport-contract 0.25.0). DaisyUI progress bars per quota window with a dot at the elapsed fraction of the window (the 'on track' level), ahead/under-pace labels, reset countdowns, stale/unavailable/not-configured states. --- .../account-usage/ui/AccountUsageView.svelte | 127 +++++++++++++++++++++ .../account-usage/ui/AccountUsageView.test.ts | 95 +++++++++++++++ src/features/account-usage/ui/UsageBar.svelte | 49 ++++++++ 3 files changed, 271 insertions(+) create mode 100644 src/features/account-usage/ui/AccountUsageView.svelte create mode 100644 src/features/account-usage/ui/AccountUsageView.test.ts create mode 100644 src/features/account-usage/ui/UsageBar.svelte (limited to 'src/features/account-usage/ui') diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte new file mode 100644 index 0000000..c1af2a7 --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.svelte @@ -0,0 +1,127 @@ + + +
+
+

Subscription usage

+ +
+ + {#if error} +

{error}

+ {/if} + + {#if usage === null && error === null} +

Loading…

+ {/if} + + {#each cards as card (card.provider)} +
+
+
+ {card.label} + {#if card.subtitle} + {card.subtitle} + {/if} +
+
+ {#if card.stateBadge} + {card.stateBadge} + {/if} + {#if card.ageLabel} + updated {card.ageLabel} + {/if} +
+
+ + {#if card.message} +

{card.message}

+ {/if} + {#if card.creditNote} +

{card.creditNote}

+ {/if} + + {#each card.bars as bar (bar.id)} + + {/each} +
+ {/each} + + {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))} +

+ + on-track pace — usage that would exactly use the allowance by its reset +

+ {/if} +
diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts new file mode 100644 index 0000000..e7e5799 --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.test.ts @@ -0,0 +1,95 @@ +import type { AccountUsageResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { AccountUsageResult } from "../logic/types"; +import AccountUsageView from "./AccountUsageView.svelte"; + +const NOW = 1_760_000_000_000; +const DAY = 86_400_000; + +const usage: AccountUsageResponse = { + servedAt: NOW, + providers: [ + { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "me@x.com", planType: "max" }, + limits: [ + { + id: "claude:7d", + label: "7 Day", + kind: "window", + windowId: "7d", + shared: true, + tier: null, + durationMs: 28 * DAY, + resetsAt: NOW + 14 * DAY, + unit: "percent", + used: 30, + limit: 100, + remaining: 70, + usedFraction: 0.3, + remainingFraction: 0.7, + status: "ok", + }, + ], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }, + { + provider: "codex", + label: "Codex", + state: "not-configured", + account: null, + limits: [], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + fetchedAt: null, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 30_000, + error: "No Codex OAuth credentials", + }, + ], +}; + +describe("AccountUsageView", () => { + it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => { + const calls: Array<{ refresh?: boolean } | undefined> = []; + const load = async (opts?: { refresh?: boolean }): Promise => { + calls.push(opts); + return { ok: true, usage }; + }; + render(AccountUsageView, { load, now: () => NOW }); + const bar = await screen.findByLabelText("7 Day usage"); + expect(bar.tagName).toBe("PROGRESS"); + expect(bar.classList.contains("progress")).toBe(true); + expect(bar.getAttribute("value")).toBe("30"); + const marker = screen.getByTestId("pace-marker"); + expect(marker.getAttribute("style")).toContain("left: 50%"); + expect(screen.getByText("20% under pace")).toBeTruthy(); + expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" })); + expect(calls.at(-1)).toEqual({ refresh: true }); + }); + + it("shows the unavailable message on 503", async () => { + const load = async (): Promise => ({ + ok: false, + error: "x", + unavailable: true, + }); + render(AccountUsageView, { load, now: () => NOW }); + expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy(); + }); +}); diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte new file mode 100644 index 0000000..f4298c1 --- /dev/null +++ b/src/features/account-usage/ui/UsageBar.svelte @@ -0,0 +1,49 @@ + + +
+
+ {bar.label} + {bar.valueLabel} +
+
+ + {#if bar.pacePercent !== null} + + + {/if} +
+ {#if bar.resetLabel || bar.paceLabel} +
+ {bar.resetLabel ?? ""} + {#if bar.paceLabel} + {bar.paceLabel} + {/if} +
+ {/if} +
-- cgit v1.2.3