Skip to content

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:

json
[{
  "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 / categoryOrder are joined from /v2/materials (the material-category list — { id, name, order, items[] }, immutable → hard-cached like items), keyed by the row's category id.
  • sellPrice is the unit lowest sell listing in copper — the sells.unit_price of /v2/commerce/prices for that item id — or null when 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: 0 slots are still returned.
  • 400 / 401 / 403 / 502 error 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):

json
[{ "id": 1, "value": 184320, "name": "Coin", "icon": "https://render.guildwars2.com/…", "order": 101 }]
  • order is enriched from the static currency cache alongside name/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

  1. Given a stored key whose materials span several categories, when the Materials page renders, then it shows one titled <section> per category, ordered by categoryOrder, each holding the category's material icons with their counts.
  2. Given a material stack with count: 0, when the page renders, then that slot appears dimmed with no count badge (it is not hidden).
  3. 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.
  4. 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.
  5. 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

  1. Given a tradable material (sellPrice present), 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.
  2. 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.
  3. 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.
  4. 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.
  5. Given TP_TAX_RATE is changed from 0 to 0.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

  1. Given a stored wallet-scoped key, when the Wallet page renders, then Coin (id 1) appears pinned at the top as Ng Ms Kc with the three official coin icons.
  2. Given the other currencies, when the page renders, then each shows its icon, name, and thousands-separated amount, ordered by order below the pinned Coin (a bare order sort would otherwise place Gem above Coin — research V3).
  3. Given no stored API key, when the Wallet page is opened, then it renders the shared ConnectAccountPrompt and issues no wallet request.

Requirements ​

  • R1 — Category enrichment on /account/materials (API). Add Gw2Service.materials() → /v2/materials ({ id, name, order, items[] }, 9 categories, ?ids=all in one call — research V1; hard-cached with no expiry like items). In AccountService.getMaterials, join each stack's category id to its category name/order and add categoryName + categoryOrder to 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: 0 stays.
  • R2 — Price enrichment on /account/materials (API). In getMaterials, read the storage item ids through Gw2Service.prices(...) and add sellPrice = the item's sells.unit_price (copper), or null when the id is absent from the /v2/commerce/prices response (non-tradable / unlisted → the batch returns HTTP 206 with that id dropped — research V4). Tradability is decided by presence in the response, not the whitelisted flag. 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 order on /account/wallet (API). Stop stripping order from /v2/currencies; add order to each WalletEntry, enriched from the static currency cache with name/icon.
  • R4 — Zod-first contract & OpenAPI (API). The three new fields are added to the Material / WalletEntry Zod schemas (sellPrice nullable), regenerating openapi.json and the web client; verify:contract stays green. First-class deliverable, not deferred.
  • R5 — Web data hooks. Add useMaterials(apiKey) and useWallet(apiKey) in apps/web/src/api, mirroring useAccount: useSuspenseQuery, Authorization: Bearer <key>, hashKey(apiKey) folded into the queryKey (never the raw key), response Zod-parsed. Exported from api/index.ts.
  • R6 — Shared key access. Promote apiKeyStorage from features/account to shared/lib, and add a useApiKey() hook returning string | null (the read path all account-scoped pages use). The account feature keeps writeApiKey / clearApiKey. No cross-feature import of features/account remains (react.md promotion rule).
  • R7 — ConnectAccountPrompt (shared/ui). One reusable component owning the no-key markup: a message prop and a link to the account page (/). Both pages render it when useApiKey() is null. 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 — full g/s/c (thousands-separated gold), used by the Wallet Coin headline and by the material subtotals and grand total; and formatCoinCompact — 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) returning count × sellPrice × (1 − TP_TAX_RATE) (floored) or null when sellPrice is null, plus the TP_TAX_RATE constant (0 today). 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 by category, sort groups by categoryOrder, and preserve the API's within-category order — which R1 guarantees is the items[]-index order, research V2). Category <section>s with a name heading, subtotal, and grand total; slots show icon + count badge + rarity-colored border; count: 0 dimmed; value under each slot (R8/R9). routes.tsx exposes /materials.
  • R11 — Wallet page (features/wallet/). WalletPage (key gate) + WalletView (useWallet, suspends). Coin (id 1) as the g/s/c headline with the official coin icons; Coin is pinned to the top by id, then the other currencies render icon + name + amount sorted by order — a plain order sort alone would put Gem (id 4, order 100) above Coin (id 1, order 101), research V3. routes.tsx exposes /wallet.
  • R12 — Coin icons & design tokens. Render coins with the three official coin icons (gold / silver / copper), bundled as local static assets in apps/web with provenance recorded — the GW2 wiki publishes no canonical coin color palette, so no coin.* 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 colocated cvas in each feature's styles.ts (design-system.md "colocated until promoted").
  • R13 — Navigation. Add Materials and Wallet NavLinks to the shell nav in App.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, the docs/superpowers/ count guard, and all prior suites stay green, updated only where this spec changes their subject (App.tsx nav, account.schema.ts, the promoted apiKeyStorage path).

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 in research.md with 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/materials shape & fetch. Confirmed (research V1). { id, name, order, items[] }, 9 categories, ?ids=all in one call; sort by order matches 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 by items[]-index and the page trusts that — no dependence on raw API order. The live 200 body is unobserved (018 had no inventories key) → a dated manual record during implementation, not a code gate.
  • V3 — Wallet ordering. Confirmed-with-caveat (research V3). order exists, but Gem (id 4, order 100) sorts before Coin (id 1, order 101) — so Coin is pinned to the top by id (R11), the rest by order.
  • 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 the whitelisted flag.
  • 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; AccountService already enriches on egress and Gw2Service.prices() is reachable from AccountModule, so sellPrice joins 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: 0 material 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 × sellPrice compact (highest denomination only); each category header shows the subtotal and the page a grand total, both in full g/s/c; a sellPrice: null material shows "—" and contributes 0 to both. (web tests over the page + materialValue)
  • SC4 — Setting TP_TAX_RATE to 0.15 makes every value/subtotal/total the pre-tax amount × 0.85 with no other code change. (unit test over materialValue / tp.ts)
  • SC5 — The Wallet page renders Coin (id 1) pinned at the top as a g/s/c headline with the three coin icons, and every other currency as icon + name + thousands-separated amount ordered by order below 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 /materials and /wallet. (web test)
  • SC8 — /account/materials rows include categoryName, categoryOrder, and nullable sellPrice; /account/wallet rows include order; openapi.json reflects all four fields and verify:contract passes. (api tests + CI)
  • SC9 — formatCoin renders copper correctly across cases (only-copper < 100, exact gold, thousands-separated large gold, zero); formatCoinCompact renders only the highest non-zero denomination, floored (10g 24s → 10g, < 100 copper → 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/prices exist, 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 /api prefix, 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, and verify:contract; no pipeline change.
  • react.md conventions hold — feature-folder layout, Suspense-only data flow, the cross-feature promotion rule (driving the apiKeyStorage move to shared/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 cva until a second feature needs it.
  • /v2/materials returns 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.

CriterionTest
P1 #1apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (sections, ordered by categoryOrder)
P1 #2apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (count:0 → dimmed, no badge)
P1 #3apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P1 #3: slot icon carries the item name and a rarity-specific border"
P1 #4apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (no key → ConnectAccountPrompt, hook not called)
P1 #5apps/web/src/__tests__/App.test.tsx (nav shows Materials + Wallet)
P2 #1apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #1: a tradable slot shows its compact coin value" + groupMaterials.test.ts
P2 #2apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #2: a category header shows its subtotal in coins" + groupMaterials.test.ts
P2 #3apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx — "P2 #3: the page shows a grand total at the top" + groupMaterials.test.ts
P2 #4apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (null sellPrice → "—", excluded) + shared/lib/__tests__/tp.test.ts
P2 #5apps/web/src/shared/lib/__tests__/tp.test.ts (TP_TAX_RATE = 0.15 → ×0.85)
P3 #1apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (Coin → g/s/c with coin tokens)
P3 #2apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (currencies ordered by order)
P3 #3apps/web/src/features/wallet/__tests__/WalletPage.test.tsx (no key → ConnectAccountPrompt)
SC1apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + groupMaterials.test.ts
SC2apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx
SC3apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + shared/lib/__tests__/tp.test.ts
SC4apps/web/src/shared/lib/__tests__/tp.test.ts
SC5apps/web/src/features/wallet/__tests__/WalletPage.test.tsx
SC6apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx + wallet/__tests__/WalletPage.test.tsx + shared/ui/__tests__/ConnectAccountPrompt.test.tsx
SC7apps/web/src/__tests__/App.test.tsx
SC8apps/api/src/account/account.controller.test.ts + account.service.test.ts + generate-openapi.test.ts; pnpm verify:contract (CI)
SC9apps/web/src/shared/lib/__tests__/formatCoin.test.ts
SC10apps/web/src/__tests__/conventions.test.ts + tokens.test.ts; tests/workflow/ (docs/superpowers/ count zero)
SC11this table complete + every named test exists and passes