diff options
| author | Adam Malczewski <[email protected]> | 2026-10-14 17:29:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-14 17:29:03 +0900 |
| commit | 8853a2df4c8b255d8c6638858f525ed5a0387f5f (patch) | |
| tree | 68c71afe1ec59a58fd1fe2b30c486a31c33358f5 /src/features/account-usage | |
| parent | d928bc86c35f590eb9113148bccf3c4ae56e8fa6 (diff) | |
| download | dispatch-web-8853a2df4c8b255d8c6638858f525ed5a0387f5f.tar.gz dispatch-web-8853a2df4c8b255d8c6638858f525ed5a0387f5f.zip | |
feat(account-usage): Hyper daily credits card with allowance + reset-time editor
Diffstat (limited to 'src/features/account-usage')
| -rw-r--r-- | src/features/account-usage/index.ts | 18 | ||||
| -rw-r--r-- | src/features/account-usage/logic/types.ts | 15 | ||||
| -rw-r--r-- | src/features/account-usage/logic/view-model.test.ts | 79 | ||||
| -rw-r--r-- | src/features/account-usage/logic/view-model.ts | 116 | ||||
| -rw-r--r-- | src/features/account-usage/ui/AccountUsageView.svelte | 30 | ||||
| -rw-r--r-- | src/features/account-usage/ui/AccountUsageView.test.ts | 95 | ||||
| -rw-r--r-- | src/features/account-usage/ui/DailyCreditsSettings.svelte | 126 |
7 files changed, 465 insertions, 14 deletions
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> |
