Step 9.10: add empty and error states
CI / check (push) Successful in 3m3s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 02:20:34 -04:00
parent ea0d023b97
commit ff136377f4
10 changed files with 191 additions and 20 deletions

View File

@ -447,3 +447,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Distance from Earth is live** (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home". - **Distance from Earth is live** (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home".
- **Landing requirement "vacuum" is shown as "Any ship"**, since every ship can land at a vacuum-rated location. - **Landing requirement "vacuum" is shown as "Any ship"**, since every ship can land at a vacuum-rated location.
- **Lease buttons are disabled when unaffordable** and absent where a bay exists or the location is undiscovered. - **Lease buttons are disabled when unaffordable** and absent where a bay exists or the location is undiscovered.
## Step 9.10
- **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
- **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one.
- **The placeholder screen is gone;** every route has a real screen.

View File

@ -12,12 +12,10 @@ import { HQ } from "./screens/HQ";
import { Locations } from "./screens/Locations"; import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market"; import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
import { Placeholder } from "./screens/Placeholder";
import { StartScreen } from "./screens/StartScreen"; import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { usePlayerState, useRefetchOnArrival } from "./state/player";
/** Built screens by path; the rest show a placeholder. */ const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
"/": <HQ />, "/": <HQ />,
"/contracts": <Contracts />, "/contracts": <Contracts />,
"/fleet": <Fleet />, "/fleet": <Fleet />,
@ -42,29 +40,54 @@ export function App() {
useServerClock(); useServerClock();
const state = usePlayerState(); const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch); useRefetchOnArrival(state.data ?? null, state.refetch);
const retry = () => void state.refetch();
if (state.isPending) { if (state.isPending) {
return <p className="p-6 text-slate-400">Loading…</p>; return <p className="p-6 text-slate-400">Loading…</p>;
} }
if (state.isError) { if (state.data === undefined) {
// The first load failed: nothing to show yet.
return ( return (
<p role="alert" className="p-6 text-red-400"> <div role="alert" className="space-y-3 p-6">
Could not reach the server. <p className="text-red-400">Could not reach the server.</p>
</p> <button
type="button"
onClick={retry}
disabled={state.isFetching}
className="rounded-lg bg-amber-500 px-4 py-2 font-semibold text-slate-950 disabled:opacity-60"
>
{state.isFetching ? "Retrying…" : "Retry"}
</button>
</div>
); );
} }
if (state.data === null) return <StartScreen />; if (state.data === null) return <StartScreen />;
// A later refresh failed: keep showing the last state, with a way to retry.
const banner = state.isError ? (
<div
role="alert"
className="flex items-center justify-between gap-2 bg-red-950 px-4 py-2 text-sm text-red-200"
>
<span>Offline — showing last update.</span>
<button type="button" onClick={retry} className="underline">
Retry
</button>
</div>
) : null;
return ( return (
<> <>
<Routes> <Routes>
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}> <Route
element={<Layout credits={formatCredits(state.data.company.credits)} banner={banner} />}
>
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<Route <Route
key={screen.path} key={screen.path}
index={screen.path === "/"} index={screen.path === "/"}
path={screen.path === "/" ? undefined : screen.path} path={screen.path === "/" ? undefined : screen.path}
element={SCREEN_COMPONENTS[screen.path] ?? <Placeholder title={screen.title} />} element={SCREEN_COMPONENTS[screen.path]}
/> />
))} ))}
</Route> </Route>

View File

