summaryrefslogtreecommitdiffhomepage
path: root/src/features/account-usage/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/account-usage/ui')
-rw-r--r--src/features/account-usage/ui/AccountUsageView.svelte155
-rw-r--r--src/features/account-usage/ui/AccountUsageView.test.ts184
-rw-r--r--src/features/account-usage/ui/DailyCreditsSettings.svelte126
-rw-r--r--src/features/account-usage/ui/UsageBar.svelte49
4 files changed, 514 insertions, 0 deletions
diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte
new file mode 100644
index 0000000..2a7a589
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.svelte
@@ -0,0 +1,155 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type {
+ AccountUsageReport,
+ AccountUsageResponse,
+ LoadAccountUsage,
+ SaveAccountUsageSettings,
+ } from "../logic/types";
+ import { viewProviderCards } from "../logic/view-model";
+ import DailyCreditsSettings from "./DailyCreditsSettings.svelte";
+ import UsageBar from "./UsageBar.svelte";
+
+ let {
+ load,
+ saveSettings,
+ now = () => Date.now(),
+ pollMs = 60_000,
+ }: {
+ load: LoadAccountUsage;
+ /** Save per-provider settings (Hyper allowance + reset time). */
+ saveSettings: SaveAccountUsageSettings;
+ /** Injected clock (tests). */
+ now?: () => number;
+ /** How often to re-read `GET /usage` (served from the backend cache). */
+ pollMs?: number;
+ } = $props();
+
+ let usage = $state<AccountUsageResponse | null>(null);
+ let error = $state<string | null>(null);
+ let refreshing = $state(false);
+ let inFlight = false;
+ /** Ticks so the pace marker + "resets in" labels advance between polls. */
+ let clock = $state(untrack(() => now()));
+
+ const cards = $derived(usage ? viewProviderCards(usage, clock) : []);
+
+ async function refresh(force: boolean): Promise<void> {
+ if (inFlight) return;
+ inFlight = true;
+ if (force) refreshing = true;
+ const result = await load(force ? { refresh: true } : undefined);
+ inFlight = false;
+ refreshing = false;
+ clock = now();
+ if (result.ok) {
+ usage = result.usage;
+ error = null;
+ } else {
+ error = result.unavailable
+ ? "Account usage is unavailable (backend extension not loaded)."
+ : result.error;
+ }
+ }
+
+ /** Splice a provider's refreshed report (from a settings save) into the view. */
+ function replaceReport(report: AccountUsageReport): void {
+ clock = now();
+ if (!usage) return;
+ usage = {
+ ...usage,
+ providers: usage.providers.map((p) => (p.provider === report.provider ? report : p)),
+ };
+ }
+
+ $effect(() => {
+ const interval = untrack(() => pollMs);
+ untrack(() => void refresh(false));
+ const poll = setInterval(() => void refresh(false), interval);
+ const tick = setInterval(() => {
+ clock = now();
+ }, 30_000);
+ return () => {
+ clearInterval(poll);
+ clearInterval(tick);
+ };
+ });
+</script>
+
+<div class="flex flex-col gap-3">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => void refresh(true)}
+ disabled={refreshing}
+ aria-label="Refresh account usage"
+ >
+ {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if}
+ Refresh
+ </button>
+ </div>
+
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {/if}
+
+ {#if usage === null && error === null}
+ <p class="text-xs opacity-60">Loading…</p>
+ {/if}
+
+ {#each cards as card (card.provider)}
+ <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage">
+ <div class="flex items-start justify-between gap-2">
+ <div class="flex min-w-0 flex-col">
+ <span class="text-sm font-semibold">{card.label}</span>
+ {#if card.subtitle}
+ <span class="truncate text-[11px] opacity-60">{card.subtitle}</span>
+ {/if}
+ </div>
+ <div class="flex shrink-0 flex-col items-end gap-0.5">
+ {#if card.stateBadge}
+ <span
+ class="badge badge-xs"
+ class:badge-warning={card.state === "stale"}
+ class:badge-error={card.state === "unavailable"}
+ class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"}
+ >{card.stateBadge}</span
+ >
+ {/if}
+ {#if card.ageLabel}
+ <span class="text-[11px] opacity-50">updated {card.ageLabel}</span>
+ {/if}
+ </div>
+ </div>
+
+ {#if card.message}
+ <p class="text-xs opacity-70">{card.message}</p>
+ {/if}
+ {#if card.creditNote}
+ <p class="text-xs text-warning">{card.creditNote}</p>
+ {/if}
+
+ {#each card.bars as bar (bar.id)}
+ <UsageBar {bar} />
+ {/each}
+
+ {#if card.daily}
+ <DailyCreditsSettings
+ daily={card.daily}
+ save={saveSettings}
+ onSaved={replaceReport}
+ {now}
+ />
+ {/if}
+ </section>
+ {/each}
+
+ {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))}
+ <p class="flex items-center gap-1.5 text-[11px] opacity-50">
+ <span class="inline-block size-2 rounded-full bg-base-content"></span>
+ on-track pace — usage that would exactly use the allowance by its reset
+ </p>
+ {/if}
+</div>
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..ec1e51b
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.test.ts
@@ -0,0 +1,184 @@
+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,
+ AccountUsageSettingsResult,
+ SaveAccountUsageSettings,
+} from "../logic/types";
+import AccountUsageView from "./AccountUsageView.svelte";
+
+const NOW = 1_760_000_000_000;
+
+const noSave: SaveAccountUsageSettings = async () => ({ ok: false, error: "unused" });
+const DAY = 86_400_000;
+
+const usage: AccountUsageResponse = {
+ servedAt: NOW,
+ providers: [
+ {
+ provider: "claude",
+ label: "Claude",
+ state: "fresh",
+ account: { accountId: "a", email: "[email protected]", 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,
+ dailyCredits: 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,
+ dailyCredits: 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<AccountUsageResult> => {
+ calls.push(opts);
+ return { ok: true, usage };
+ };
+ render(AccountUsageView, { load, saveSettings: noSave, 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<AccountUsageResult> => ({
+ ok: false,
+ error: "x",
+ unavailable: true,
+ });
+ render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW });
+ expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy();
+ });
+
+ it("edits the Hyper allowance + reset time and splices the saved report", async () => {
+ const hyper = {
+ provider: "hyper" as const,
+ label: "Hyper",
+ state: "fresh" as const,
+ account: { accountId: null, email: null, planType: null },
+ limits: [
+ {
+ id: "hyper:daily",
+ label: "Daily credits",
+ kind: "window" as const,
+ windowId: "1d",
+ shared: true,
+ tier: null,
+ durationMs: DAY,
+ resetsAt: null,
+ unit: "credits" as const,
+ used: 150,
+ limit: 250,
+ remaining: 100,
+ usedFraction: 0.6,
+ remainingFraction: 0.4,
+ status: "ok" as const,
+ },
+ ],
+ limitReached: null,
+ canServe: true,
+ creditOverage: false,
+ credits: null,
+ dailyCredits: { balance: 100, allowance: 250, resetAnchor: null, resetSource: null },
+ fetchedAt: NOW,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 300_000,
+ error: null,
+ };
+ const load = async (): Promise<AccountUsageResult> => ({
+ ok: true,
+ usage: { servedAt: NOW, providers: [hyper] },
+ });
+ const saved: Array<Parameters<SaveAccountUsageSettings>[1]> = [];
+ const saveSettings: SaveAccountUsageSettings = async (_p, settings) => {
+ saved.push(settings);
+ const r: AccountUsageSettingsResult = {
+ ok: true,
+ report: {
+ ...hyper,
+ limits: [
+ {
+ ...(hyper.limits[0] as (typeof hyper.limits)[0]),
+ limit: 300,
+ resetsAt: NOW + DAY / 2,
+ },
+ ],
+ dailyCredits: {
+ balance: 100,
+ allowance: 300,
+ resetAnchor: NOW - DAY / 2,
+ resetSource: "manual",
+ },
+ },
+ };
+ return r;
+ };
+ render(AccountUsageView, { load, saveSettings, now: () => NOW });
+ expect(await screen.findByText("150 / 250")).toBeTruthy();
+ expect(screen.queryByTestId("pace-marker")).toBeNull();
+
+ await userEvent.click(screen.getByRole("button", { name: "Edit Hyper credit settings" }));
+ const allowance = screen.getByLabelText("Daily credit allowance");
+ await userEvent.clear(allowance);
+ await userEvent.type(allowance, "300");
+ const time = screen.getByLabelText("Daily credit reset time") as HTMLInputElement;
+ await userEvent.type(time, "09:00");
+ await userEvent.click(screen.getByRole("button", { name: "Save Hyper credit settings" }));
+
+ expect(saved[0]?.dailyAllowance).toBe(300);
+ expect(typeof saved[0]?.resetAnchor).toBe("number");
+ expect(await screen.findByText(/set manually/)).toBeTruthy();
+ expect(screen.getByTestId("pace-marker")).toBeTruthy();
+ });
+});
diff --git a/src/features/account-usage/ui/DailyCreditsSettings.svelte b/src/features/account-usage/ui/DailyCreditsSettings.svelte
new file mode 100644
index 0000000..7ed710a
--- /dev/null
+++ b/src/features/account-usage/ui/DailyCreditsSettings.svelte
@@ -0,0 +1,126 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { AccountUsageReport, SaveAccountUsageSettings } from "../logic/types";
+ import {
+ anchorFromTimeOfDay,
+ type DailyCreditsView,
+ parseAllowanceInput,
+ } from "../logic/view-model";
+
+ let {
+ daily,
+ save,
+ onSaved,
+ now = () => Date.now(),
+ }: {
+ daily: DailyCreditsView;
+ save: SaveAccountUsageSettings;
+ /** Receives the refreshed report after a successful save. */
+ onSaved: (report: AccountUsageReport) => void;
+ now?: () => number;
+ } = $props();
+
+ let editing = $state(false);
+ let allowanceInput = $state("");
+ let timeInput = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+
+ const parsedAllowance = $derived(parseAllowanceInput(allowanceInput));
+ const timeValid = $derived(timeInput === "" || anchorFromTimeOfDay(timeInput, now()) !== null);
+
+ function startEdit(): void {
+ untrack(() => {
+ allowanceInput = String(daily.allowance);
+ timeInput = daily.resetTime;
+ });
+ error = null;
+ editing = true;
+ }
+
+ async function submit(): Promise<void> {
+ if (parsedAllowance === null || !timeValid) return;
+ saving = true;
+ error = null;
+ // Empty time clears a MANUAL anchor (a detected one may still apply).
+ const resetAnchor = timeInput === "" ? null : anchorFromTimeOfDay(timeInput, now());
+ const result = await save("hyper", { dailyAllowance: parsedAllowance, resetAnchor });
+ saving = false;
+ if (result.ok) {
+ editing = false;
+ onSaved(result.report);
+ } else {
+ error = result.error;
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-1 text-[11px]">
+ <div class="flex items-center justify-between gap-2">
+ <span class="opacity-70">{daily.balanceLabel}</span>
+ {#if !editing}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={startEdit}
+ aria-label="Edit Hyper credit settings">Edit</button
+ >
+ {/if}
+ </div>
+ {#if !editing}
+ <span class="opacity-60">
+ Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel})
+ </span>
+ {:else}
+ <form
+ class="flex flex-col gap-1"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ >
+ <label class="flex items-center justify-between gap-2">
+ <span>Daily allowance</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ class:input-error={parsedAllowance === null}
+ aria-label="Daily credit allowance"
+ bind:value={allowanceInput}
+ disabled={saving}
+ />
+ </label>
+ <label class="flex items-center justify-between gap-2">
+ <span>Reset time (local)</span>
+ <input
+ type="time"
+ class="input input-bordered input-xs w-24 font-mono"
+ class:input-error={!timeValid}
+ aria-label="Daily credit reset time"
+ bind:value={timeInput}
+ disabled={saving}
+ />
+ </label>
+ <span class="opacity-50">Leave the time empty to use the auto-detected refill time.</span>
+ {#if error}<span class="text-error">{error}</span>{/if}
+ <div class="flex justify-end gap-1">
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => (editing = false)}
+ disabled={saving}>Cancel</button
+ >
+ <button
+ type="submit"
+ class="btn btn-primary btn-xs"
+ disabled={saving || parsedAllowance === null || !timeValid}
+ aria-label="Save Hyper credit settings"
+ >
+ {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if}
+ Save
+ </button>
+ </div>
+ </form>
+ {/if}
+</div>
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 @@
+<script lang="ts">
+ import type { UsageBarView } from "../logic/view-model";
+
+ let { bar }: { bar: UsageBarView } = $props();
+
+ const progressClass = $derived(
+ {
+ success: "progress-success",
+ warning: "progress-warning",
+ error: "progress-error",
+ neutral: "",
+ }[bar.tone],
+ );
+</script>
+
+<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}>
+ <div class="flex items-baseline justify-between gap-2 text-xs">
+ <span class="truncate font-medium">{bar.label}</span>
+ <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span>
+ </div>
+ <div class="relative flex items-center">
+ <progress
+ class="progress h-2 w-full {progressClass}"
+ value={bar.percent}
+ max="100"
+ aria-label="{bar.label} usage"
+ ></progress>
+ {#if bar.pacePercent !== null}
+ <!-- Pace marker: where usage would be if spread evenly to the reset. -->
+ <span
+ class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content"
+ style="left: {bar.pacePercent}%"
+ data-testid="pace-marker"
+ title="On-track pace: {Math.round(bar.pacePercent)}%"
+ aria-hidden="true"
+ ></span>
+ {/if}
+ </div>
+ {#if bar.resetLabel || bar.paceLabel}
+ <div class="flex justify-between gap-2 text-[11px] opacity-60">
+ <span>{bar.resetLabel ?? ""}</span>
+ {#if bar.paceLabel}
+ <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace}
+ >{bar.paceLabel}</span
+ >
+ {/if}
+ </div>
+ {/if}
+</div>