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:
parent
bf19f498b8
commit
e924defb79
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue