Step 9.10: add empty and error states
CI / check (push) Successful in 3m3s
Details
CI / check (push) Successful in 3m3s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ea0d023b97
commit
ff136377f4
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue