Spec 020 — Bank materials & wallet pages
Status: implemented Branch: 020-materials-and-wallet
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Spec 018 built the enriched reads — /account/materials, /account/wallet, /commerce/prices — but explicitly deferred "any web UI": no page consumes them. A connected player still sees only their account name (016). The whole point of surfacing holdings is for a player to look at them, so this spec builds the two pages that render those reads as in-game-faithful views: the Material Storage grid and the Wallet.
It also folds in the small server-side enrichment 018 deferred, because faithful rendering needs it: material-category names (018 left category as a raw id), a per-material Trading Post value (list price), and currency ordering. These are additive fields on two existing responses — flat, enriched rows the pages group and format. No new gameplay logic, no reconciliation against a plan.
HTTP endpoints (contract-first)
This spec is mostly web, but it extends two existing spec-018 responses so the pages have the data to render (Approach A, agreed in brainstorming — flat enriched rows; grouping and formatting stay web-side, so the arrays remain reusable for later features). Both stay Zod-first → openapi.json → regenerated web client, and verify:contract covers them.
GET /api/account/materials (authenticated) — extended
Each row gains categoryName, categoryOrder, and sellPrice:
[{
"id": 12134, "count": 250, "category": 5,
"categoryName": "Cooking Materials", "categoryOrder": 29,
"name": "Pile of Flax Seeds", "icon": "https://render.guildwars2.com/…",
"rarity": "Basic", "sellPrice": 32
}]categoryName/categoryOrderare joined from/v2/materials(the material-category list —{ id, name, order, items[] }, immutable → hard-cached like items), keyed by the row'scategoryid.sellPriceis the unit lowest sell listing in copper — thesells.unit_priceof/v2/commerce/pricesfor that item id — ornullwhen the item is account-bound / not listed (absent from/v2/commerce/prices). It is a unit price; the count multiply happens web-side.- Price is enriched on egress from the volatile 60 s price cache, never baked into the 5-minute per-user cache body (
gw2-api.md: cache the raw per-user body, enrich from the shorter-lived caches on egress — so a cache hit still reflects near-current prices).count: 0slots are still returned. 400 / 401 / 403 / 502error contract is unchanged from 018.
GET /api/account/wallet (authenticated) — extended
Each row gains order (the currency's display order, from /v2/currencies — 018 stripped it):
[{ "id": 1, "value": 184320, "name": "Coin", "icon": "https://render.guildwars2.com/…", "order": 101 }]orderis enriched from the static currency cache alongsidename/icon. Error contract unchanged.
GET /api/commerce/prices (018 P3) is reused internally by the materials enrichment and is not changed by this spec.
User stories
Ordered by priority. Each story is independently testable and shippable — if only P1 ships, there is still something usable.
P1 — See my material storage like in-game
As a player, I want a page that shows my banked materials laid out in the game's named category sections — icons with counts, empty slots dimmed — so that I recognize my storage at a glance.
Independent test: with a stored key, /materials renders one section per material category, labeled with the category name and ordered by categoryOrder; each held material shows its icon and count, count: 0 slots render dimmed with no count, and each icon carries its rarity color. With no stored key the page renders the shared "connect your account" prompt and never calls the data hook. Materials and Wallet appear in the top nav.
Acceptance scenarios
- Given a stored key whose materials span several categories, when the Materials page renders, then it shows one titled
<section>per category, ordered bycategoryOrder, each holding the category's material icons with their counts. - Given a material stack with
count: 0, when the page renders, then that slot appears dimmed with no count badge (it is not hidden). - Given any material, when its slot renders, then the icon carries that item's rarity color and the item name is available (title/alt), sourced from the rarity design tokens.
- Given no stored API key, when the Materials page is opened, then it renders the shared
ConnectAccountPrompt(message + link to/) and does not issue a materials request. - Given either new page exists, when the app shell renders, then the top nav shows Materials and Wallet links that route to them.
P2 — See what my materials are worth
As a player, I want each material's Trading Post value, a per-category subtotal, and a page grand total, so that I know what my banked mats are worth without opening the TP.
Independent test: with a stored key, each tradable material shows count × sellPrice formatted compact (highest non-zero denomination only — 10g, 43c); each category header shows the summed subtotal in full g/s/c; the page shows a grand total in full g/s/c equal to the sum of subtotals; a material with sellPrice: null shows "—" and is excluded from every total. Flipping the single TP_TAX_RATE constant from 0 to 0.15 scales every value to 85% with no other code change.
Acceptance scenarios
- Given a tradable material (
sellPricepresent), when its slot renders, then it shows its value =count × sellPrice, formatted compact — only the highest non-zero denomination, floored, with the lower ones dropped (10g 24s 5c→10g;0g 0s 43c→43c). This keeps the dense grid readable. - Given a category of materials, when its section renders, then the header shows the subtotal = the sum of that category's material values, in full g/s/c.
- Given the whole page, when it renders, then a grand total = the sum of all category subtotals appears at the top, in full g/s/c.
- Given a material with
sellPrice: null(untradable/unlisted), when it renders, then its value shows "—" and it contributes 0 to its subtotal and the grand total. - Given
TP_TAX_RATEis changed from0to0.15, when values recompute, then every per-item value, subtotal, and grand total becomes the pre-tax amount ×0.85, with no change to any component — value flows through one shared helper.
P3 — See my wallet like in-game
As a player, I want a Wallet page that shows my currencies as in-game: Coin rendered as gold/silver/ copper with coin icons, every other currency as icon + name + amount in the game's order.
Independent test: with a stored key, /wallet renders Coin (id 1) as a g/s/c headline with the three coin-colored icons, and every other currency as icon + name + thousands-separated amount, sorted by order. With no stored key it renders the same shared ConnectAccountPrompt.
Acceptance scenarios
- Given a stored
wallet-scoped key, when the Wallet page renders, then Coin (id 1) appears pinned at the top asNg Ms Kcwith the three official coin icons. - Given the other currencies, when the page renders, then each shows its icon, name, and thousands-separated amount, ordered by
orderbelow the pinned Coin (a bareordersort would otherwise place Gem above Coin — research V3). - Given no stored API key, when the Wallet page is opened, then it renders the shared
ConnectAccountPromptand issues no wallet request.
Requirements
- R1 — Category enrichment on
/account/materials(API). AddGw2Service.materials()→/v2/materials({ id, name, order, items[] }, 9 categories,?ids=allin one call — research V1; hard-cached with no expiry like items). InAccountService.getMaterials, join each stack'scategoryid to its category name/order and addcategoryName+categoryOrderto the row, and return the rows sorted by(categoryOrder asc, items[]-index asc)so the page's in-game layout does not depend on GW2's undocumented array order (research V2). Rows keep their flat shape;count: 0stays. - R2 — Price enrichment on
/account/materials(API). IngetMaterials, read the storage item ids throughGw2Service.prices(...)and addsellPrice= the item'ssells.unit_price(copper), ornullwhen the id is absent from the/v2/commerce/pricesresponse (non-tradable / unlisted → the batch returns HTTP 206 with that id dropped — research V4). Tradability is decided by presence in the response, not thewhitelistedflag. Prices are joined on egress from the 60 s price cache; the 5-minute per-user cache stores only the raw storage body, so prices are never pinned stale (018 caching rule; egress path confirmed — research V6). - R3 — Currency
orderon/account/wallet(API). Stop strippingorderfrom/v2/currencies; addorderto eachWalletEntry, enriched from the static currency cache withname/icon. - R4 — Zod-first contract & OpenAPI (API). The three new fields are added to the
Material/WalletEntryZod schemas (sellPricenullable), regeneratingopenapi.jsonand the web client;verify:contractstays green. First-class deliverable, not deferred. - R5 — Web data hooks. Add
useMaterials(apiKey)anduseWallet(apiKey)inapps/web/src/api, mirroringuseAccount:useSuspenseQuery,Authorization: Bearer <key>,hashKey(apiKey)folded into the queryKey (never the raw key), response Zod-parsed. Exported fromapi/index.ts. - R6 — Shared key access. Promote
apiKeyStoragefromfeatures/accounttoshared/lib, and add auseApiKey()hook returningstring | null(the read path all account-scoped pages use). The account feature keepswriteApiKey/clearApiKey. No cross-feature import offeatures/accountremains (react.md promotion rule). - R7 —
ConnectAccountPrompt(shared/ui). One reusable component owning the no-key markup: amessageprop and a link to the account page (/). Both pages render it whenuseApiKey()isnull. No render prop — pages read the key via the hook and branch. No duplication of the prompt markup. - R8 — Coin formatting (shared/lib). Two formatters over a copper amount:
formatCoin— fullg/s/c(thousands-separated gold), used by the Wallet Coin headline and by the material subtotals and grand total; andformatCoinCompact— the highest non-zero denomination only, floored (10g 24s→10g,43c→43c,0→0c), used by each per-material value. Both live in the same module. Each denomination is paired with its official coin icon (R12), not a color. - R9 — Material value helper (shared/lib). A single
materialValue(count, sellPrice)returningcount × sellPrice × (1 − TP_TAX_RATE)(floored) ornullwhensellPriceisnull, plus theTP_TAX_RATEconstant (0today). Every per-item value, subtotal, and total flows through it, so net-of-tax is a one-constant change (P2 #5). - R10 — Materials page (
features/materials/).MaterialsPage(key gate) +MaterialsView(useMaterials, suspends) +groupMaterials.ts(group rows bycategory, sort groups bycategoryOrder, and preserve the API's within-category order — which R1 guarantees is theitems[]-index order, research V2). Category<section>s with a name heading, subtotal, and grand total; slots show icon + count badge + rarity-colored border;count: 0dimmed; value under each slot (R8/R9).routes.tsxexposes/materials. - R11 — Wallet page (
features/wallet/).WalletPage(key gate) +WalletView(useWallet, suspends). Coin (id 1) as theg/s/cheadline with the official coin icons; Coin is pinned to the top by id, then the other currencies render icon + name + amount sorted byorder— a plainordersort alone would put Gem (id 4,order100) above Coin (id 1,order101), research V3.routes.tsxexposes/wallet. - R12 — Coin icons & design tokens. Render coins with the three official coin icons (gold / silver / copper), bundled as local static assets in
apps/webwith provenance recorded — the GW2 wiki publishes no canonical coin color palette, so nocoin.*color tokens are introduced (research V5). Existing rarity/semantic tokens are reused elsewhere; the tokens-never-literals guard (__tests__/conventions.test.ts) stays green (coins are images, not color literals). Repeated variants (dimmed slot, count badge) are colocatedcvas in each feature'sstyles.ts(design-system.md "colocated until promoted"). - R13 — Navigation. Add Materials and Wallet
NavLinks to the shell nav inApp.tsx. - R14 — Suspense/error ownership. Pages render no loading/error branch of their own; the shell's
Suspense+QueryBoundary(or a feature-local pair, as the account page does) own both — react.md R10. The key gate (no-key prompt) is a render decision, not an error state. - R15 — No new
docs/superpowers/artifacts; the tokens guard, thedocs/superpowers/count guard, and all prior suites stay green, updated only where this spec changes their subject (App.tsxnav,account.schema.ts, the promotedapiKeyStoragepath).
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer, resolved inresearch.mdwith cited evidence. Blocks the approval gate.
No [NEEDS CLARIFICATION] remains — the design (two in-game-faithful pages; materials in named category sections with dimmed zeros, per-item value (compact — highest denomination only) + subtotal + grand total (full g/s/c) on the list price, gross by default with a one-line net-of-tax switch; wallet with a g/s/c Coin headline (pinned first) and other currencies by order; a shared useApiKey hook + ConnectAccountPrompt gate; coins rendered via the official coin icons) was agreed in brainstorming. All six [NEEDS VERIFICATION] items now carry a verdict in research.md (live/wiki 2026-08-17; codebase for V6) — all resolved, none refuting the spec:
- V1 —
/v2/materialsshape & fetch. Confirmed (research V1).{ id, name, order, items[] }, 9 categories,?ids=allin one call; sort byordermatches the in-game section order. - V2 — grid completeness & ordering. Confirmed / resolved (research V2). The account read returns the full grid incl
count: 0(no reconstruction needed). Within-category order is undocumented upstream, so R1 returns rows sorted byitems[]-index and the page trusts that — no dependence on raw API order. The live200body is unobserved (018 had noinventorieskey) → a dated manual record during implementation, not a code gate. - V3 — Wallet ordering. Confirmed-with-caveat (research V3).
orderexists, but Gem (id 4,order100) sorts before Coin (id 1,order101) — so Coin is pinned to the top by id (R11), the rest byorder. - V4 — Price basis & omission. Confirmed (research V4).
sells.unit_price= lowest sell listing (copper); a non-tradable id → HTTP 206, omitted →sellPrice: null. Tradability = presence in the response, not thewhitelistedflag. - V5 — Coin colors & icons. Confirmed for icons; colors have no canonical source (research V5). Use the three official coin icons (bundled locally); the
coin.*color tokens are dropped — the wiki has no coin hex palette (R12). - V6 — Egress price join is available. Confirmed (research V6). The per-user cache stores the raw body;
AccountServicealready enriches on egress andGw2Service.prices()is reachable fromAccountModule, sosellPricejoins per call without pinning prices stale.
Success criteria
Measurable and outcome-focused. Web behaviors are asserted with the existing apps/web test stack (Vitest + Testing Library) against stubbed data hooks; API deltas with the existing apps/api tests.
- SC1 — With a stored key, the Materials page renders one titled section per material category, ordered by
categoryOrder, each containing that category's material icons with counts. (web test) - SC2 — A
count: 0material renders dimmed with no count; a held material's icon carries its rarity color and exposes its name. (web test) - SC3 — Each tradable material shows
count × sellPricecompact (highest denomination only); each category header shows the subtotal and the page a grand total, both in full g/s/c; asellPrice: nullmaterial shows "—" and contributes 0 to both. (web tests over the page +materialValue) - SC4 — Setting
TP_TAX_RATEto0.15makes every value/subtotal/total the pre-tax amount ×0.85with no other code change. (unit test overmaterialValue/tp.ts) - SC5 — The Wallet page renders Coin (id 1) pinned at the top as a
g/s/cheadline with the three coin icons, and every other currency as icon + name + thousands-separated amount ordered byorderbelow it. (web test) - SC6 — Visiting Materials or Wallet with no stored key renders the shared
ConnectAccountPrompt(linking to/) and issues no data request; both pages use the same component. (web tests) - SC7 — The top nav shows Materials and Wallet links that route to
/materialsand/wallet. (web test) - SC8 —
/account/materialsrows includecategoryName,categoryOrder, and nullablesellPrice;/account/walletrows includeorder;openapi.jsonreflects all four fields andverify:contractpasses. (api tests + CI) - SC9 —
formatCoinrenders copper correctly across cases (only-copper< 100, exact gold, thousands-separated large gold, zero);formatCoinCompactrenders only the highest non-zero denomination, floored (10g 24s→10g,< 100copper →Nc,0→0c). (unit test) - SC10 — The tokens-never-literals guard passes (coins render via bundled icons, not color literals, so no new color tokens are added); the
docs/superpowers/file count stays zero; all prior suites pass. (existing invariants) - SC11 — Every acceptance scenario and success criterion maps to a named test, with no gap; the suite passes.
Out of scope
- Buy-order / instant-sell value — value uses the list price (lowest sell listing) only; the highest-buy-order "sell now" number is deferred.
- Price freshness UI / manual refresh — no "prices as of…" indicator or refetch button; values reflect whatever the 60 s price cache holds.
- Search / filter, item-detail navigation, richer tooltips — no search box, no clicking through to an item page, no tooltip beyond the icon's title/alt.
- Editing holdings — read-only views; no deposit/withdraw.
- Holdings beyond material storage and the wallet — bank, character inventories, shared slots, the legendary armory (each is a later read; 018 out-of-scope stands).
- Reconciliation against a plan ("have vs need for legendary X") — the later planner feature that composes these reads (018 out-of-scope stands).
- A force-refresh override for the 5-minute per-user cache (018 out-of-scope stands).
Assumptions
- 018 stands —
/api/account/materials,/api/account/wallet, and/api/commerce/pricesexist, are enriched, and are cached (5-minute per-user for the account reads, 60 s for prices, no-expiry static for items/currencies). This spec extends the two account responses and reuses prices. - 016 / 015 stand — the account key flow (connect,
localStorage, disconnect), the global/apiprefix, and CORS are the foundation these pages build on. - The Zod-first → Orval pipeline stands (
stack.md) — adding response fields means schema edits, a regenerated client, andverify:contract; no pipeline change. - react.md conventions hold — feature-folder layout, Suspense-only data flow, the cross-feature promotion rule (driving the
apiKeyStoragemove toshared/lib), and the Page/View naming. - design-system.md holds — tokens-never-literals; new coin colors are tokens, not literals; a variant stays a colocated
cvauntil a second feature needs it. /v2/materialsreturns the whole storage grid via the account read — a typical account's distinct material-storage item ids fit within the client's per-batch cap for the price/category enrichment; the client chunks anyway, so correctness does not depend on it, only latency.
Traceability
Each acceptance scenario and success criterion maps to a named test. SC11 asserts no empty cell. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (sections, ordered by categoryOrder) |
| P1 #2 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (count:0 → dimmed, no badge) |
| P1 #3 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P1 #3: slot icon carries the item name and a rarity-specific border" |
| P1 #4 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (no key → ConnectAccountPrompt, hook not called) |
| P1 #5 | apps/web/src/__tests__/App.test.tsx (nav shows Materials + Wallet) |
| P2 #1 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #1: a tradable slot shows its compact coin value" + groupMaterials.test.ts |
| P2 #2 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #2: a category header shows its subtotal in coins" + groupMaterials.test.ts |
| P2 #3 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #3: the page shows a grand total at the top" + groupMaterials.test.ts |
| P2 #4 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (null sellPrice → "—", excluded) + shared/lib/__tests__/tp.test.ts |
| P2 #5 | apps/web/src/shared/lib/__tests__/tp.test.ts (TP_TAX_RATE = 0.15 → ×0.85) |
| P3 #1 | apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (Coin → g/s/c with coin tokens) |
| P3 #2 | apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (currencies ordered by order) |
| P3 #3 | apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (no key → ConnectAccountPrompt) |
| SC1 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + groupMaterials.test.ts |
| SC2 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx |
| SC3 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + shared/lib/__tests__/tp.test.ts |
| SC4 | apps/web/src/shared/lib/__tests__/tp.test.ts |
| SC5 | apps/web/src/features/wallet/__tests__/WalletPage.test.tsx |
| SC6 | apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + wallet/__tests__/WalletPage.test.tsx + shared/ui/__tests__/ConnectAccountPrompt.test.tsx |
| SC7 | apps/web/src/__tests__/App.test.tsx |
| SC8 | apps/api/src/account/account.controller.test.ts + account.service.test.ts + generate-openapi.test.ts; pnpm verify:contract (CI) |
| SC9 | apps/web/src/shared/lib/__tests__/formatCoin.test.ts |
| SC10 | apps/web/src/__tests__/conventions.test.ts + tokens.test.ts; tests/workflow/ (docs/superpowers/ count zero) |
| SC11 | this table complete + every named test exists and passes |