Skip to content

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 in apps/web/src; the guard test apps/web/src/__tests__/conventions.test.ts must stay green. No new coin color tokens are added (coins render via bundled icons — research V5).
  • GW2 access only through Gw2Service — never a direct fetch from a service (docs/architecture/stack.md).
  • Zod-first contract — response shapes are Zod schemas feeding @ZodResponse, openapi.json, and the Orval client; pnpm verify:contract must 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.md R10). The no-key gate is a render decision, not an error state.
  • Feature-folder layout & naming — features/<name>/, the routed component ends in Page, its suspending child is a View; no cross-feature imports — anything shared is promoted to shared/ (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 = 0 today (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 order 100 otherwise sorts before Coin order 101 — research V3). sellPrice basis is sells.unit_price (lowest sell listing, copper); null when the id is absent from /v2/commerce/prices (tradability = presence, not whitelisted — research V4).
  • No new docs/superpowers/ files — the count guard stays at zero.
  • Commits: imperative, scoped (api:, web:, specs:), each ending with the Co-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:build green; the human reviews the diff; status → implemented inside the PR.

File Structure ​

API (apps/api) ​

  • Modify src/gw2/gw2.schemas.ts — add Gw2MaterialCategorySchema ({ id, name, order, items }); add order to the currency schema (018 stripped it).
  • Modify src/gw2/gw2-client.ts — add materials() → GET /v2/materials?ids=all, cached in staticCache (no expiry). Currency read now carries order.
  • Modify src/gw2/gw2.service.ts — add materials() passthrough.
  • Modify src/account/account.schema.ts — Material gains categoryName, categoryOrder, sellPrice (nullable); WalletEntry gains order.
  • Modify src/account/account.service.ts — getMaterials joins categories + prices and sorts; getWallet adds order.
  • 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/** via pnpm --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); create src/shared/lib/useApiKey.ts. Update features/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); create src/shared/ui/ConnectAccountPrompt.tsx.
  • Create src/api/useMaterials.ts, src/api/useWallet.ts; export from src/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) and src/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 in staticCache with no expiry (mirror items). Gw2Service.materials() passthrough.
  • Gw2Currency gains order: 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), existing accountMaterials + items.
  • Produces: Material gains categoryName: string, categoryOrder: number. getMaterials returns rows sorted by (categoryOrder asc, then the item's index in that category's items[] 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: Material gains sellPrice: number | null = the item's sells.unit_price, or null when 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 each CoinPart as amount + its coin icon (<img alt="gold"/> etc.); variant defaults 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[] and useWallet(apiKey: string): WalletEntry[] (suspense; Authorization: Bearer <key>; queryKey folds hashKey(apiKey); response Zod-parsed) — mirrors useAccount. Types re-exported from api/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 by category preserving the pre-sorted order, subtotal = Σ non-null materialValue, 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 /> }]. WalletView pins id 1 to the top rendered via <Coins variant="full">, then the rest sorted by order (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, groupMaterials return 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 + Suspense wrapper) — follow the existing features/account test 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).