summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md50
-rw-r--r--backend-handoff.md3
-rw-r--r--src/app/store.svelte.ts47
-rw-r--r--src/app/views/AccountUsagePane.svelte12
-rw-r--r--src/features/account-usage/index.ts18
-rw-r--r--src/features/account-usage/logic/types.ts15
-rw-r--r--src/features/account-usage/logic/view-model.test.ts79
-rw-r--r--src/features/account-usage/logic/view-model.ts116
-rw-r--r--src/features/account-usage/ui/AccountUsageView.svelte30
-rw-r--r--src/features/account-usage/ui/AccountUsageView.test.ts95
-rw-r--r--src/features/account-usage/ui/DailyCreditsSettings.svelte126
11 files changed, 565 insertions, 26 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 043c95b..95f968e 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,8 +5,8 @@
> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for
> READING only.
>
-> **2026-10-14 delta (account-usage — ADDITIVE, `0.25.0`):** `GET /usage` + `GET /usage/:provider` and the
-> `AccountUsage*` types (verbatim at the end of this file).
+> **2026-10-14 delta (account-usage — ADDITIVE, `0.25.0` → `0.26.0`):** `GET /usage` + `GET /usage/:provider` +
+> `PUT /usage/:provider/settings` (Hyper daily credits) and the `AccountUsage*` types (verbatim at the end of this file).
>
> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever
> it changes.
@@ -1257,7 +1257,7 @@ ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOV
`UMANS_API_KEY`; an unconfigured backend is listed but `configured: false` — the UI disables it,
it is never an error.
-## Account usage (`[email protected]`, verbatim)
+## Account usage (`[email protected]`, verbatim)
```ts
// ─── Account usage (Claude / Codex subscription quotas) ───────────────────────
@@ -1267,8 +1267,12 @@ ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOV
// cached server-side. Distinct from per-turn token `Usage`: these are the
// account's plan allowance windows, never estimated from token counts.
-/** The subscription accounts the usage service reports on. */
-export type AccountUsageProviderId = "claude" | "codex";
+/**
+ * The subscription accounts the usage service reports on. `hyper` is a daily
+ * Hypercredit allowance (Hyper's API reports only the balance — the allowance
+ * and reset time are user settings, see `AccountUsageDailyCredits`).
+ */
+export type AccountUsageProviderId = "claude" | "codex" | "hyper";
/**
* Freshness/availability of one provider's report:
@@ -1311,7 +1315,8 @@ export interface AccountUsageLimit {
readonly durationMs: number | null;
/** Epoch-ms when the window resets (null when unknown). */
readonly resetsAt: number | null;
- readonly unit: "percent" | "usd";
+ /** `credits` = Hypercredits (`limit` = the daily allowance). */
+ readonly unit: "percent" | "usd" | "credits";
readonly used: number;
readonly limit: number | null;
readonly remaining: number | null;
@@ -1353,6 +1358,8 @@ export interface AccountUsageReport {
/** True when Codex credits fund requests past an exhausted plan window. */
readonly creditOverage: boolean;
readonly credits: AccountUsageCredits | null;
+ /** Daily credit allowance details (Hyper only; null for other providers). */
+ readonly dailyCredits: AccountUsageDailyCredits | null;
/** Epoch-ms of the report's data (last successful fetch), null if never. */
readonly fetchedAt: number | null;
/** Epoch-ms of the last refresh attempt (success or failure), null if never. */
@@ -1363,7 +1370,36 @@ export interface AccountUsageReport {
readonly error: string | null;
}
-/** Response of `GET /usage` — one report per provider, always both, in fixed order (claude, codex). */
+/**
+ * Daily credit allowance (Hyper). Hyper's `GET /v1/credits` returns only the
+ * balance, so `allowance` (default 250) and the reset time are settings:
+ * - `resetAnchor`: an epoch-ms instant of ANY past or future refresh; resets
+ * recur every 24h from it (`resetsAt` on the limit is the next one).
+ * - `resetSource`: `manual` (user-set), `detected` (inferred from an observed
+ * balance refill — approximate, ± the poll interval), or null (unknown ⇒ no
+ * `resetsAt`, so no pace marker).
+ */
+export interface AccountUsageDailyCredits {
+ /** Current balance as reported (may exceed `allowance` with purchased bundles). */
+ readonly balance: number;
+ readonly allowance: number;
+ readonly resetAnchor: number | null;
+ readonly resetSource: "manual" | "detected" | null;
+}
+
+/**
+ * Body of `PUT /usage/:provider/settings` (only `hyper` has settings). Omitted
+ * fields are unchanged. `dailyAllowance` must be a positive integer;
+ * `resetAnchor` is an epoch-ms refresh instant, or `null` to clear the manual
+ * time (falling back to a detected one, if any). Responds with the provider's
+ * refreshed `AccountUsageReport`; 400 invalid body, 404 provider without settings.
+ */
+export interface UpdateAccountUsageSettingsRequest {
+ readonly dailyAllowance?: number;
+ readonly resetAnchor?: number | null;
+}
+
+/** Response of `GET /usage` — one report per provider, in fixed order (claude, codex, hyper). */
export interface AccountUsageResponse {
readonly providers: readonly AccountUsageReport[];
/** Server clock (epoch-ms) when this response was built — use it to compute window progress. */
diff --git a/backend-handoff.md b/backend-handoff.md
index 6fff8e1..9516e9d 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -33,6 +33,9 @@ repos of one worktree like archive-workspaces).
window's elapsed fraction (`(now − (resetsAt − durationMs)) / durationMs`) — the usage level
that is "on track" to use the allowance exactly by the reset. Spend meters (no window) show no
marker. Polls every 60 s (backend cache hit); Refresh button sends `?refresh=1`.
+- Hyper (`[email protected]`): a third card with a `hyper:daily` credits bar (default 250/day)
+ and an inline editor (allowance + local reset time → `PUT /usage/hyper/settings`). Hyper's API only
+ reports the balance, so the reset time is manual or auto-detected from an observed refill.
- Open asks: none. Backend-side, inference responses from the claude/codex providers also update
the cached windows between polls (kernel `providerResponseHeaders` event) — no FE change needed.
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 7433f81..0a9cbd1 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -56,8 +56,13 @@ import {
subscribe as protocolSubscribe,
unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
-import type { AccountUsageResult } from "../features/account-usage";
-import { normalizeAccountUsage } from "../features/account-usage";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore, migrateLegacyModelName } from "../features/chat";
import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
@@ -544,6 +549,15 @@ export interface AppStore {
*/
accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
/**
+ * Update a provider's usage settings (`PUT /usage/:provider/settings`; today
+ * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns
+ * the provider's refreshed report.
+ */
+ setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult>;
+ /**
* Load the GLOBAL search-backend selection
* (`GET /search/config`): which engine backs the `web_search` tool's search
* mode. Not workspace-scoped — the selection is global. `unavailable: true`
@@ -2613,6 +2627,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(settings),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`,
+ };
+ }
+ const report = normalizeAccountUsageReport(await res.json());
+ return report
+ ? { ok: true, report }
+ : { ok: false, error: "Malformed usage settings response" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Save usage settings request failed",
+ };
+ }
+ },
+
async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
try {
const res = await fetchImpl(`${httpBase}/concurrency/status`);
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte
index 4315d4c..09afb5f 100644
--- a/src/app/views/AccountUsagePane.svelte
+++ b/src/app/views/AccountUsagePane.svelte
@@ -1,16 +1,22 @@
<script lang="ts">
/**
- * The `account-usage` view — Claude + Codex subscription usage bars with
+ * The `account-usage` view — Claude + Codex + Hyper usage bars with
* on-track pace markers. GLOBAL (not workspace/conversation scoped), so it
* stays mounted across tab switches (no {#key}).
*/
- import { AccountUsageView, type LoadAccountUsage } from "../../features/account-usage";
+ import {
+ AccountUsageView,
+ type LoadAccountUsage,
+ type SaveAccountUsageSettings,
+ } from "../../features/account-usage";
import type { AppViewPorts } from "../view-ports";
let { ports }: { ports: AppViewPorts } = $props();
const store = $derived(ports.store);
const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts);
+ const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) =>
+ store.setAccountUsageSettings(provider, settings);
</script>
-<AccountUsageView load={loadAccountUsage} />
+<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} />
diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts
index 73f2592..76204dc 100644
--- a/src/features/account-usage/index.ts
+++ b/src/features/account-usage/index.ts
@@ -1,27 +1,41 @@
export type {
+ AccountUsageDailyCredits,
AccountUsageLimit,
AccountUsageProviderId,
AccountUsageReport,
AccountUsageResponse,
AccountUsageResult,
+ AccountUsageSettingsResult,
AccountUsageState,
LoadAccountUsage,
+ SaveAccountUsageSettings,
+ UpdateAccountUsageSettingsRequest,
} from "./logic/types";
-export type { BarTone, ProviderCardView, UsageBarView } from "./logic/view-model";
+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 subscription usage bars with on-track pace markers",
+ 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
index 5bb4d00..ff92590 100644
--- a/src/features/account-usage/logic/types.ts
+++ b/src/features/account-usage/logic/types.ts
@@ -1,9 +1,11 @@
import type {
+ AccountUsageDailyCredits,
AccountUsageLimit,
AccountUsageProviderId,
AccountUsageReport,
AccountUsageResponse,
AccountUsageState,
+ UpdateAccountUsageSettingsRequest,
} from "@dispatch/transport-contract";
/**
@@ -15,11 +17,13 @@ import type {
* 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). */
@@ -31,3 +35,14 @@ export type AccountUsageResult =
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
index 943d047..78390cf 100644
--- a/src/features/account-usage/logic/view-model.test.ts
+++ b/src/features/account-usage/logic/view-model.test.ts
@@ -1,9 +1,12 @@
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";
@@ -44,6 +47,7 @@ function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport {
canServe: null,
creditOverage: false,
credits: null,
+ dailyCredits: null,
fetchedAt: NOW - 3 * 60_000,
lastAttemptAt: NOW - 3 * 60_000,
nextRefreshAt: NOW + 2 * 60_000,
@@ -196,3 +200,78 @@ describe("formatDuration", () => {
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
index d6f3053..17d37a6 100644
--- a/src/features/account-usage/logic/view-model.ts
+++ b/src/features/account-usage/logic/view-model.ts
@@ -1,5 +1,6 @@
import type {
AccountUsageCredits,
+ AccountUsageDailyCredits,
AccountUsageLimit,
AccountUsageLimitStatus,
AccountUsageProviderId,
@@ -54,7 +55,7 @@ function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null {
tier: str(r.tier),
durationMs: num(r.durationMs),
resetsAt: num(r.resetsAt),
- unit: r.unit === "usd" ? "usd" : "percent",
+ unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent",
used,
limit: num(r.limit),
remaining: num(r.remaining),
@@ -75,9 +76,28 @@ function normalizeCredits(v: unknown): AccountUsageCredits | null {
};
}
+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 : null;
+ r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null;
if (provider === null) return null;
const account = isRecord(r.account)
? {
@@ -94,7 +114,7 @@ function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null
: [];
return {
provider,
- label: str(r.label) ?? (provider === "claude" ? "Claude" : "Codex"),
+ label: str(r.label) ?? PROVIDER_LABELS[provider],
state: STATES.find((s) => s === r.state) ?? "unavailable",
account,
limits,
@@ -102,6 +122,7 @@ function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null
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),
@@ -109,6 +130,11 @@ function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null
};
}
+/** 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 };
@@ -146,6 +172,11 @@ 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";
@@ -196,12 +227,20 @@ function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null):
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));
- const valueLabel =
- limit.unit === "usd"
- ? limit.limit === null
+ let valueLabel: string;
+ if (limit.unit === "usd") {
+ valueLabel =
+ limit.limit === null
? `${formatUsd(limit.used)} (no cap)`
- : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`
- : `${Math.round(limit.used)}%`;
+ : `${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;
@@ -248,6 +287,65 @@ export interface ProviderCardView {
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 {
@@ -261,6 +359,7 @@ function stateMessage(r: AccountUsageReport): string | null {
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).";
@@ -292,6 +391,7 @@ export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCa
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,
};
}
diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte
index c1af2a7..2a7a589 100644
--- a/src/features/account-usage/ui/AccountUsageView.svelte
+++ b/src/features/account-usage/ui/AccountUsageView.svelte
@@ -1,15 +1,24 @@
<script lang="ts">
import { untrack } from "svelte";
- import type { AccountUsageResponse, LoadAccountUsage } from "../logic/types";
+ 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). */
@@ -43,6 +52,16 @@
}
}
+ /** 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));
@@ -115,6 +134,15 @@
{#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}
diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts
index e7e5799..ec1e51b 100644
--- a/src/features/account-usage/ui/AccountUsageView.test.ts
+++ b/src/features/account-usage/ui/AccountUsageView.test.ts
@@ -2,10 +2,16 @@ 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 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 = {
@@ -39,6 +45,7 @@ const usage: AccountUsageResponse = {
canServe: null,
creditOverage: false,
credits: null,
+ dailyCredits: null,
fetchedAt: NOW,
lastAttemptAt: NOW,
nextRefreshAt: NOW + 300_000,
@@ -54,6 +61,7 @@ const usage: AccountUsageResponse = {
canServe: null,
creditOverage: false,
credits: null,
+ dailyCredits: null,
fetchedAt: null,
lastAttemptAt: NOW,
nextRefreshAt: NOW + 30_000,
@@ -69,7 +77,7 @@ describe("AccountUsageView", () => {
calls.push(opts);
return { ok: true, usage };
};
- render(AccountUsageView, { load, now: () => NOW });
+ 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);
@@ -89,7 +97,88 @@ describe("AccountUsageView", () => {
error: "x",
unavailable: true,
});
- render(AccountUsageView, { load, now: () => NOW });
+ 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>