From de5f5fc6e24300f66c3e2055f60ca1a6c8de9662 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:50:59 +0200 Subject: [PATCH] feat(miner-ui): adopt StateBoundary + skeletons on the Overview route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview route's three summary cards each rendered their loading/error surface with a hand-rolled Fallback helper -- a flat gray "Loading …" sentence and an inline error line, re-rendered every 10s poll. This adopts the shared @loopover/ui-kit primitives the main app's routes already use: each card wraps its Stats in with a content-shaped placeholder that mirrors the card's Stat rows, so the layout no longer shifts when data arrives and the loading state reads as a live shimmer instead of static text. Error surfaces now use StateBoundary's shared alert; each card still degrades independently, and errors auto-recover on the next successful poll (no manual retry wired, matching the existing poll-driven UX). Behavior/data unchanged: same three read-only sources, same Stats, same oldest-queued CLI-parity minutes (#6185). Updates the loading test to assert the per-card role=status skeleton and strengthens the error test to assert the shared StateBoundary copy. Closes #6509 --- apps/loopover-miner-ui/src/app.test.tsx | 16 ++- apps/loopover-miner-ui/src/routes/index.tsx | 126 ++++++++++++-------- 2 files changed, 87 insertions(+), 55 deletions(-) diff --git a/apps/loopover-miner-ui/src/app.test.tsx b/apps/loopover-miner-ui/src/app.test.tsx index 0c1069b334..81a2ee804b 100644 --- a/apps/loopover-miner-ui/src/app.test.tsx +++ b/apps/loopover-miner-ui/src/app.test.tsx @@ -53,14 +53,17 @@ describe("OverviewView (#4853)", () => { expect(statValue("Oldest queued")).toBe("90m"); }); - it("shows an independent loading message per card before data arrives", () => { + it("shows an independent content-shaped loading skeleton per card before data arrives (#6509)", () => { render(); - expect(screen.getByText(/Loading run state/i)).toBeTruthy(); - expect(screen.getByText(/Loading the portfolio queue/i)).toBeTruthy(); - expect(screen.getByText(/Loading the claim ledger/i)).toBeTruthy(); + // Each card's loading surface is now a role="status" Skeleton placeholder, not a flat gray sentence. + expect(screen.getByRole("status", { name: /loading run activity/i })).toBeTruthy(); + expect(screen.getByRole("status", { name: /loading the portfolio queue/i })).toBeTruthy(); + expect(screen.getByRole("status", { name: /loading the claim ledger/i })).toBeTruthy(); + // The pre-#6509 hand-rolled "Loading …" text is gone. + expect(screen.queryByText(/Loading run state/i)).toBeNull(); }); - it("degrades each card independently: an errored source shows an alert while the others still render", () => { + it("degrades each card independently: an errored source shows a StateBoundary alert while the others still render", () => { render( { />, ); expect(screen.getAllByRole("alert")).toHaveLength(2); // runs + claims errored + // The shared StateBoundary error surface, with this route's per-card copy (#6509). + expect(screen.getByText(/Couldn't read run state/i)).toBeTruthy(); + expect(screen.getByText(/Couldn't read the claim ledger/i)).toBeTruthy(); expect(statValue("Total items")).toBe("5"); // portfolio still renders }); }); diff --git a/apps/loopover-miner-ui/src/routes/index.tsx b/apps/loopover-miner-ui/src/routes/index.tsx index 7821daa92a..9ff6b16689 100644 --- a/apps/loopover-miner-ui/src/routes/index.tsx +++ b/apps/loopover-miner-ui/src/routes/index.tsx @@ -1,6 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; +import { Skeleton } from "@loopover/ui-kit/components/skeleton"; +import { StateBoundary } from "@loopover/ui-kit/components/state-views"; import { fetchLedgers, type LedgersResult } from "../lib/ledgers"; import { fetchPortfolioQueue, type PortfolioQueueResult } from "../lib/portfolio-queue"; @@ -14,7 +16,13 @@ export const Route = createFileRoute("/")({ // Overview dashboard (#4853): replaces the Phase-6 placeholder with a live, at-a-glance summary of real miner // state — run activity, portfolio queue, and claims — aggregated from the same local read-only APIs the dedicated // views use (run-state, portfolio-queue, ledgers). Each card degrades independently: it shows its own loading or -// error message without taking the others down. Live-refreshed on the shared poll cadence (#4856). +// error surface without taking the others down. Live-refreshed on the shared poll cadence (#4856). +// +// #6509: the per-card loading/error surface is the shared `StateBoundary` + content-shaped `Skeleton` from +// @loopover/ui-kit (the same primitives the main app's routes already use), replacing this route's own +// hand-rolled "Loading …" / "Could not read …" text. The skeleton mirrors each card's Stat rows so the layout +// doesn't shift once data arrives, and — unlike a flat gray sentence re-rendered every 10s poll — reads as a +// live placeholder. Errors auto-recover on the next successful poll, so no manual retry action is wired. /** One metric line inside a summary card. */ function Stat({ label, value, tone }: { label: string; value: string | number; tone?: string }) { @@ -37,33 +45,43 @@ function SummaryCard({ title, children }: { title: string; children: React.React ); } -function Fallback({ state, subject }: { state: "loading" | "error"; subject: string }) { - return state === "loading" ? ( -

Loading {subject}…

- ) : ( -

- Could not read {subject}. -

+/** Content-shaped loading placeholder for a summary card: `rows` shimmer lines mirroring the `Stat` label/value + * layout, so the card keeps its height and the content doesn't jump once the poll resolves. `role="status"` + * keeps the loading state announced to assistive tech (the flat "Loading …" text it replaces was announced too). */ +function CardStatsSkeleton({ rows, label }: { rows: number; label: string }) { + return ( +
+ {Array.from({ length: rows }).map((_, index) => ( +
+ + +
+ ))} +
); } export function OverviewRunsCard({ runs }: { runs: RunHistoryResult | null }) { return ( - {runs === null ? ( - - ) : !runs.ok ? ( - - ) : ( - <> - - row.state !== "idle").length} - tone="text-[var(--success)]" - /> - - )} + } + errorTitle="Couldn't read run state" + errorDescription="The local run-state API didn't respond. This refreshes automatically." + > + {runs?.ok && ( + <> + + row.state !== "idle").length} + tone="text-[var(--success)]" + /> + + )} + ); } @@ -71,24 +89,28 @@ export function OverviewRunsCard({ runs }: { runs: RunHistoryResult | null }) { export function OverviewPortfolioCard({ portfolio }: { portfolio: PortfolioQueueResult | null }) { return ( - {portfolio === null ? ( - - ) : !portfolio.ok ? ( - - ) : ( - <> - - - - - {/* Deliver the CLI/web-UI parity the portfolio-queue data path promises (#6185): the CLI's `queue - dashboard` renders "oldest-queued: Xm" (portfolio-dashboard.js), and the same minutes-rounded age - is shown here. Omitted (like the CLI) when the queue is empty and the age is null. */} - {portfolio.summary.oldestQueuedAgeMs !== null && ( - - )} - - )} + } + errorTitle="Couldn't read the portfolio queue" + errorDescription="The local portfolio-queue API didn't respond. This refreshes automatically." + > + {portfolio?.ok && ( + <> + + + + + {/* Deliver the CLI/web-UI parity the portfolio-queue data path promises (#6185): the CLI's `queue + dashboard` renders "oldest-queued: Xm" (portfolio-dashboard.js), and the same minutes-rounded age + is shown here. Omitted (like the CLI) when the queue is empty and the age is null. */} + {portfolio.summary.oldestQueuedAgeMs !== null && ( + + )} + + )} + ); } @@ -96,16 +118,20 @@ export function OverviewPortfolioCard({ portfolio }: { portfolio: PortfolioQueue export function OverviewClaimsCard({ claims }: { claims: LedgersResult | null }) { return ( - {claims === null ? ( - - ) : !claims.ok ? ( - - ) : ( - <> - - - - )} + } + errorTitle="Couldn't read the claim ledger" + errorDescription="The local ledgers API didn't respond. This refreshes automatically." + > + {claims?.ok && ( + <> + + + + )} + ); }