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 ( +
+ + + + + + + Data may be stale. Last fetched:{" "} + {formatted} + +
+ ); +} + +function LoadingState() { + return ( +
+
+

Loading dashboard...

+
+ ); +} + +// Round data is only reachable in the `ready` branch: narrowing on `status` +// means the loading, empty and error branches have no payload to pass along. +export function DashboardContent({ + state, + onRetry, + transition, +}: { + state: DashboardState; + onRetry: () => void; + transition?: Transition; +}) { + switch (state.status) { + case "loading": + return ; + case "error": + return ; + case "empty": + return ; + case "ready": { + const { data, stale } = state; + return ( + <> + {stale && } + +
+ + +
+
+ + +
+
+ + ); + } + } +} diff --git a/apps/web/src/components/dashboard/DashboardEmptyState.tsx b/apps/web/src/components/dashboard/DashboardEmptyState.tsx index 5f04c61..1794f9e 100644 --- a/apps/web/src/components/dashboard/DashboardEmptyState.tsx +++ b/apps/web/src/components/dashboard/DashboardEmptyState.tsx @@ -1,4 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors +// Rendered only when the load succeeds and reports no round. It takes no props, +// so there is no round or bid data it could display. export function DashboardEmptyState() { return (
@@ -22,12 +24,11 @@ export function DashboardEmptyState() {

No Round Data Available

- 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:

  • No rounds have been created yet
  • -
  • The configured endpoint returned no data
  • -
  • The data source is temporarily unavailable
  • +
  • The configured endpoint returned no round

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 (

@@ -27,7 +34,9 @@ export function DashboardErrorState({

Failed to Load Dashboard

-

{error}

+

+ The dashboard could not load round data. Error code: {safeCode} +

diff --git a/apps/web/src/components/dashboard/DashboardStates.test.tsx b/apps/web/src/components/dashboard/DashboardStates.test.tsx new file mode 100644 index 0000000..0fb41b5 --- /dev/null +++ b/apps/web/src/components/dashboard/DashboardStates.test.tsx @@ -0,0 +1,399 @@ +// Copyright (c) 2026 Sub Rosa contributors +// The dashboard empty and error states must never show a sealed bid. These +// tests drive the real hook and the real state components with an injected +// fetch and fake time -- no wallet, RPC or contract client is involved. +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { createFakeTime } from "@sub-rosa/time"; +import { StatusApiError, StatusJsonParseError, type SdkErrorCode } from "@sub-rosa/sdk"; + +import { DASHBOARD_FIXTURE } from "../../dashboard/fixture"; +import type { DashboardData } from "../../dashboard/types"; +import { + loadDashboardData, + useDashboardData, + type UseDashboardDataResult, +} from "../../hooks/useDashboardData"; +import { TimeProvider } from "../../lib/time"; +import { DashboardContent } from "./DashboardContent"; +import { DashboardEmptyState } from "./DashboardEmptyState"; +import { DashboardErrorState } from "./DashboardErrorState"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const ENDPOINT = "https://dashboard.invalid/round"; +const POLL_INTERVAL_MS = 30_000; +const NOW_ISO = "2026-06-15T12:00:00.000Z"; +const NOW_MS = Date.parse(NOW_ISO); + +const SEALED_BIDDER = "GSEALEDBIDDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ"; +const SEALED_LABEL = "agent-sealed"; +const SEALED_BLOB = "c0ffee5ea1edb10bc0ffee5ea1edb10b"; + +// A round the contract still treats as sealed: commit phase, nothing revealed. +// The extra `ciphertext` field stands in for sealed blob bytes in the payload. +const SEALED_FIXTURE: DashboardData = { + ...DASHBOARD_FIXTURE, + meta: { ...DASHBOARD_FIXTURE.meta, fetchedAt: NOW_ISO }, + round: { ...DASHBOARD_FIXTURE.round, status: "Open", winner: null, winningBid: null }, + keeper: { + currentPhase: "awaiting-drand", + nextAction: "wait for Drand", + lastActionAt: null, + actionHistory: [], + }, + bidders: [ + { + address: SEALED_BIDDER, + label: SEALED_LABEL, + committed: true, + revealed: false, + valid: null, + settled: false, + escrowUsdc: 9045, + bidUsdc: 7310.25, + ciphertext: SEALED_BLOB, + } as DashboardData["bidders"][number], + ], + settlement: null, +}; + +// What the round cards display for this bid: amount, escrow and bidder. +const DISPLAYED_MARKERS: RegExp[] = [ + /7[,.\s]?310/, + /9[,.\s]?045/, + /GSEALE/, + new RegExp(SEALED_BIDDER.slice(-6)), + new RegExp(SEALED_LABEL), + /USDC/, +]; +// Plus the sealed blob bytes, which no dashboard state should ever show. +const SEALED_MARKERS: RegExp[] = [...DISPLAYED_MARKERS, /c0ffee/i]; + +function assertNoSealedData(rendered: string, context: string) { + for (const marker of SEALED_MARKERS) { + assert.doesNotMatch(rendered, marker, `${context}: must not render ${marker}`); + } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +/** A 200 response whose body delivers part of the sealed payload, then dies. */ +function interruptedResponse(): Response { + const partial = JSON.stringify(SEALED_FIXTURE).slice(0, -40); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(partial)); + controller.error(new Error("connection reset")); + }, + }), + { status: 200 }, + ); +} + +/** A complete, parseable payload that carries the sealed bid but is missing a section. */ +function incompleteSealedPayload(): unknown { + const { keeper: _keeper, ...rest } = SEALED_FIXTURE; + return rest; +} + +type Responder = () => Promise; + +async function mount(initial: Responder) { + const time = createFakeTime(NOW_MS); + let responder = initial; + let calls = 0; + const fetchImpl = (async () => { + calls++; + return responder(); + }) as typeof fetch; + + let result!: UseDashboardDataResult; + let renderer!: ReactTestRenderer; + function Harness() { + result = useDashboardData({ endpoint: ENDPOINT, fetchImpl }); + return ; + } + await act(async () => { + renderer = create( + + + , + ); + }); + + return { + // Methods rather than getters, so an assertion on one read does not + // narrow the type of the next. + result: () => result, + status: () => result.status, + errorCode: () => (result.status === "error" ? result.code : null), + get calls() { + return calls; + }, + /** The full rendered tree, including every prop and attribute. */ + rendered: () => JSON.stringify(renderer.toJSON()), + respondWith: (next: Responder) => { + responder = next; + }, + poll: () => act(async () => time.scheduler.advance(POLL_INTERVAL_MS)), + // Starts a load without waiting for it, so a test can hold it in flight. + refetch: () => + act(async () => { + result.refetch(); + }), + unmount: () => act(async () => renderer.unmount()), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +} + +test("the sealed fixture markers are detectable when the round is rendered", async () => { + const h = await mount(async () => jsonResponse(SEALED_FIXTURE)); + try { + assert.equal(h.status(), "ready"); + for (const marker of DISPLAYED_MARKERS) { + assert.match(h.rendered(), marker, `ready state should render ${marker}`); + } + } finally { + await h.unmount(); + } +}); + +test("empty state component renders no amount and accepts no round", () => { + const html = renderToStaticMarkup(); + assert.match(html, /No Round Data Available/); + assertNoSealedData(html, "empty state"); + const text = html.replace(/<[^>]*>/g, " "); + assert.doesNotMatch(text, /\d/, "empty state copy contains no figures at all"); +}); + +test("error state component renders the SDK error code and nothing else from the load", () => { + const html = renderToStaticMarkup( + {}} />, + ); + assert.match(html, /Failed to Load Dashboard/); + assert.match(html, /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"}