@ -7,10 +7,12 @@ export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */ /** Shown at the top right. Credits arrive with the player state (Step 8.5). */
credits?: ReactNode; credits?: ReactNode;
clock?: ReactNode; clock?: ReactNode;
/** Shown under the header, e.g. the offline notice. */
banner?: ReactNode;
} }
/** Header with credits and game clock, the current screen, and a bottom tab bar. */ /** Header with credits and game clock, the current screen, and a bottom tab bar. */
export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps) { export function Layout({ credits = "—", clock = <GameClock />, banner }: LayoutProps) {
return ( return (
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100"> <div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2"> <header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
@ -22,6 +24,7 @@ export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps)
</span> </span>
</div> </div>
</header> </header>
{banner}
<main className="flex-1 overflow-x-hidden pb-20"> <main className="flex-1 overflow-x-hidden pb-20">
<Outlet /> <Outlet />

View File

@ -45,6 +45,10 @@ export function HQ() {
)} )}
</div> </div>
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
)}
{state.tutorial && ( {state.tutorial && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3"> <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
<h2 className="font-semibold text-amber-300"> <h2 className="font-semibold text-amber-300">

View File

@ -20,6 +20,11 @@ export function Pilots() {
<section className="space-y-3 p-4"> <section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Pilots</h1> <h1 className="text-xl font-semibold">Pilots</h1>
<HirePilot /> <HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-slate-400">
No hired pilots yet. Hire one at a bay to crew another ship.
</p>
)}
<ul className="space-y-2"> <ul className="space-y-2">
{state.pilots.map((pilot) => ( {state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} /> <PilotCard key={pilot.id} pilot={pilot} />

View File

@ -1,9 +0,0 @@
/** A screen that has not been built yet (Phase 9 fills these in). */
export function Placeholder({ title }: { title: string }) {
return (
<section className="p-4">
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
</section>
);
}

View File

@ -49,6 +49,9 @@ export function TutorialBoard() {
{errorMessage(fly.error)} {errorMessage(fly.error)}
</p> </p>
)} )}
{board.data.contracts.length === 0 && (
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
)}
<ul className="space-y-2"> <ul className="space-y-2">
{board.data.contracts.map((contract) => ( {board.data.contracts.map((contract) => (
<li key={contract.id} className="rounded-lg border border-slate-800 p-3"> <li key={contract.id} className="rounded-lg border border-slate-800 p-3">

View File

@ -0,0 +1,132 @@
import { focusManager } from "@tanstack/react-query";
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { SCREENS } from "../src/routes";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const networkDown = () => {
throw new TypeError("Failed to fetch");
};
const afterTutorial = samplePlayerState({ tutorial: null });
describe("network errors", () => {
afterEach(() => focusManager.setFocused(undefined));
it.each(SCREENS)(
"$title: a failed first load offers Retry, which recovers",
async ({ path, title }) => {
let online = false;
renderApp(
fakeApi({
getPlayerState: () => (online ? afterTutorial : networkDown()),
getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }),
}),
path,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Could not reach the server.");
online = true;
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
},
);
it.each(SCREENS)(
"$title: a failed refresh keeps the screen and shows an offline banner",
async ({ path, title }) => {
let online = true;
renderApp(
fakeApi({
getPlayerState: () => (online ? afterTutorial : networkDown()),
getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }),
}),
path,
);
await screen.findByRole("heading", { level: 1, name: title });
online = false;
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
const banner = await screen.findByText("Offline — showing last update.");
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
online = true;
await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" }));
await act(async () => {});
expect(screen.queryByText("Offline — showing last update.")).not.toBeInTheDocument();
},
);
it("Contracts: a failed board load offers Retry", async () => {
let online = false;
renderApp(
fakeApi({
getPlayerState: () => ({ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] }),
getContractBoard: () => (online ? { contracts: [], periodEndsAt: 0 } : networkDown()),
}),
"/contracts",
);
const alert = await screen.findByText("Could not load contracts.", { exact: false });
online = true;
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument();
});
});
describe("empty states", () => {
const show = (path: string, state = afterTutorial, extra = {}) =>
renderApp(fakeApi({ getPlayerState: () => state, ...extra }), path);
it("HQ: no ships points to the Market", async () => {
show("/");
expect(await screen.findByText("No ships yet — buy one in the Market.")).toBeInTheDocument();
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
});
it("Fleet: no ships links to the Market", async () => {
show("/fleet");
expect(await screen.findByRole("link", { name: "visit the Market" })).toHaveAttribute(
"href",
"/market",
);
});
it("Contracts: no bays explains how to get contracts", async () => {
show("/contracts");
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
});
it("Contracts: an empty bay board says to check back", async () => {
show(
"/contracts",
{ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] },
{ getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }) },
);
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument();
});
it("Contracts: an empty tutorial board says to check back", async () => {
show("/contracts", samplePlayerState(), {
getTutorialBoard: () => ({ contracts: [], periodEndsAt: 0 }),
});
expect(await screen.findByText(/No company runs left this period/)).toBeInTheDocument();
});
it("Pilots: no hired pilots suggests hiring", async () => {
show("/pilots");
expect(await screen.findByText(/No hired pilots yet/)).toBeInTheDocument();
expect(screen.getByText("Lease a bay to hire pilots there.")).toBeInTheDocument();
});
it("Market and Locations always have content for a new player", async () => {
show("/market");
expect(await screen.findByRole("button", { name: "Buy Courier" })).toBeInTheDocument();
show("/locations");
expect((await screen.findAllByText("Earth")).length).toBeGreaterThan(0);
});
});

View File

@ -418,5 +418,9 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/App.tsx` | Clock sync; loading / start screen / routed layout | | `src/App.tsx` | Clock sync; loading / start screen / routed layout |
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
| `src/routes.ts` | Screen list shared by router and navigation | | `src/routes.ts` | Screen list shared by router and navigation |
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself. Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.

View File

@ -94,4 +94,4 @@
- [x] **Step 9.7 — Pilots screen.** `src/screens/Pilots.tsx`: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point. - [x] **Step 9.7 — Pilots screen.** `src/screens/Pilots.tsx`: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point.
- [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh. - [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
- [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock." - [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
- [ ] Step 9.10 — Empty and error states - [x] **Step 9.10 — Empty and error states.** First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen.