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/ui | |
| 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/ui')
3 files changed, 247 insertions, 4 deletions
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> |
