From e924defb793277e09a234791fb6926de9320f535 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 18:06:02 -0400 Subject: [PATCH] Step 10H.3: announce new contracts and tag them New Co-Authored-By: Claude Opus 5.5 --- apps/web/src/screens/LocationBoards.tsx | 60 +++++++++++++++++++- apps/web/test/fresh-board.test.tsx | 75 +++++++++++++++++++++++++ apps/web/test/setup.ts | 6 +- memory-bank/progress.md | 1 + 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 apps/web/test/fresh-board.test.tsx diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index a67d00e..b0b1041 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -1,6 +1,6 @@ import type { Contract, PlayerState, Ship } from "@deadhead/schema"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useSearchParams } from "react-router"; import { useApi } from "../api/context"; import { serverNow, useClock } from "../clock/clock"; @@ -17,6 +17,48 @@ import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/ export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; +const seenKey = (locationId: string) => `deadhead:seen-board:${locationId}`; + +/** Contract ids on the last board the player saw here, or null if never (decision 0.5.62). */ +function readSeen(locationId: string): string[] | null { + try { + const raw = localStorage.getItem(seenKey(locationId)); + return raw ? (JSON.parse(raw) as string[]) : null; + } catch { + return null; + } +} + +function writeSeen(locationId: string, ids: string[]): void { + try { + localStorage.setItem(seenKey(locationId), JSON.stringify(ids)); + } catch { + // Storage blocked: the board works, it just can't tag new contracts. + } +} + +/** + * Contracts that weren't on the last board the player saw at this location + * (decision 0.5.62): none on a first visit. Tags stay until the board changes + * again, minus contracts taken in the meantime. + */ +export function useNewContracts(locationId: string, contracts: Contract[] | undefined) { + const [fresh, setFresh] = useState>(new Set()); + const ids = contracts?.map((c) => c.id) ?? null; + const idsKey = ids?.join(",") ?? null; + useEffect(() => { + if (ids === null) return; + const seen = readSeen(locationId); + const added = seen === null ? [] : ids.filter((id) => !seen.includes(id)); + setFresh((previous) => + added.length > 0 ? new Set(added) : new Set([...previous].filter((id) => ids.includes(id))), + ); + writeSeen(locationId, ids); + // `idsKey` stands for `ids` (a new array every render). + }, [locationId, idsKey]); + return fresh; +} + /** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */ export function requirementsText(contract: Contract): string | null { const parts = [ @@ -125,6 +167,7 @@ function LocationBoard({ return endsAt === undefined ? false : Math.max(1000, endsAt - serverNow() + 500); }, }); + const fresh = useNewContracts(locationId, board.data?.contracts); if (board.isPending) return

Loading contracts…

; if (board.isError) { @@ -147,6 +190,14 @@ function LocationBoard({ + {fresh.size > 0 && ( + + New contracts posted + + )}

); const stuck = ; @@ -176,7 +227,7 @@ function LocationBoard({ key={contract.id} tone={contract.firstContact ? "amber" : "default"} feature={contract.firstContact} - className="flex flex-col gap-2" + className={`flex flex-col gap-2 ${fresh.has(contract.id) ? "animate-rise" : ""}`} >
@@ -191,6 +242,11 @@ function LocationBoard({ {goodName(contract)}
+ {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).