diff --git a/DECISIONS.md b/DECISIONS.md index 4a6917d..45dc514 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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". - **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. + +## 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. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9db703d..b82dc9e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,12 +12,10 @@ import { HQ } from "./screens/HQ"; import { Locations } from "./screens/Locations"; import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; -import { Placeholder } from "./screens/Placeholder"; import { StartScreen } from "./screens/StartScreen"; import { usePlayerState, useRefetchOnArrival } from "./state/player"; -/** Built screens by path; the rest show a placeholder. */ -const SCREEN_COMPONENTS: Partial> = { +const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { "/": , "/contracts": , "/fleet": , @@ -42,29 +40,54 @@ export function App() { useServerClock(); const state = usePlayerState(); useRefetchOnArrival(state.data ?? null, state.refetch); + const retry = () => void state.refetch(); if (state.isPending) { return

Loading…

; } - if (state.isError) { + if (state.data === undefined) { + // The first load failed: nothing to show yet. return ( -

- Could not reach the server. -

+
+

Could not reach the server.

+ +
); } if (state.data === null) return ; + // A later refresh failed: keep showing the last state, with a way to retry. + const banner = state.isError ? ( +
+ Offline — showing last update. + +
+ ) : null; + return ( <> - }> + } + > {SCREENS.map((screen) => ( } + element={SCREEN_COMPONENTS[screen.path]} /> ))} diff --git a/apps/web/src/layout/Layout.tsx b/apps/web/src/layout/Layout.tsx index 7a82582..a21e1cc 100644 --- a/apps/web/src/layout/Layout.tsx +++ b/apps/web/src/layout/Layout.tsx @@ -7,10 +7,12 @@ export interface LayoutProps { /** Shown at the top right. Credits arrive with the player state (Step 8.5). */ credits?: 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. */ -export function Layout({ credits = "—", clock = }: LayoutProps) { +export function Layout({ credits = "—", clock = , banner }: LayoutProps) { return (
@@ -22,6 +24,7 @@ export function Layout({ credits = "—", clock = }: LayoutProps)
+ {banner}
diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx index 6677262..bb740b0 100644 --- a/apps/web/src/screens/HQ.tsx +++ b/apps/web/src/screens/HQ.tsx @@ -45,6 +45,10 @@ export function HQ() { )} + {!state.tutorial && state.ships.length === 0 && ( +

No ships yet — buy one in the Market.

+ )} + {state.tutorial && (

diff --git a/apps/web/src/screens/Pilots.tsx b/apps/web/src/screens/Pilots.tsx index d63cd20..65d3fe3 100644 --- a/apps/web/src/screens/Pilots.tsx +++ b/apps/web/src/screens/Pilots.tsx @@ -20,6 +20,11 @@ export function Pilots() {

Pilots

+ {!state.pilots.some((p) => !p.isAvatar) && ( +

+ No hired pilots yet. Hire one at a bay to crew another ship. +

+ )}
    {state.pilots.map((pilot) => ( diff --git a/apps/web/src/screens/Placeholder.tsx b/apps/web/src/screens/Placeholder.tsx deleted file mode 100644 index d6a3e87..0000000 --- a/apps/web/src/screens/Placeholder.tsx +++ /dev/null @@ -1,9 +0,0 @@ -/** A screen that has not been built yet (Phase 9 fills these in). */ -export function Placeholder({ title }: { title: string }) { - return ( -
    -

    {title}

    -

    Coming soon.

    -
    - ); -} diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index c8bac66..05d2515 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -49,6 +49,9 @@ export function TutorialBoard() { {errorMessage(fly.error)}

    )} + {board.data.contracts.length === 0 && ( +

    No company runs left this period. Check back soon.

    + )}
      {board.data.contracts.map((contract) => (
    • diff --git a/apps/web/test/states.test.tsx b/apps/web/test/states.test.tsx new file mode 100644 index 0000000..3271ceb --- /dev/null +++ b/apps/web/test/states.test.tsx @@ -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); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 1070e94..69a4c0f 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c30d272..dece1c3 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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." -- [ ] 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.