diff --git a/apps/web/package.json b/apps/web/package.json index d7267f4..fc9a9bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/components/dashboard/DashboardStates.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx", "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts src/passkey-session.test.ts && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/apps/web/src/components/dashboard/DashboardContent.tsx b/apps/web/src/components/dashboard/DashboardContent.tsx new file mode 100644 index 0000000..2fe1584 --- /dev/null +++ b/apps/web/src/components/dashboard/DashboardContent.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { motion, type Transition } from "framer-motion"; +import type { DashboardState } from "../../hooks/useDashboardData"; +import { BidderProgressCard } from "./BidderProgressCard"; +import { DashboardEmptyState } from "./DashboardEmptyState"; +import { DashboardErrorState } from "./DashboardErrorState"; +import { KeeperStatusCard } from "./KeeperStatusCard"; +import { RoundStatusCard } from "./RoundStatusCard"; +import { SettlementCard } from "./SettlementCard"; + +function StaleBanner({ fetchedAt }: { fetchedAt: string }) { + const formatted = new Intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "short", + }).format(Date.parse(fetchedAt)); + return ( +
Loading dashboard...
+- The dashboard could not find any round data to display. This might mean: + The dashboard loaded successfully but there is no round to display. This might mean:
If using a custom endpoint, verify VITE_DASHBOARD_ENDPOINT is set correctly.
diff --git a/apps/web/src/components/dashboard/DashboardErrorState.tsx b/apps/web/src/components/dashboard/DashboardErrorState.tsx
index 90a780d..cb27263 100644
--- a/apps/web/src/components/dashboard/DashboardErrorState.tsx
+++ b/apps/web/src/components/dashboard/DashboardErrorState.tsx
@@ -1,11 +1,18 @@
// Copyright (c) 2026 Sub Rosa contributors
+import { SDK_ERROR_CODES, type SdkErrorCode } from "@sub-rosa/sdk";
+
+// The only input is an SDK error code. This component deliberately accepts no
+// message, round, or bid object, so a failed load has nothing sealed to render.
export function DashboardErrorState({
- error,
+ code,
onRetry,
}: {
- error: string;
+ code: SdkErrorCode;
onRetry: () => void;
}) {
+ // Re-check against the closed set so an untyped caller cannot pass free text.
+ const safeCode: SdkErrorCode = SDK_ERROR_CODES.includes(code) ? code : "UNKNOWN";
+
return (
{error}
+
+ The dashboard could not load round data. Error code: {safeCode}
+
STATUS_API_ERROR<\/code>/);
+ assertNoSealedData(html, "error state");
+});
+
+test("error state component refuses free text passed as a code", () => {
+ const smuggled = `bid 7310.25 USDC from ${SEALED_BIDDER}` as unknown as SdkErrorCode;
+ const html = renderToStaticMarkup( {}} />);
+ assert.match(html, /UNKNOWN<\/code>/);
+ assertNoSealedData(html, "error state with untyped code");
+});
+
+test("loadDashboardData resolves null only for an explicit no-round response", async () => {
+ assert.equal(await loadDashboardData(ENDPOINT, async () => jsonResponse(null)), null);
+ assert.equal(
+ await loadDashboardData(ENDPOINT, async () => jsonResponse({ ...SEALED_FIXTURE, round: null })),
+ null,
+ );
+ assert.deepEqual(
+ await loadDashboardData(ENDPOINT, async () => jsonResponse(SEALED_FIXTURE)),
+ JSON.parse(JSON.stringify(SEALED_FIXTURE)),
+ );
+});
+
+test("loadDashboardData throws SDK errors and never returns a partial round", async () => {
+ await assert.rejects(
+ loadDashboardData(ENDPOINT, async () => jsonResponse(SEALED_FIXTURE, 503)),
+ StatusApiError,
+ );
+ await assert.rejects(
+ loadDashboardData(ENDPOINT, async () => interruptedResponse()),
+ StatusJsonParseError,
+ );
+ await assert.rejects(
+ loadDashboardData(ENDPOINT, async () => jsonResponse(incompleteSealedPayload())),
+ StatusJsonParseError,
+ );
+ // A payload with no `round` key at all is malformed, not "no round".
+ await assert.rejects(
+ loadDashboardData(ENDPOINT, async () => jsonResponse({ bidders: SEALED_FIXTURE.bidders })),
+ StatusJsonParseError,
+ );
+});
+
+test("an empty fixture shows the empty state and no amount", async () => {
+ const h = await mount(async () => jsonResponse(null));
+ try {
+ assert.equal(h.status(), "empty");
+ assert.equal("data" in h.result(), false);
+ assert.match(h.rendered(), /No Round Data Available/);
+ assert.doesNotMatch(h.rendered(), /Failed to Load Dashboard/);
+ assertNoSealedData(h.rendered(), "empty state");
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a no-round response carrying leftover bidders still shows only the empty state", async () => {
+ const h = await mount(async () => jsonResponse({ ...SEALED_FIXTURE, round: null }));
+ try {
+ assert.equal(h.status(), "empty");
+ assert.equal("data" in h.result(), false);
+ assertNoSealedData(h.rendered(), "empty state with leftover bidders");
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a failed fixture shows the SDK error code and no bid amount", async () => {
+ const h = await mount(async () => jsonResponse(SEALED_FIXTURE, 503));
+ try {
+ assert.equal(h.errorCode(), "STATUS_API_ERROR");
+ assert.equal("data" in h.result(), false);
+ assert.match(h.rendered(), /Failed to Load Dashboard/);
+ assert.match(h.rendered(), /STATUS_API_ERROR/);
+ assert.doesNotMatch(h.rendered(), /No Round Data Available/);
+ assertNoSealedData(h.rendered(), "error state");
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a rejected fetch shows the UNKNOWN code, not the rejection text", async () => {
+ const h = await mount(async () => {
+ throw new TypeError(`fetch failed for bid 7310.25 USDC by ${SEALED_BIDDER}`);
+ });
+ try {
+ assert.equal(h.errorCode(), "UNKNOWN");
+ assert.match(h.rendered(), /UNKNOWN/);
+ assertNoSealedData(h.rendered(), "error state after rejected fetch");
+ } finally {
+ await h.unmount();
+ }
+});
+
+for (const [name, respond] of [
+ ["the connection drops mid-body", async () => interruptedResponse()],
+ ["the payload arrives incomplete", async () => jsonResponse(incompleteSealedPayload())],
+] as Array<[string, Responder]>) {
+ test(`a sealed fixture that fails mid-load does not show the fixture amount (${name})`, async () => {
+ const h = await mount(respond);
+ try {
+ assert.equal(h.errorCode(), "STATUS_INVALID_RESPONSE");
+ assert.equal("data" in h.result(), false);
+ assert.match(h.rendered(), /STATUS_INVALID_RESPONSE/);
+ assertNoSealedData(h.rendered(), "error state on first load");
+ } finally {
+ await h.unmount();
+ }
+ });
+
+ test(`a loaded sealed round is dropped when a later load fails (${name})`, async () => {
+ const h = await mount(async () => jsonResponse(SEALED_FIXTURE));
+ try {
+ assert.equal(h.status(), "ready");
+
+ h.respondWith(respond);
+ await h.poll();
+
+ assert.equal(h.status(), "error");
+ assert.equal("data" in h.result(), false, "the earlier round is not kept beside the error");
+ assert.match(h.rendered(), /STATUS_INVALID_RESPONSE/);
+ assertNoSealedData(h.rendered(), "error state after a loaded round");
+ } finally {
+ await h.unmount();
+ }
+ });
+}
+
+test("retrying from the error state does not bring the earlier round back while loading", async () => {
+ const h = await mount(async () => jsonResponse(SEALED_FIXTURE));
+ try {
+ h.respondWith(async () => jsonResponse(null, 500));
+ await h.poll();
+ assert.equal(h.status(), "error");
+
+ const slow = deferred();
+ h.respondWith(() => slow.promise);
+ await h.refetch();
+
+ assert.equal(h.status(), "loading");
+ assert.equal(h.result().refreshing, true);
+ assertNoSealedData(h.rendered(), "loading state after an error");
+
+ await act(async () => slow.resolve(jsonResponse(null, 500)));
+ assert.equal(h.status(), "error");
+ assertNoSealedData(h.rendered(), "error state after a failed retry");
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a later successful load replaces the error state", async () => {
+ const h = await mount(async () => jsonResponse(null, 503));
+ try {
+ assert.equal(h.status(), "error");
+
+ h.respondWith(async () => jsonResponse(DASHBOARD_FIXTURE));
+ await h.poll();
+
+ assert.equal(h.status(), "ready");
+ assert.equal("code" in h.result(), false, "the error code is cleared");
+ assert.equal(h.result().refreshing, false);
+ assert.doesNotMatch(h.rendered(), /Failed to Load Dashboard/);
+ assert.doesNotMatch(h.rendered(), /STATUS_API_ERROR/);
+ assert.match(h.rendered(), /agent-alpha/);
+
+ h.respondWith(async () => jsonResponse(null));
+ await h.poll();
+ assert.equal(h.status(), "empty");
+ assert.doesNotMatch(h.rendered(), /agent-alpha/);
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a slow failure cannot overwrite a newer successful load", async () => {
+ const slowFailure = deferred();
+ const h = await mount(() => slowFailure.promise);
+ try {
+ assert.equal(h.status(), "loading");
+
+ h.respondWith(async () => jsonResponse(DASHBOARD_FIXTURE));
+ await h.refetch();
+ assert.equal(h.status(), "ready");
+
+ await act(async () => slowFailure.resolve(jsonResponse(null, 500)));
+ assert.equal(h.status(), "ready");
+ assert.equal(h.result().refreshing, false);
+ assert.doesNotMatch(h.rendered(), /Failed to Load Dashboard/);
+ } finally {
+ await h.unmount();
+ }
+});
+
+test("a slow sealed response cannot resurface over a newer failure", async () => {
+ const slowSealed = deferred();
+ const h = await mount(() => slowSealed.promise);
+ try {
+ h.respondWith(async () => jsonResponse(null, 500));
+ await h.refetch();
+ assert.equal(h.status(), "error");
+
+ await act(async () => slowSealed.resolve(jsonResponse(SEALED_FIXTURE)));
+ assert.equal(h.status(), "error");
+ assert.equal("data" in h.result(), false);
+ assertNoSealedData(h.rendered(), "error state after a superseded sealed response");
+ assert.equal(h.calls, 2);
+ } finally {
+ await h.unmount();
+ }
+});
diff --git a/apps/web/src/components/dashboard/index.ts b/apps/web/src/components/dashboard/index.ts
index 64309f2..20c1f3d 100644
--- a/apps/web/src/components/dashboard/index.ts
+++ b/apps/web/src/components/dashboard/index.ts
@@ -6,3 +6,4 @@ export { BidderProgressCard } from "./BidderProgressCard";
export { SettlementCard } from "./SettlementCard";
export { DashboardEmptyState } from "./DashboardEmptyState";
export { DashboardErrorState } from "./DashboardErrorState";
+export { DashboardContent } from "./DashboardContent";
diff --git a/apps/web/src/hooks/useDashboardData.ts b/apps/web/src/hooks/useDashboardData.ts
index 0809e74..b06e0f7 100644
--- a/apps/web/src/hooks/useDashboardData.ts
+++ b/apps/web/src/hooks/useDashboardData.ts
@@ -1,5 +1,11 @@
-import { publicErrorMessage } from "@sub-rosa/logging/errors";
// Copyright (c) 2026 Sub Rosa contributors
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ sdkErrorCode,
+ StatusApiError,
+ StatusJsonParseError,
+ type SdkErrorCode,
+} from "@sub-rosa/sdk";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { DashboardData } from "../dashboard/types";
import type { DashboardSnapshot } from "@sub-rosa/sdk";
@@ -12,6 +18,21 @@ import { useDrandCountdown } from "./useDrandCountdown";
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const LIVE_POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
+/**
+ * What the dashboard may render. Only `ready` carries round data: the
+ * `loading`, `empty` and `error` variants have no field that could hold a bid
+ * amount, a bidder identity or sealed blob bytes, so those states cannot
+ * display a bid the contract still treats as sealed.
+ */
+export type DashboardState =
+ | { status: "loading" }
+ | { status: "empty" }
+ | { status: "error"; code: SdkErrorCode }
+ | { status: "ready"; data: DashboardData; stale: boolean };
+
+export type UseDashboardDataResult = DashboardState & {
+ /** A load is in flight. A `ready` round stays on screen while it reloads. */
+ refreshing: boolean;
export interface UseDashboardDataResult {
data: DashboardData | null;
snapshot: DashboardSnapshot | null;
@@ -19,6 +40,12 @@ export interface UseDashboardDataResult {
error: string | null;
stale: boolean;
refetch: () => void;
+};
+
+export interface UseDashboardDataOptions {
+ /** Defaults to `VITE_DASHBOARD_ENDPOINT`; blank selects the bundled fixture. */
+ endpoint?: string;
+ fetchImpl?: typeof fetch;
}
/**
@@ -45,11 +72,60 @@ export function isStale(fetchedAt: string | null | undefined, nowMs: number): bo
return nowMs - fetchedTime > STALE_THRESHOLD_MS;
}
-export function useDashboardData(): UseDashboardDataResult {
- const { clock, scheduler } = useTime();
- const endpoint = import.meta.env.VITE_DASHBOARD_ENDPOINT as string | undefined;
- const useFixture = !endpoint?.trim();
+/** True when a successful response explicitly reports that there is no round. */
+function reportsNoRound(json: unknown): boolean {
+ if (json === null) return true;
+ return typeof json === "object" && "round" in json && json.round === null;
+}
+
+/**
+ * Load the dashboard payload. Resolves to `null` only when the endpoint
+ * answers successfully with no round. Every failure -- including a payload
+ * that arrives incomplete -- throws, so a partial round is never returned.
+ */
+export async function loadDashboardData(
+ endpoint: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const response = await fetchImpl(endpoint);
+ if (!response.ok) {
+ throw new StatusApiError(response.status, { error: `HTTP ${response.status}` });
+ }
+ let json: unknown;
+ try {
+ json = await response.json();
+ } catch (cause) {
+ throw new StatusJsonParseError(response.status, { cause });
+ }
+
+ if (reportsNoRound(json)) {
+ return null;
+ }
+
+ try {
+ assertDashboardData(json);
+ } catch (cause) {
+ throw new StatusJsonParseError(response.status, { cause });
+ }
+ return json;
+}
+
+export function useDashboardData(
+ options: UseDashboardDataOptions = {},
+): UseDashboardDataResult {
+ const { clock, scheduler } = useTime();
+ const endpoint = (
+ options.endpoint ?? (import.meta.env.VITE_DASHBOARD_ENDPOINT as string | undefined)
+ )?.trim();
+ const fetchImpl = options.fetchImpl;
+ const useFixture = !endpoint;
+
+ const [state, setState] = useState({ status: "loading" });
+ const [refreshing, setRefreshing] = useState(true);
+ // Only the most recently started load may publish, so an older response can
+ // never overwrite (or resurrect data over) the result of a newer one.
+ const latestLoad = useRef(0);
const [state, setState] = useState>(() => ({
data: null,
loading: true,
@@ -58,44 +134,37 @@ export function useDashboardData(): UseDashboardDataResult {
}));
const fetchData = useCallback(async () => {
+ const load = ++latestLoad.current;
+
if (useFixture) {
- setState((s) => ({
- ...s,
+ setState({
+ status: "ready",
data: DASHBOARD_FIXTURE,
- loading: false,
- error: null,
stale: isStale(DASHBOARD_FIXTURE.meta.fetchedAt, clock.nowMs()),
- }));
+ });
+ setRefreshing(false);
return;
}
- setState((s) => ({ ...s, loading: true, error: null }));
+ setRefreshing(true);
+ setState((s) => (s.status === "ready" ? s : { status: "loading" }));
+ // Each outcome is built from scratch: nothing from the previous state is
+ // merged in, so a failure drops any round that was loaded before it.
+ let next: DashboardState;
try {
- const response = await fetch(endpoint!);
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
-
- const json: unknown = await response.json();
- assertDashboardData(json);
-
- setState((s) => ({
- ...s,
- data: json,
- loading: false,
- error: null,
- stale: isStale(json.meta.fetchedAt, clock.nowMs()),
- }));
+ const data = await loadDashboardData(endpoint, fetchImpl);
+ next = data
+ ? { status: "ready", data, stale: isStale(data.meta.fetchedAt, clock.nowMs()) }
+ : { status: "empty" };
} catch (e) {
- const message = publicErrorMessage(e);
- setState((s) => ({
- ...s,
- loading: false,
- error: `Failed to fetch dashboard data: ${message}`,
- }));
+ next = { status: "error", code: sdkErrorCode(e) };
}
- }, [endpoint, useFixture, clock]);
+
+ if (load !== latestLoad.current) return;
+ setState(next);
+ setRefreshing(false);
+ }, [endpoint, fetchImpl, useFixture, clock]);
useEffect(() => {
let cancelled = false;
@@ -114,6 +183,7 @@ export function useDashboardData(): UseDashboardDataResult {
return () => {
cancelled = true;
+ latestLoad.current++;
if (intervalHandle !== undefined) {
scheduler.clear(intervalHandle);
}
@@ -121,12 +191,13 @@ export function useDashboardData(): UseDashboardDataResult {
}, [fetchData, useFixture, scheduler]);
// Update stale status periodically
+ const hasData = state.status === "ready";
useEffect(() => {
- if (!state.data) return;
+ if (!hasData) return;
const checkStale = () => {
setState((s) => {
- if (!s.data) return s;
+ if (s.status !== "ready") return s;
const nowStale = isStale(s.data.meta.fetchedAt, clock.nowMs());
return nowStale !== s.stale ? { ...s, stale: nowStale } : s;
});
@@ -134,7 +205,7 @@ export function useDashboardData(): UseDashboardDataResult {
const handle = scheduler.setInterval(checkStale, 60_000);
return () => scheduler.clear(handle);
- }, [state.data, clock, scheduler]);
+ }, [hasData, clock, scheduler]);
// Build the shared snapshot from the drand countdown for the current round's
// reveal round. useDrandCountdown returns a stable object that updates when
@@ -154,6 +225,7 @@ export function useDashboardData(): UseDashboardDataResult {
return {
...state,
+ refreshing,
snapshot,
refetch: fetchData,
};
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx
index 9aab42a..9c6dd46 100644
--- a/apps/web/src/pages/DashboardPage.tsx
+++ b/apps/web/src/pages/DashboardPage.tsx
@@ -2,55 +2,12 @@
import { motion, useReducedMotion } from "framer-motion";
import { useDashboardData } from "../hooks/useDashboardData";
import { LOGO_SRC } from "../lib/chain";
-import {
- RoundStatusCard,
- KeeperStatusCard,
- BidderProgressCard,
- SettlementCard,
- DashboardEmptyState,
- DashboardErrorState,
-} from "../components/dashboard";
-
-function StaleBanner({ fetchedAt }: { fetchedAt: string }) {
- const formatted = new Intl.DateTimeFormat(undefined, {
- dateStyle: "short",
- timeStyle: "short",
- }).format(Date.parse(fetchedAt));
- return (
-
-
-
- Data may be stale. Last fetched:{" "}
- {formatted}
-
-
- );
-}
-
-function LoadingState() {
- return (
-
-
- Loading dashboard...
-
- );
-}
+import { DashboardContent } from "../components/dashboard";
export function DashboardPage({ goHome }: { goHome: () => void }) {
const reduce = useReducedMotion();
+ const dashboard = useDashboardData();
+ const { refreshing, refetch } = dashboard;
const { data, loading, error, stale, refetch, snapshot } = useDashboardData();
const transition = reduce
@@ -74,9 +31,9 @@ export function DashboardPage({ goHome }: { goHome: () => void }) {
type="button"
className="secondary-action compact"
onClick={refetch}
- disabled={loading}
+ disabled={refreshing}
>
- {loading ? "Refreshing..." : "Refresh"}
+ {refreshing ? "Refreshing..." : "Refresh"}