diff options
Diffstat (limited to 'src/features/account-usage')
| -rw-r--r-- | src/features/account-usage/index.ts | 41 | ||||
| -rw-r--r-- | src/features/account-usage/logic/types.ts | 48 | ||||
| -rw-r--r-- | src/features/account-usage/logic/view-model.test.ts | 277 | ||||
| -rw-r--r-- | src/features/account-usage/logic/view-model.ts | 403 | ||||
| -rw-r--r-- | src/features/account-usage/ui/AccountUsageView.svelte | 155 | ||||
| -rw-r--r-- | src/features/account-usage/ui/AccountUsageView.test.ts | 184 | ||||
| -rw-r--r-- | src/features/account-usage/ui/DailyCreditsSettings.svelte | 126 | ||||
| -rw-r--r-- | src/features/account-usage/ui/UsageBar.svelte | 49 |
8 files changed, 1283 insertions, 0 deletions
diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts new file mode 100644 index 0000000..76204dc --- /dev/null +++ b/src/features/account-usage/index.ts @@ -0,0 +1,41 @@ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageResult, + AccountUsageSettingsResult, + AccountUsageState, + LoadAccountUsage, + SaveAccountUsageSettings, + UpdateAccountUsageSettingsRequest, +} from "./logic/types"; +export type { + BarTone, + DailyCreditsView, + ProviderCardView, + UsageBarView, +} from "./logic/view-model"; +export { + anchorFromTimeOfDay, + elapsedFraction, + formatAge, + formatDuration, + normalizeAccountUsage, + normalizeAccountUsageReport, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewProviderCards, + viewUsageBar, +} from "./logic/view-model"; +export { default as AccountUsageView } from "./ui/AccountUsageView.svelte"; +export { default as DailyCreditsSettings } from "./ui/DailyCreditsSettings.svelte"; +export { default as UsageBar } from "./ui/UsageBar.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "account-usage", + description: "Claude + Codex + Hyper subscription usage bars with on-track pace markers", +} as const; diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts new file mode 100644 index 0000000..ff92590 --- /dev/null +++ b/src/features/account-usage/logic/types.ts @@ -0,0 +1,48 @@ +import type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the account-usage feature — zero DOM, zero effects. + * + * The backend (`account-usage` extension, transport-contract 0.25.0) polls the + * Claude + Codex subscription usage endpoints and caches them (5 min TTL, + * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual + * refresh sends `?refresh=1`, which the server still rate-limits. + */ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +}; + +/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */ +export type AccountUsageResult = + | { readonly ok: true; readonly usage: AccountUsageResponse } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */ +export type LoadAccountUsage = (opts?: { + readonly refresh?: boolean; +}) => Promise<AccountUsageResult>; + +/** Outcome of `PUT /usage/:provider/settings` — the provider's refreshed report. */ +export type AccountUsageSettingsResult = + | { readonly ok: true; readonly report: AccountUsageReport } + | { readonly ok: false; readonly error: string }; + +/** Injected port: update a provider's usage settings (today only `hyper`). */ +export type SaveAccountUsageSettings = ( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, +) => Promise<AccountUsageSettingsResult>; diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts new file mode 100644 index 0000000..78390cf --- /dev/null +++ b/src/features/account-usage/logic/view-model.test.ts @@ -0,0 +1,277 @@ +import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + anchorFromTimeOfDay, + elapsedFraction, + formatDuration, + normalizeAccountUsage, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewUsageBar, +} from "./view-model"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const NOW = 1_760_000_000_000; + +function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit { + return { + id: "claude:5h", + label: "5 Hour", + kind: "window", + windowId: "5h", + shared: true, + tier: null, + durationMs: 5 * HOUR, + resetsAt: NOW + 2 * HOUR, + unit: "percent", + used: 40, + limit: 100, + remaining: 60, + usedFraction: 0.4, + remainingFraction: 0.6, + status: "ok", + ...over, + }; +} + +function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport { + return { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [limit()], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW - 3 * 60_000, + lastAttemptAt: NOW - 3 * 60_000, + nextRefreshAt: NOW + 2 * 60_000, + error: null, + ...over, + }; +} + +describe("pace marker (elapsedFraction)", () => { + it("is halfway two weeks into a four-week window", () => { + const resetsAt = NOW + 14 * DAY; + expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5); + }); + + it("clamps to 0..1 and is null when the window is unknown", () => { + expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0); + expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1); + expect(elapsedFraction(null, NOW, NOW)).toBeNull(); + expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull(); + }); +}); + +describe("viewUsageBar", () => { + it("places the pace marker at elapsed % and labels ahead/under pace", () => { + // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under. + const bar = viewUsageBar(limit(), NOW); + expect(bar.percent).toBe(40); + expect(bar.pacePercent).toBeCloseTo(60); + expect(bar.paceLabel).toBe("20% under pace"); + expect(bar.aheadOfPace).toBe(false); + expect(bar.resetLabel).toBe("resets in 2h"); + expect(bar.valueLabel).toBe("40%"); + expect(bar.tone).toBe("success"); + + const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW); + expect(ahead.paceLabel).toBe("20% ahead of pace"); + expect(ahead.aheadOfPace).toBe(true); + expect(ahead.tone).toBe("warning"); + }); + + it("has no pace marker for spend meters; formats dollars", () => { + const spend = viewUsageBar( + limit({ + id: "claude:extra", + kind: "spend", + unit: "usd", + durationMs: null, + resetsAt: null, + used: 12.5, + limit: 50, + usedFraction: 0.25, + }), + NOW, + ); + expect(spend.pacePercent).toBeNull(); + expect(spend.valueLabel).toBe("$12.50 / $50.00"); + const uncapped = viewUsageBar( + limit({ + unit: "usd", + kind: "spend", + limit: null, + usedFraction: null, + used: 3, + durationMs: null, + }), + NOW, + ); + expect(uncapped.valueLabel).toBe("$3.00 (no cap)"); + expect(uncapped.percent).toBe(0); + }); + + it("exhausted → error tone", () => { + expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe( + "error", + ); + }); +}); + +describe("viewProviderCard", () => { + it("fresh card with subtitle and age", () => { + const c = viewProviderCard(report(), NOW); + expect(c.subtitle).toBe("Max · [email protected]"); + expect(c.stateBadge).toBeNull(); + expect(c.message).toBeNull(); + expect(c.ageLabel).toBe("3m ago"); + expect(c.bars).toHaveLength(1); + }); + + it("stale / not-configured / unsupported states", () => { + const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW); + expect(stale.stateBadge).toBe("stale"); + expect(stale.message).toContain("HTTP 429"); + const nc = viewProviderCard( + report({ + provider: "codex", + label: "Codex", + state: "not-configured", + limits: [], + account: null, + }), + NOW, + ); + expect(nc.stateBadge).toBe("not configured"); + expect(nc.message).toContain("Codex"); + expect(nc.ageLabel).toBeNull(); + const un = viewProviderCard( + report({ state: "unsupported", error: "api key", limits: [] }), + NOW, + ); + expect(un.message).toBe("api key"); + }); + + it("credit overage note", () => { + const c = viewProviderCard( + report({ creditOverage: true, limitReached: true, canServe: true }), + NOW, + ); + expect(c.creditNote).toContain("credits"); + }); +}); + +describe("normalizeAccountUsage", () => { + it("coerces a well-formed body and drops malformed rows", () => { + const n = normalizeAccountUsage( + { + servedAt: 5, + providers: [ + { ...report(), limits: [limit(), { id: "bad" }] }, + { provider: "gemini" }, + "junk", + ], + }, + 0, + ); + expect(n.servedAt).toBe(5); + expect(n.providers).toHaveLength(1); + expect(n.providers[0]?.limits).toHaveLength(1); + }); + + it("malformed body → empty", () => { + expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 }); + }); +}); + +describe("formatDuration", () => { + it("formats", () => { + expect(formatDuration(30_000)).toBe("<1m"); + expect(formatDuration(45 * 60_000)).toBe("45m"); + expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m"); + expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h"); + }); +}); + +describe("hyper daily credits", () => { + const hyperLimit = limit({ + id: "hyper:daily", + label: "Daily credits", + windowId: "1d", + unit: "credits", + durationMs: DAY, + resetsAt: NOW + 12 * HOUR, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + }); + + it("formats credits and places the pace marker halfway through the day", () => { + const bar = viewUsageBar(hyperLimit, NOW); + expect(bar.valueLabel).toBe("150 / 250"); + expect(bar.pacePercent).toBeCloseTo(50); + expect(bar.paceLabel).toBe("10% ahead of pace"); + }); + + it("card exposes balance + reset source", () => { + const c = viewProviderCard( + report({ + provider: "hyper", + label: "Hyper", + account: { accountId: null, email: null, planType: null }, + limits: [hyperLimit], + dailyCredits: { balance: 300, allowance: 250, resetAnchor: null, resetSource: null }, + }), + NOW, + ); + expect(c.daily).toMatchObject({ + balanceLabel: "300 credits left (incl. bundles)", + allowance: 250, + resetTime: "", + resetSource: null, + }); + expect(c.daily?.resetSourceLabel).toContain("unknown"); + }); + + it("round-trips a local time of day into a past anchor", () => { + const now = new Date(2026, 9, 14, 15, 30).getTime(); + const a = anchorFromTimeOfDay("09:00", now); + expect(a).toBe(new Date(2026, 9, 14, 9, 0).getTime()); + expect(anchorFromTimeOfDay("17:00", now)).toBe(new Date(2026, 9, 13, 17, 0).getTime()); + expect(toLocalTimeOfDay(a ?? 0)).toBe("09:00"); + expect(anchorFromTimeOfDay("25:00", now)).toBeNull(); + expect(anchorFromTimeOfDay("noon", now)).toBeNull(); + }); + + it("parses the allowance input", () => { + expect(parseAllowanceInput(" 250 ")).toBe(250); + expect(parseAllowanceInput("0")).toBeNull(); + expect(parseAllowanceInput("2.5")).toBeNull(); + }); + + it("normalizes hyper reports + credit units", () => { + const n = normalizeAccountUsage( + { + providers: [ + { + ...report({ provider: "hyper" }), + limits: [hyperLimit], + dailyCredits: { balance: 1, allowance: 250, resetAnchor: 5, resetSource: "manual" }, + }, + ], + }, + 0, + ); + expect(n.providers[0]?.limits[0]?.unit).toBe("credits"); + expect(n.providers[0]?.dailyCredits?.resetSource).toBe("manual"); + }); +}); diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts new file mode 100644 index 0000000..17d37a6 --- /dev/null +++ b/src/features/account-usage/logic/view-model.ts @@ -0,0 +1,403 @@ +import type { + AccountUsageCredits, + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageLimitStatus, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, +} from "@dispatch/transport-contract"; + +/** + * Pure view-models for the account-usage feature — zero DOM, zero effects, + * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit + * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be + * to use the whole allowance exactly by the reset (elapsed fraction of the + * window: 2 weeks into a 4-week window ⇒ 50%). + */ + +// ── Normalization (network seam) ────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} +function bool(v: unknown): boolean | null { + return typeof v === "boolean" ? v : null; +} + +const STATES: readonly AccountUsageState[] = [ + "fresh", + "stale", + "unavailable", + "unsupported", + "not-configured", +]; +const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"]; + +function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null { + const id = str(r.id); + const used = num(r.used); + if (id === null || used === null) return null; + const status = STATUSES.find((s) => s === r.status) ?? "unknown"; + return { + id, + label: str(r.label) ?? id, + kind: r.kind === "spend" ? "spend" : "window", + windowId: str(r.windowId) ?? "", + shared: r.shared === true, + tier: str(r.tier), + durationMs: num(r.durationMs), + resetsAt: num(r.resetsAt), + unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent", + used, + limit: num(r.limit), + remaining: num(r.remaining), + usedFraction: num(r.usedFraction), + remainingFraction: num(r.remainingFraction), + status, + }; +} + +function normalizeCredits(v: unknown): AccountUsageCredits | null { + if (!isRecord(v)) return null; + return { + hasCredits: bool(v.hasCredits), + unlimited: bool(v.unlimited), + overageLimitReached: bool(v.overageLimitReached), + balance: num(v.balance), + spendControlReached: bool(v.spendControlReached), + }; +} + +function normalizeDailyCredits(v: unknown): AccountUsageDailyCredits | null { + if (!isRecord(v)) return null; + const balance = num(v.balance); + const allowance = num(v.allowance); + if (balance === null || allowance === null) return null; + return { + balance, + allowance, + resetAnchor: num(v.resetAnchor), + resetSource: v.resetSource === "manual" || v.resetSource === "detected" ? v.resetSource : null, + }; +} + +const PROVIDER_LABELS: Record<AccountUsageProviderId, string> = { + claude: "Claude", + codex: "Codex", + hyper: "Hyper", +}; + +function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null { + const provider: AccountUsageProviderId | null = + r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null; + if (provider === null) return null; + const account = isRecord(r.account) + ? { + accountId: str(r.account.accountId), + email: str(r.account.email), + planType: str(r.account.planType), + } + : null; + const limits = Array.isArray(r.limits) + ? r.limits + .filter(isRecord) + .map(normalizeLimit) + .filter((l): l is AccountUsageLimit => l !== null) + : []; + return { + provider, + label: str(r.label) ?? PROVIDER_LABELS[provider], + state: STATES.find((s) => s === r.state) ?? "unavailable", + account, + limits, + limitReached: bool(r.limitReached), + canServe: bool(r.canServe), + creditOverage: r.creditOverage === true, + credits: normalizeCredits(r.credits), + dailyCredits: normalizeDailyCredits(r.dailyCredits), + fetchedAt: num(r.fetchedAt), + lastAttemptAt: num(r.lastAttemptAt), + nextRefreshAt: num(r.nextRefreshAt), + error: str(r.error), + }; +} + +/** Coerce one untyped report (e.g. the `PUT /usage/:provider/settings` body); null if malformed. */ +export function normalizeAccountUsageReport(data: unknown): AccountUsageReport | null { + return isRecord(data) ? normalizeReport(data) : null; +} + +/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */ +export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse { + if (!isRecord(data)) return { providers: [], servedAt: fallbackNow }; + const providers = Array.isArray(data.providers) + ? data.providers + .filter(isRecord) + .map(normalizeReport) + .filter((p): p is AccountUsageReport => p !== null) + : []; + return { providers, servedAt: num(data.servedAt) ?? fallbackNow }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** "2d 4h" / "3h 12m" / "45m" / "<1m". */ +export function formatDuration(ms: number): string { + if (ms < 60_000) return "<1m"; + const totalMin = Math.floor(ms / 60_000); + const d = Math.floor(totalMin / 1440); + const h = Math.floor((totalMin % 1440) / 60); + const m = totalMin % 60; + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** "updated 3m ago" style age, or "just now". */ +export function formatAge(fetchedAt: number | null, now: number): string | null { + if (fetchedAt === null) return null; + const ms = now - fetchedAt; + return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`; +} + +function formatUsd(v: number): string { + return `$${v.toFixed(2)}`; +} + +/** Whole credits as-is, fractional ones to 1 decimal ("150", "12.5"). */ +function formatCredits(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + +// ── Bars ────────────────────────────────────────────────────────────────────── + +export type BarTone = "success" | "warning" | "error" | "neutral"; + +export interface UsageBarView { + readonly id: string; + readonly label: string; + /** Fill 0..100 for the `<progress>` value. */ + readonly percent: number; + /** "42%" or "$12.50 / $50.00". */ + readonly valueLabel: string; + readonly tone: BarTone; + /** + * Pace marker position 0..100 (% of the window elapsed) — the usage level + * that would be "on track" to exactly use the allowance by the reset. Null + * when the window's start can't be determined (no duration/reset). + */ + readonly pacePercent: number | null; + /** "+12% ahead of pace" / "8% under pace" / null. */ + readonly paceLabel: string | null; + /** True when usage is ahead of the pace marker. */ + readonly aheadOfPace: boolean; + /** "resets in 3h 12m" / null. */ + readonly resetLabel: string | null; +} + +/** + * Fraction (0..1) of the window elapsed at `now`. The window started at + * `resetsAt - durationMs`; returns null when either is unknown. + */ +export function elapsedFraction( + durationMs: number | null, + resetsAt: number | null, + now: number, +): number | null { + if (durationMs === null || resetsAt === null || durationMs <= 0) return null; + const start = resetsAt - durationMs; + return Math.min(1, Math.max(0, (now - start) / durationMs)); +} + +function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone { + if (status === "exhausted") return "error"; + if (status === "warning") return "warning"; + if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success"; + return "neutral"; +} + +export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView { + const fraction = limit.usedFraction; + const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100)); + let valueLabel: string; + if (limit.unit === "usd") { + valueLabel = + limit.limit === null + ? `${formatUsd(limit.used)} (no cap)` + : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`; + } else if (limit.unit === "credits") { + valueLabel = + limit.limit === null + ? `${formatCredits(limit.used)} used` + : `${formatCredits(limit.used)} / ${formatCredits(limit.limit)}`; + } else { + valueLabel = `${Math.round(limit.used)}%`; + } + const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now); + const pacePercent = elapsed === null ? null : elapsed * 100; + let paceLabel: string | null = null; + let aheadOfPace = false; + if (pacePercent !== null && fraction !== null) { + const diff = Math.round(percent - pacePercent); + aheadOfPace = diff > 0; + paceLabel = + diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`; + } + const resetLabel = + limit.resetsAt === null + ? null + : limit.resetsAt <= now + ? "resetting" + : `resets in ${formatDuration(limit.resetsAt - now)}`; + return { + id: limit.id, + label: limit.label, + percent, + valueLabel, + tone: toneFor(limit.status, fraction), + pacePercent, + paceLabel, + aheadOfPace, + resetLabel, + }; +} + +// ── Provider cards ──────────────────────────────────────────────────────────── + +export interface ProviderCardView { + readonly provider: AccountUsageProviderId; + readonly label: string; + /** "Pro · [email protected]" / null. */ + readonly subtitle: string | null; + readonly state: AccountUsageState; + /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */ + readonly stateBadge: string | null; + /** Explanatory line for empty/non-fresh states, else null. */ + readonly message: string | null; + readonly bars: readonly UsageBarView[]; + /** "updated 3m ago" / null. */ + readonly ageLabel: string | null; + /** Credit-overage note (Codex), or null. */ + readonly creditNote: string | null; + /** Daily-credit details + editable settings (Hyper), or null. */ + readonly daily: DailyCreditsView | null; +} + +export interface DailyCreditsView { + /** "100 credits left" (+ " (incl. bundles)" when the balance exceeds the allowance). */ + readonly balanceLabel: string; + readonly allowance: number; + /** The reset anchor as a local `HH:MM` (for a time input), or "" when unknown. */ + readonly resetTime: string; + /** "set manually" / "detected from a refill (approx.)" / "unknown — set it to enable the pace marker". */ + readonly resetSourceLabel: string; + readonly resetSource: "manual" | "detected" | null; +} + +/** Local `HH:MM` of an epoch-ms instant (time-of-day only; resets recur daily). */ +export function toLocalTimeOfDay(epochMs: number): string { + const d = new Date(epochMs); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** + * Parse a local `HH:MM` into a reset anchor: the most recent occurrence of that + * local time at or before `now` (any occurrence works — resets recur daily). + * Null for malformed input. + */ +export function anchorFromTimeOfDay(hhmm: string, now: number): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + const d = new Date(now); + d.setHours(h, min, 0, 0); + if (d.getTime() > now) d.setDate(d.getDate() - 1); + return d.getTime(); +} + +/** Parse a positive-integer allowance input; null when invalid. */ +export function parseAllowanceInput(raw: string): number | null { + const t = raw.trim(); + if (!/^\d+$/.test(t)) return null; + const n = Number(t); + return n > 0 && n <= 1_000_000_000 ? n : null; +} + +function viewDailyCredits(d: AccountUsageDailyCredits): DailyCreditsView { + return { + balanceLabel: `${formatCredits(d.balance)} credits left${d.balance > d.allowance ? " (incl. bundles)" : ""}`, + allowance: d.allowance, + resetTime: d.resetAnchor === null ? "" : toLocalTimeOfDay(d.resetAnchor), + resetSourceLabel: + d.resetSource === "manual" + ? "set manually" + : d.resetSource === "detected" + ? "detected from a refill (approx.)" + : "unknown — set it to enable the pace marker", + resetSource: d.resetSource, + }; +} + +function stateMessage(r: AccountUsageReport): string | null { + switch (r.state) { + case "fresh": + return r.limits.length === 0 ? "No quota windows reported." : null; + case "stale": + return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`; + case "unavailable": + return `Usage unavailable${r.error ? `: ${r.error}` : "."}`; + case "unsupported": + return r.error ?? "This credential type can't report subscription usage."; + case "not-configured": + if (r.provider === "hyper") return "No Hyper API key configured (HYPER_API_KEY)."; + return r.provider === "claude" + ? "Not signed in to Claude (no OAuth credentials)." + : "Not signed in to Codex (no ChatGPT OAuth credentials)."; + } +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1); +} + +export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView { + const parts = [ + r.account?.planType ? capitalize(r.account.planType) : null, + r.account?.email ?? null, + ].filter((p): p is string => p !== null && p !== ""); + let creditNote: string | null = null; + if (r.creditOverage) { + creditNote = "Plan allowance used — requests are being funded by credits."; + } else if (r.limitReached === true && r.canServe === false) { + creditNote = "Plan limit reached — requests are blocked until reset."; + } + return { + provider: r.provider, + label: r.label, + subtitle: parts.length > 0 ? parts.join(" · ") : null, + state: r.state, + stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "), + message: stateMessage(r), + bars: r.limits.map((l) => viewUsageBar(l, now)), + ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null, + creditNote, + daily: r.dailyCredits ? viewDailyCredits(r.dailyCredits) : null, + }; +} + +export function viewProviderCards( + usage: AccountUsageResponse, + now: number, +): readonly ProviderCardView[] { + return usage.providers.map((p) => viewProviderCard(p, now)); +} 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> |
