Step 10H.3: announce new contracts and tag them New

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 18:06:02 -04:00
parent bf19f498b8
commit e924defb79
4 changed files with 139 additions and 3 deletions

View File

@ -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<ReadonlySet<string>>(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 <p className="text-ink-400">Loading contracts…</p>;
if (board.isError) {
@ -147,6 +190,14 @@ function LocationBoard({
<span className="text-cyan-200">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
{fresh.size > 0 && (
<span
role="status"
className="ml-2 animate-rise rounded-full border border-cyan-400/50 bg-cyan-500/15 px-2 py-0.5 tracking-wider text-cyan-100 normal-case"
>
New contracts posted
</span>
)}
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
@ -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" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
@ -191,6 +242,11 @@ function LocationBoard({
{goodName(contract)}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />

View File

@ -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();
});
});

View File

@ -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 });

View File

@ -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:<id>`, 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).