summaryrefslogtreecommitdiffhomepage
path: root/src/features/account-usage/ui/AccountUsageView.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-14 17:04:35 +0900
committerAdam Malczewski <[email protected]>2026-10-14 17:04:35 +0900
commit10e78065201c8387494b8aafabb987da87183b3a (patch)
treef038f368459acbd3b817974100fb4bd4817ecbff /src/features/account-usage/ui/AccountUsageView.svelte
parent551a7a050e8cd25e795026a1060c9d5b5ea028bf (diff)
downloaddispatch-web-10e78065201c8387494b8aafabb987da87183b3a.tar.gz
dispatch-web-10e78065201c8387494b8aafabb987da87183b3a.zip
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.
Diffstat (limited to 'src/features/account-usage/ui/AccountUsageView.svelte')
-rw-r--r--src/features/account-usage/ui/AccountUsageView.svelte127
1 files changed, 127 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..c1af2a7
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.svelte
@@ -0,0 +1,127 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { AccountUsageResponse, LoadAccountUsage } from "../logic/types";
+ import { viewProviderCards } from "../logic/view-model";
+ import UsageBar from "./UsageBar.svelte";
+
+ let {
+ load,
+ now = () => Date.now(),
+ pollMs = 60_000,
+ }: {
+ load: LoadAccountUsage;
+ /** 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;
+ }
+ }
+
+ $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}
+ </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>