+ {fresh.has(contract.id) && (
+
+ New
+
+ )}
{contract.priority && contract.deliveryWindowGameHours !== null && (
diff --git a/apps/web/test/fresh-board.test.tsx b/apps/web/test/fresh-board.test.tsx
new file mode 100644
index 0000000..054e3a7
--- /dev/null
+++ b/apps/web/test/fresh-board.test.tsx
@@ -0,0 +1,75 @@
+import type { Contract } from "@deadhead/schema";
+import { screen, within } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { fakeApi, renderApp, samplePlayerState } from "./render";
+
+const job = (id: string, payout: number): Contract => ({
+ id,
+ originId: "earth",
+ destinationId: "moon",
+ cargoTypeId: "standard",
+ goodId: "mail",
+ shipper: "Pacific Orbital Logistics",
+ priority: false,
+ deliveryWindowGameHours: null,
+ tier: 0,
+ massTonnes: 4,
+ payout,
+ requiredSkill: null,
+ requiredLongHaulLevel: 0,
+ offeredAt: Date.now(),
+ expiresAt: Date.now() + 600_000,
+ firstContact: false,
+});
+
+const owner = () => samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] });
+
+function render(boards: Contract[][], periodMs = 600_000) {
+ let call = 0;
+ renderApp(
+ fakeApi({
+ getPlayerState: owner,
+ getContractBoard: () => ({
+ contracts: boards[Math.min(call++, boards.length - 1)]!,
+ periodEndsAt: Date.now() + periodMs,
+ }),
+ }),
+ "/contracts?location=earth",
+ );
+}
+
+const remember = (ids: string[]) =>
+ localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
+const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
+
+describe("visible board refresh (CR-008)", () => {
+ it("tags contracts that weren't on the last board seen here, and says so", async () => {
+ remember(["old-1", "old-2"]);
+ render([[job("old-1", 111), job("new-1", 222)]]);
+ expect(await screen.findByRole("status")).toHaveTextContent("New contracts posted");
+ expect(within(await card("222 cr")).getByText("New")).toBeInTheDocument();
+ expect(within(await card("111 cr")).queryByText("New")).not.toBeInTheDocument();
+ });
+
+ it("tags nothing when the board is the one already seen", async () => {
+ remember(["old-1"]);
+ render([[job("old-1", 111)]]);
+ await card("111 cr");
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(screen.queryByText("New")).not.toBeInTheDocument();
+ });
+
+ it("tags nothing on a first visit, but remembers the board", async () => {
+ render([[job("a", 111)]]);
+ await card("111 cr");
+ expect(screen.queryByText("New")).not.toBeInTheDocument();
+ expect(JSON.parse(localStorage.getItem("deadhead:seen-board:earth")!)).toEqual(["a"]);
+ });
+
+ it("announces the new board when the timer rolls over while the page is open", async () => {
+ render([[job("a", 111)], [job("b", 222)]], 300);
+ await card("111 cr");
+ expect(await screen.findByRole("status")).toHaveTextContent("New contracts posted");
+ expect(within(await card("222 cr")).getByText("New")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts
index cf6a287..047c035 100644
--- a/apps/web/test/setup.ts
+++ b/apps/web/test/setup.ts
@@ -2,7 +2,11 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
-afterEach(() => cleanup());
+afterEach(() => {
+ cleanup();
+ // The board remembers what was seen per location (CR-008); start each test fresh.
+ if (typeof localStorage !== "undefined") localStorage.clear();
+});
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
configure({ asyncUtilTimeout: 3000 });
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 4209bcd..ce92bda 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -219,3 +219,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 /
Spec: `memory-bank/change-requests/CR-008-fresh-boards.md`. New decisions recorded as plan §0.5.60–0.5.62.
- [x] **Step 10H.1 — Content: pay variance and new goods.** Config `contractPayVariance` 0.15. Five Standard goods (decision 0.5.61): Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies Earth → Moon; Crew Personal Effects and Recycled Alloys Moon → Earth (locations' goods lists update from the catalog). Earth → Moon now has 6 Standard goods, Moon → Earth 4. All existing tests still pass (tutorial masses unchanged: goods on both routes still hold 5/12/20 t). Test: `goods.test.ts` (route variety, variance is a fraction).
- [x] **Step 10H.2 — Sim: per-contract pay rate.** `PayoutInput.marketRate` (default 1) multiplies the payout; `payRate(rng, content)` draws 1 ± `contractPayVariance` uniformly as the last draw of every board contract (fit and normal); `buildContract` takes `payRate` (tutorial boards pass none, so tutorial payouts are unchanged). Tests: `pay-rate.test.ts` (payout scales with the rate; 1,000 Earth boards: rates within ±15 %, mean within 1 ± 0.01; the same good, mass and route now pays different amounts); the tutorial and CR-007 class-mix tests still pass.
+- [x] **Step 10H.3 — Client: visible refresh.** `useNewContracts(locationId, contracts)` (`LocationBoards.tsx`) compares the board with the last one seen at that location (`localStorage` key `deadhead:seen-board:
`, read and written in try/catch), then remembers it. Contracts not seen before get a cyan "New" pill and rise in; the refresh line shows a `role="status"` "New contracts posted". Tags stay until the board changes again (contracts taken in between drop out); nothing is tagged on a first visit. The test setup clears `localStorage` after each test. Test: `fresh-board.test.tsx` (remembered older board → tags and notice; same board → none; first visit → none but remembered; rollover while open → notice and tags on the new board).