From 8853a2df4c8b255d8c6638858f525ed5a0387f5f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 14 Oct 2026 17:29:03 +0900 Subject: feat(account-usage): Hyper daily credits card with allowance + reset-time editor --- src/features/account-usage/index.ts | 18 ++- src/features/account-usage/logic/types.ts | 15 +++ .../account-usage/logic/view-model.test.ts | 79 +++++++++++++ src/features/account-usage/logic/view-model.ts | 116 +++++++++++++++++-- .../account-usage/ui/AccountUsageView.svelte | 30 ++++- .../account-usage/ui/AccountUsageView.test.ts | 95 +++++++++++++++- .../account-usage/ui/DailyCreditsSettings.svelte | 126 +++++++++++++++++++++ 7 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 src/features/account-usage/ui/DailyCreditsSettings.svelte (limited to 'src/features') 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; + +/** 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; 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 { 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): 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 = { + claude: "Claude", + codex: "Codex", + hyper: "Hyper", +}; + function normalizeReport(r: Record): 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): 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): 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): 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 @@ + +
+
+ {daily.balanceLabel} + {#if !editing} + + {/if} +
+ {#if !editing} + + Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel}) + + {:else} +
{ + e.preventDefault(); + void submit(); + }} + > + + + Leave the time empty to use the auto-detected refill time. + {#if error}{error}{/if} +
+ + +
+
+ {/if} +
-- cgit v1.2.3