Bank materials & wallet pages — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. The bite-sized checkbox steps live in
tasks.md(step 3); this file is the header half — architecture, file structure, and task decomposition with interfaces.
Status: approved Branch: 020-materials-and-wallet
Status is set by the human, never by the agent. draft → approved (opens step 3 · Tasks) → implemented.
Goal: Ship two in-game-faithful, read-only web pages — Material Storage and Wallet — that consume the spec-018 account reads, extended with the display fields those pages need.
Architecture: Approach A — the API returns flat, enriched rows and the web does the grouping/formatting. The API side adds a /v2/materials fetch to the GW2 client and enriches /account/materials rows with categoryName + categoryOrder + sellPrice (sorted by (categoryOrder, items[]-index)), and /account/wallet rows with order — all Zod-first so openapi.json and the Orval client regenerate. The web side adds two suspense hooks, promotes API-key access into shared/lib, adds shared coin/value helpers and a ConnectAccountPrompt gate, then builds the two feature folders and wires nav + routes.
Tech Stack: API — NestJS 11, nestjs-zod, SWC, Vitest. Web — React 19, react-router 8, @tanstack/react-query 5 (suspense), Zod 4, Panda CSS, Orval-generated client, Vitest + Testing Library + jsdom (+ msw available).
Global Constraints
Every task's requirements implicitly include these (verbatim from the spec, CLAUDE.md, and the architecture docs):
- No
any, no unexplained escape hatches (docs/architecture/typescript.md). - Tokens, never literals — no hex/
rgb()/hsl()literal colors anywhere inapps/web/src; the guard testapps/web/src/__tests__/conventions.test.tsmust stay green. No new coin color tokens are added (coins render via bundled icons — research V5). - GW2 access only through
Gw2Service— never a directfetchfrom a service (docs/architecture/stack.md). - Zod-first contract — response shapes are Zod schemas feeding
@ZodResponse,openapi.json, and the Orval client;pnpm verify:contractmust pass (no drift). - Suspense-only data flow — pages declare no loading/error branch; the shell (or a feature-local
Suspense+QueryBoundary, as the account page does) owns both (react.mdR10). The no-key gate is a render decision, not an error state. - Feature-folder layout & naming —
features/<name>/, the routed component ends inPage, its suspending child is aView; no cross-feature imports — anything shared is promoted toshared/(react.md). - Per-user cache holds the raw body; enrich on egress — never bake volatile prices into the 5-min per-user cache (
docs/architecture/gw2-api.md; research V6). TP_TAX_RATE = 0today (gross); net-of-tax must be a one-constant change.- Coin display: Coin is currency id 1, pinned to the top of the wallet by id (Gem id 4
order100 otherwise sorts before Coinorder101 — research V3).sellPricebasis issells.unit_price(lowest sell listing, copper);nullwhen the id is absent from/v2/commerce/prices(tradability = presence, notwhitelisted— research V4). - No new
docs/superpowers/files — the count guard stays at zero. - Commits: imperative, scoped (
api:,web:,specs:), each ending with theCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>trailer. Frequent commits (one per task, TDD). - Definition of done: every acceptance scenario and success criterion maps to a named test in the spec's traceability table; typecheck + tests +
verify:contract+docs:buildgreen; the human reviews the diff; status →implementedinside the PR.
File Structure
API (apps/api)
- Modify
src/gw2/gw2.schemas.ts— addGw2MaterialCategorySchema({ id, name, order, items }); addorderto the currency schema (018 stripped it). - Modify
src/gw2/gw2-client.ts— addmaterials()→GET /v2/materials?ids=all, cached instaticCache(no expiry). Currency read now carriesorder. - Modify
src/gw2/gw2.service.ts— addmaterials()passthrough. - Modify
src/account/account.schema.ts—MaterialgainscategoryName,categoryOrder,sellPrice(nullable);WalletEntrygainsorder. - Modify
src/account/account.service.ts—getMaterialsjoins categories + prices and sorts;getWalletaddsorder. - Modify
openapi.json— regenerated (not hand-edited). - Tests:
gw2.schemas.test.ts,gw2-client.test.ts,account.service.test.ts,account.controller.test.ts,generate-openapi.test.ts.
Web (apps/web)
- Regenerate
src/api/generated/**viapnpm --filter @gw2priory/web generate:api(Orval) after the OpenAPI change. - Move
src/features/account/apiKeyStorage.ts→src/shared/lib/apiKey.ts(add a subscribe/snapshot store); createsrc/shared/lib/useApiKey.ts. Updatefeatures/account/*imports; move its test. - Create
src/shared/lib/formatCoin.ts,src/shared/lib/tp.ts. - Create
src/shared/ui/Coins.tsx+src/assets/coins/{gold,silver,copper}.png(bundled, provenance recorded); createsrc/shared/ui/ConnectAccountPrompt.tsx. - Create
src/api/useMaterials.ts,src/api/useWallet.ts; export fromsrc/api/index.ts. - Create
src/features/materials/{MaterialsPage,MaterialsView}.tsx,groupMaterials.ts,styles.ts,routes.tsx. - Create
src/features/wallet/{WalletPage,WalletView}.tsx,styles.ts,routes.tsx. - Modify
src/App.tsx(nav links) andsrc/main.tsx(register the two route tables). - Tests: colocated
__tests__for each unit +src/__tests__/App.test.tsx(nav).
Tasks
Ordered by dependency: API contract first (Tasks 1–5), then web foundations (6–11), then pages and wiring (12–14), then verification (15). Each task ends with an independently testable deliverable and its own commit.
Task 1 — GW2 client: /v2/materials + currency order
Files: Modify src/gw2/gw2.schemas.ts, src/gw2/gw2-client.ts, src/gw2/gw2.service.ts; Test src/gw2/gw2.schemas.test.ts, src/gw2/gw2-client.test.ts.
Interfaces — Produces:
Gw2MaterialCategory = { id: number; name: string; order: number; items: number[] }(Zod:Gw2MaterialCategorySchema).Gw2Client.materials(): Promise<Gw2MaterialCategory[]>—GET /v2/materials?ids=all, validated, cached instaticCachewith no expiry (mirroritems).Gw2Service.materials()passthrough.Gw2Currencygainsorder: number(stop stripping it).
Deliverable: the client can fetch the 9 material categories (cached) and currencies now expose order. Tests: schema accepts the live shape + strips extras; materials() hits the network once then serves from cache; currency parse retains order.
Task 2 — Materials category enrichment + ordering (service + schema)
Files: Modify src/account/account.schema.ts, src/account/account.service.ts; Test src/account/account.service.test.ts.
Interfaces:
- Consumes:
Gw2Service.materials()(Task 1), existingaccountMaterials+items. - Produces:
MaterialgainscategoryName: string,categoryOrder: number.getMaterialsreturns rows sorted by(categoryOrder asc, then the item's index in that category'sitems[]asc).
Deliverable: each material row carries category name/order and the array is in in-game layout order. Tests: a row is enriched with the right categoryName/categoryOrder; rows come back sorted across categories and within a category by items[] index; a count: 0 row still survives (F7 — guard against a count > 0 filter regression).
Task 3 — Materials price enrichment (sellPrice, egress join)
Files: Modify src/account/account.schema.ts, src/account/account.service.ts; Test src/account/account.service.test.ts.
Interfaces:
- Consumes:
Gw2Service.prices(ids)(existing, 60 s cache). - Produces:
MaterialgainssellPrice: number | null= the item'ssells.unit_price, ornullwhen the id is absent from the prices response.
Deliverable: rows carry a unit list price, joined on egress (not cached in the per-user body). Tests: a tradable id gets its sells.unit_price; an id omitted by the prices endpoint gets sellPrice: null; the join reads from Gw2Service.prices, and the per-user cached body is the raw storage rows (enrichment runs per call — research V6).
Task 4 — Wallet order enrichment (service + schema)
Files: Modify src/account/account.schema.ts, src/account/account.service.ts; Test src/account/account.service.test.ts.
Interfaces — Produces: WalletEntry gains order: number, joined from the currency read alongside name/icon.
Deliverable: wallet rows carry order. Tests: a wallet row is enriched with its currency order; a currency id the read omits is still dropped (existing 018 behavior preserved).
Task 5 — Regenerate contract + client, assert paths/fields
Files: Modify openapi.json (regenerated), src/api/generated/** (regenerated); Modify src/generate-openapi.test.ts if it asserts field presence; run pnpm verify:contract.
Interfaces — Produces: the Orval-generated web types (AccountControllerMaterialsResponse etc.) now include the new fields — the web hooks in Tasks 8/11 consume them.
Deliverable: openapi.json + generated client reflect the four new fields and verify:contract passes. Tests: generate-openapi.test.ts asserts /account/materials schema has categoryName/categoryOrder/sellPrice and /account/wallet has order; verify:contract is clean (committed regen, no drift).
Task 6 — Promote API-key access to shared/lib + useApiKey
Files: Create src/shared/lib/apiKey.ts (moved from features/account/apiKeyStorage.ts), src/shared/lib/useApiKey.ts; Modify features/account/{AccountPage.tsx,ApiKeyInput.tsx,...} imports; Move test to src/shared/lib/__tests__/apiKey.test.ts; Create src/shared/lib/__tests__/useApiKey.test.ts.
Interfaces — Produces:
readApiKey(): string | null,writeApiKey(key: string): void,clearApiKey(): void(write/clear notify subscribers),subscribe(cb): () => void,getSnapshot(): string | null.useApiKey(): string | null—useSyncExternalStore(subscribe, getSnapshot), so a connect/disconnect on the account page is reflected everywhere.
Deliverable: one shared, reactive key source; features/account consumes it (no logic lost). Tests: read/write/clear round-trip; useApiKey re-renders on write and clear; account feature still connects/disconnects.
Task 7 — Coin formatting helpers (formatCoin)
Files: Create src/shared/lib/formatCoin.ts, src/shared/lib/__tests__/formatCoin.test.ts.
Interfaces — Produces:
type CoinPart = { denom: 'gold' | 'silver' | 'copper'; amount: number }.formatCoin(copper: number): CoinPart[]— full, highest non-zero denomination down to copper, dropping only leading-zero denominations (100205→[10g, 2s, 5c];43→[43c];0→[0c]).formatCoinCompact(copper: number): CoinPart— the single highest non-zero denomination, floored (100240→10g;43→43c;0→0c).
Deliverable: pure, tested copper→denomination logic. Tests: the SC9 cases — only-copper < 100, exact gold, thousands-separated large gold, zero, and compact flooring.
Task 8 — Material value helper (tp.ts)
Files: Create src/shared/lib/tp.ts, src/shared/lib/__tests__/tp.test.ts.
Interfaces — Produces:
export const TP_TAX_RATE = 0;materialValue(count: number, sellPrice: number | null): number | null=sellPrice === null ? null : Math.floor(count * sellPrice * (1 - TP_TAX_RATE)).
Deliverable: the single value chokepoint. Tests: gross value; null passthrough; SC4 — setting TP_TAX_RATE to 0.15 yields × 0.85 (tested by parameterizing the rate).
Task 9 — Coin icons + Coins component
Files: Create src/assets/coins/{gold,silver,copper}.png (download from the research-V5 wiki URLs; record provenance in a sibling src/assets/coins/README.md), src/shared/ui/Coins.tsx, src/shared/ui/styles.ts (or colocated), src/shared/ui/__tests__/Coins.test.tsx.
Interfaces:
- Consumes:
formatCoin/formatCoinCompact(Task 7), the three imported PNG assets. - Produces:
<Coins value={copper} variant="full" | "compact" />— renders eachCoinPartasamount+ its coin icon (<img alt="gold"/>etc.);variantdefaults to"full".
Deliverable: the one place coins render as icons. Tests: full variant renders all parts with the right icons/alt; compact renders one; zero renders 0c.
Task 10 — ConnectAccountPrompt
Files: Create src/shared/ui/ConnectAccountPrompt.tsx, colocated styles.ts, src/shared/ui/__tests__/ConnectAccountPrompt.test.tsx.
Interfaces — Produces: <ConnectAccountPrompt message={string} /> — renders "Connect your GW2 account {message}" and a react-router Link to /.
Deliverable: the reusable no-key gate UI. Tests: renders the message and a link to / (wrap in a router for the test).
Task 11 — Web data hooks (useMaterials, useWallet)
Files: Create src/api/useMaterials.ts, src/api/useWallet.ts; Modify src/api/index.ts; Test src/api/__tests__/useMaterials.test.ts, useWallet.test.ts.
Interfaces:
- Consumes: the Orval query options + zod response (Task 5),
hashKey(existing). - Produces:
useMaterials(apiKey: string): Material[]anduseWallet(apiKey: string): WalletEntry[](suspense;Authorization: Bearer <key>; queryKey foldshashKey(apiKey); response Zod-parsed) — mirrorsuseAccount. Types re-exported fromapi/index.ts.
Deliverable: typed suspense reads for both pages. Tests: the hook parses a stubbed response and never puts the raw key in the queryKey (mirror the useAccount test).
Task 12 — Materials feature (grouping + view + page)
Files: Create src/features/materials/groupMaterials.ts, MaterialsView.tsx, MaterialsPage.tsx, styles.ts, routes.tsx; Test src/features/materials/__tests__/{groupMaterials,MaterialsPage}.test.tsx.
Interfaces:
- Consumes:
useMaterials(11),useApiKey(6),ConnectAccountPrompt(10),Coins(9),materialValue(8), rarity tokens. - Produces:
groupMaterials(rows: Material[]): { groups: { id: number; name: string; order: number; items: Material[]; subtotal: number }[]; grandTotal: number }— groups bycategorypreserving the pre-sorted order,subtotal= Σ non-nullmaterialValue,grandTotal= Σ subtotals.routes: RouteObject[]=[{ path: '/materials', element: <MaterialsPage /> }].
Deliverable: the Materials page — category sections (name heading + <Coins> subtotal), grand total at top, slots (icon + count badge + rarity-colored border, count:0 dimmed, compact <Coins> value or "—"), and the no-key gate. Tests: P1 #1–4, P2 #1–4 (sections/order, dimmed zeros, rarity+name, no-key prompt, per-item value, subtotal, grand total, null→"—"/excluded).
Task 13 — Wallet feature (view + page)
Files: Create src/features/wallet/WalletView.tsx, WalletPage.tsx, styles.ts, routes.tsx; Test src/features/wallet/__tests__/WalletPage.test.tsx.
Interfaces:
- Consumes:
useWallet(11),useApiKey(6),ConnectAccountPrompt(10),Coins(9). - Produces:
routes: RouteObject[]=[{ path: '/wallet', element: <WalletPage /> }].WalletViewpins id 1 to the top rendered via<Coins variant="full">, then the rest sorted byorder(icon + name + thousands-separated amount).
Deliverable: the Wallet page. Tests: P3 #1 (Coin pinned, g/s/c with icons), P3 #2 (rest ordered by order, Gem not above Coin), P3 #3 (no-key prompt).
Task 14 — Nav + route registration
Files: Modify src/App.tsx (add Materials + Wallet NavLinks), src/main.tsx (spread materialsRoutes + walletRoutes into the router children); Test src/__tests__/App.test.tsx.
Deliverable: both pages reachable and linked. Tests: P1 #5 / SC7 — nav shows Materials + Wallet and they route to /materials and /wallet.
Task 15 — Verify + traceability + status
Files: Modify specs/020-materials-and-wallet/spec.md (fill the traceability table with the now-existing test ids; transcribe status → implemented only on the human's word).
Deliverable: the whole slice is green and traced. Steps: run pnpm typecheck, pnpm test, pnpm verify:contract, pnpm docs:build; confirm every traceability cell points to a passing test; capture the dated manual record for the live count:0 observation (research V2) if a real inventories-scoped key is available, else note it outstanding; request code review (superpowers:requesting-code-review).
Self-Review
- Spec coverage: R1→T1–2, R2→T3, R3→T4, R4→T5, R5→T11, R6→T6, R7→T10, R8→T7/T9, R9→T8, R10→T12, R11→T13, R12→T9, R13→T14, R14→T12/T13, R15→T15. P1→T12/T14, P2→T12, P3→T13. SC1–3→T12, SC4→T8, SC5→T13, SC6→T10/T12/T13, SC7→T14, SC8→T5, SC9→T7, SC10→T15, SC11→T15. No uncovered requirement.
- Placeholders: none — every task names exact files, interfaces, and test focus; TDD code goes in
tasks.md. - Type consistency:
Gw2MaterialCategory,Material(+categoryName/categoryOrder/sellPrice),WalletEntry(+order),CoinPart,materialValue,useMaterials/useWallet,groupMaterialsreturn shape, and<Coins value variant>are used identically across tasks.
Open decisions deferred to tasks.md / implementation
- Exact web page-test mechanism (mock the data hook vs
msw+Suspensewrapper) — follow the existingfeatures/accounttest pattern. - Whether category enrichment also flows through the egress path or is prebuilt once per request (correctness-neutral — research V6).
- Coin icon bundling vs hotlink — plan bundles locally (research V5 caveat: the silver render URL 404s).