Skip to content

Bank materials & wallet pages — Tasks ​

Status: approved Branch: 020-materials-and-wallet

Status is set by the human, never by the agent. draft → approved (opens step 4 · Implement, entered through plan mode) → implemented.

The task half of plan.md. Every task is TDD: write the failing test, watch it fail, write the minimal code, watch it pass, commit. Commands run from the repo root. node/pnpm live at /opt/homebrew/bin — export it first if the shell can't find them. Every commit message ends with the Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> trailer.

Run a single test file with pnpm vitest run <path>; the whole suite with pnpm test; types with pnpm typecheck.


Task 1 — GW2 client: /v2/materials + currency order ​

Files: Modify apps/api/src/gw2/gw2.schemas.ts, apps/api/src/gw2/gw2-client.ts, apps/api/src/gw2/gw2.service.ts; Test apps/api/src/gw2/gw2.schemas.test.ts, apps/api/src/gw2/gw2-client.test.ts.

  • [ ] Step 1 — Failing schema test. Add to gw2.schemas.test.ts:
ts
import { Gw2CurrencySchema, Gw2MaterialCategorySchema } from './gw2.schemas';

it('T1: Gw2MaterialCategorySchema accepts {id,name,order,items} and strips extras', () => {
  const parsed = Gw2MaterialCategorySchema.parse({
    id: 6, name: 'Basic Crafting Materials', order: 1, items: [1, 2, 3], extra: 'x',
  });
  expect(parsed).toEqual({ id: 6, name: 'Basic Crafting Materials', order: 1, items: [1, 2, 3] });
});

it('T1: Gw2CurrencySchema retains order', () => {
  expect(Gw2CurrencySchema.parse({ id: 1, name: 'Coin', icon: 'c', order: 101 }))
    .toEqual({ id: 1, name: 'Coin', icon: 'c', order: 101 });
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/api/src/gw2/gw2.schemas.test.ts → FAIL (Gw2MaterialCategorySchema undefined; currency drops order).

  • [ ] Step 3 — Implement schema. In gw2.schemas.ts add order to Gw2CurrencySchema and add the category schema + type/export:

ts
export const Gw2CurrencySchema = z.object({
  id: z.number(),
  name: z.string(),
  icon: z.string(),
  order: z.number(),
});

export const Gw2MaterialCategorySchema = z.object({
  id: z.number(),
  name: z.string(),
  order: z.number(),
  items: z.array(z.number()),
});
ts
export type Gw2MaterialCategory = z.infer<typeof Gw2MaterialCategorySchema>;
  • [ ] Step 4 — Run, expect pass. pnpm vitest run apps/api/src/gw2/gw2.schemas.test.ts → PASS.

  • [ ] Step 5 — Failing client test. Add to gw2-client.test.ts (mirror the existing fetch-stub + TokenBucket setup already in that file):

ts
it('T1: materials() fetches /v2/materials?ids=all once, then serves from cache', async () => {
  const fetchFn = vi.fn(async () =>
    new Response(JSON.stringify([{ id: 6, name: 'Basic', order: 1, items: [1, 2] }]), { status: 200 }),
  );
  const client = new Gw2Client({ fetchFn, bucket: new TokenBucket({ capacity: 300, refillPerSec: 5 }) });

  const first = await client.materials();
  const second = await client.materials();

  expect(first).toEqual([{ id: 6, name: 'Basic', order: 1, items: [1, 2] }]);
  expect(second).toEqual(first);
  expect(fetchFn).toHaveBeenCalledTimes(1);
  expect(fetchFn.mock.calls[0][0]).toContain('/materials?ids=all');
});
  • [ ] Step 6 — Run, expect fail. pnpm vitest run apps/api/src/gw2/gw2-client.test.ts → FAIL (materials not a function).

  • [ ] Step 7 — Implement client method. In gw2-client.ts import Gw2MaterialCategory/Gw2MaterialCategorySchema, then add:

ts
materials(): Promise<Gw2MaterialCategory[]> {
  const cached = this.staticCache.get('materials:all');
  if (cached !== undefined) {
    return Promise.resolve(cached as Gw2MaterialCategory[]);
  }
  return this.fetchMaterials();
}

private async fetchMaterials(): Promise<Gw2MaterialCategory[]> {
  await this.bucket.take();
  const res = await this.fetchWithRetry(`${this.baseUrl}/materials?ids=all`, 'materials');
  this.assertSuccessStatus(res, 'materials');
  const parsed = parseOrThrow(z.array(Gw2MaterialCategorySchema), await res.json());
  this.staticCache.set('materials:all', parsed);
  return parsed;
}

Add the Gw2Service passthrough in gw2.service.ts:

ts
materials(): Promise<Gw2MaterialCategory[]> {
  return this.client.materials();
}
  • [ ] Step 8 — Run, expect pass. pnpm vitest run apps/api/src/gw2/gw2-client.test.ts → PASS.

  • [ ] Step 9 — Commit.

bash
git add apps/api/src/gw2
git commit -m "$(printf 'api: add Gw2 client /v2/materials read and currency order\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 2 — Materials category enrichment + ordering ​

Files: Modify apps/api/src/account/account.schema.ts, apps/api/src/account/account.service.ts; Test apps/api/src/account/account.service.test.ts.

The existing T3 — AccountService.getMaterials test asserts the old shape and will be updated here (it now expects categoryName/categoryOrder and a materials() call). That is intended.

  • [ ] Step 1 — Update/extend the failing test. Replace the existing materials test body so the fake Gw2Service also stubs materials() and prices() (prices returns [] for now — Task 3 asserts it), and assert enrichment + sort:
ts
it('T2: enriches categoryName/order and sorts by (categoryOrder, items[] index)', async () => {
  const accountMaterials = vi.fn(async () => [
    { id: 9, category: 29, count: 3 }, // Intermediate, items index 0
    { id: 1, category: 6, count: 250 }, // Basic, items index 1
    { id: 2, category: 6, count: 0 }, // Basic, items index 0
  ]);
  const items = vi.fn(async () => [
    { id: 1, name: 'Flax', icon: 'u1', rarity: 'Basic' },
    { id: 2, name: 'Seed', icon: undefined, rarity: 'Fine' },
    { id: 9, name: 'Bolt', icon: 'u9', rarity: 'Fine' },
  ]);
  const materials = vi.fn(async () => [
    { id: 6, name: 'Basic Crafting Materials', order: 1, items: [2, 1] },
    { id: 29, name: 'Intermediate Crafting Materials', order: 2, items: [9] },
  ]);
  const prices = vi.fn(async () => []);
  const service = await buildService({ accountMaterials, items, materials, prices } as unknown as Partial<Gw2Service>);

  const result = await service.getMaterials('k');

  expect(result.map((r) => [r.id, r.categoryName, r.categoryOrder])).toEqual([
    [2, 'Basic Crafting Materials', 1], // Basic first, items index 0
    [1, 'Basic Crafting Materials', 1], // Basic, items index 1
    [9, 'Intermediate Crafting Materials', 2], // Intermediate second
  ]);
  expect(result.find((r) => r.id === 2)?.count).toBe(0); // count:0 kept (F7)
  expect(materials).toHaveBeenCalled();
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/api/src/account/account.service.test.ts → FAIL.

  • [ ] Step 3 — Extend the schema. In account.schema.ts add to Material:

ts
export const Material = z.object({
  id: z.number().int(),
  count: z.number().int(),
  category: z.number().int(),
  categoryName: z.string(),
  categoryOrder: z.number().int(),
  name: z.string(),
  icon: z.string().nullable(),
  rarity: z.string(),
  sellPrice: z.number().int().nullable(),
});
  • [ ] Step 4 — Implement enrichment + sort. Rewrite getMaterials in account.service.ts (prices joined here too so the shape compiles; Task 3 tests the price value):
ts
async getMaterials(apiKey: string): Promise<Material[]> {
  const rows = await this.gw2.accountMaterials(apiKey);
  const ids = rows.map((r) => r.id);
  const [items, categories, prices] = await Promise.all([
    this.gw2.items(ids),
    this.gw2.materials(),
    this.gw2.prices(ids),
  ]);
  const itemById = new Map(items.map((i) => [i.id, i]));
  const categoryById = new Map(categories.map((c) => [c.id, c]));
  const priceById = new Map(prices.map((p) => [p.id, p]));
  // Within-category display order = the item's index in its category's items[] (research V2).
  const itemIndex = new Map<number, number>();
  for (const c of categories) {
    c.items.forEach((itemId, idx) => itemIndex.set(itemId, idx));
  }

  const enriched = rows.flatMap((r) => {
    const it = itemById.get(r.id);
    const cat = categoryById.get(r.category);
    // Omit a row we can't name or categorise — it can't be rendered in a titled section.
    if (it === undefined || cat === undefined) {
      return [];
    }
    return [
      {
        id: r.id,
        count: r.count,
        category: r.category,
        categoryName: cat.name,
        categoryOrder: cat.order,
        name: it.name,
        icon: it.icon ?? null,
        rarity: it.rarity,
        sellPrice: priceById.get(r.id)?.sells.unit_price ?? null,
      },
    ];
  });

  enriched.sort(
    (a, b) =>
      a.categoryOrder - b.categoryOrder ||
      (itemIndex.get(a.id) ?? 0) - (itemIndex.get(b.id) ?? 0),
  );
  return enriched;
}
  • [ ] Step 5 — Run, expect pass. pnpm vitest run apps/api/src/account/account.service.test.ts → PASS.

  • [ ] Step 6 — Commit.

bash
git add apps/api/src/account
git commit -m "$(printf 'api: enrich materials with category name/order and sort by storage order\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 3 — Materials price enrichment (sellPrice) ​

Files: Modify apps/api/src/account/account.service.test.ts (assert sellPrice); the schema/service already carry it from Task 2.

  • [ ] Step 1 — Failing test. Add:
ts
it('T3: sellPrice = sells.unit_price, null when the id is absent from prices', async () => {
  const accountMaterials = vi.fn(async () => [
    { id: 1, category: 6, count: 10 },
    { id: 2, category: 6, count: 5 },
  ]);
  const items = vi.fn(async () => [
    { id: 1, name: 'Flax', icon: 'u1', rarity: 'Basic' },
    { id: 2, name: 'Bound', icon: 'u2', rarity: 'Basic' },
  ]);
  const materials = vi.fn(async () => [{ id: 6, name: 'Basic', order: 1, items: [1, 2] }]);
  const prices = vi.fn(async () => [
    { id: 1, whitelisted: true, buys: { quantity: 1, unit_price: 90 }, sells: { quantity: 1, unit_price: 100 } },
  ]); // id 2 omitted → non-tradable
  const service = await buildService({ accountMaterials, items, materials, prices } as unknown as Partial<Gw2Service>);

  const result = await service.getMaterials('k');
  expect(result.find((r) => r.id === 1)?.sellPrice).toBe(100);
  expect(result.find((r) => r.id === 2)?.sellPrice).toBeNull();
  expect(prices).toHaveBeenCalledWith([1, 2]);
});
  • [ ] Step 2 — Run, expect pass. pnpm vitest run apps/api/src/account/account.service.test.ts → PASS (the Task 2 implementation already joins prices). If it fails, fix the join, not the test.

  • [ ] Step 3 — Commit.

bash
git add apps/api/src/account
git commit -m "$(printf 'api: join TP sell price onto material rows (null when non-tradable)\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 4 — Wallet order enrichment ​

Files: Modify apps/api/src/account/account.schema.ts, apps/api/src/account/account.service.ts; Test apps/api/src/account/account.service.test.ts.

  • [ ] Step 1 — Update the failing wallet test. Replace the existing wallet test to stub currencies with order and assert it:
ts
it('T4: wallet joins currency name/icon/order, omits unjoinable', async () => {
  const accountWallet = vi.fn(async () => [
    { id: 1, value: 100 },
    { id: 7, value: 5 },
  ]);
  const currencies = vi.fn(async () => [{ id: 1, name: 'Coin', icon: 'c1', order: 101 }]);
  const service = await buildService({ accountWallet, currencies } as unknown as Partial<Gw2Service>);

  const result = await service.getWallet('k');
  expect(result).toEqual([{ id: 1, value: 100, name: 'Coin', icon: 'c1', order: 101 }]);
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/api/src/account/account.service.test.ts → FAIL.

  • [ ] Step 3 — Extend schema + service. In account.schema.ts:

ts
export const WalletEntry = z.object({
  id: z.number().int(),
  value: z.number().int(),
  name: z.string(),
  icon: z.string(),
  order: z.number().int(),
});

In account.service.ts getWallet, add order to the mapped row:

ts
return c ? [{ id: r.id, value: r.value, name: c.name, icon: c.icon, order: c.order }] : [];
  • [ ] Step 4 — Run, expect pass. pnpm vitest run apps/api/src/account/account.service.test.ts → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/api/src/account
git commit -m "$(printf 'api: add currency order to wallet rows\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 5 — Regenerate contract + client, assert paths/fields ​

Files: Modify apps/api/src/generate-openapi.test.ts; regenerate apps/api/openapi.json + apps/web/src/api/generated/**.

  • [ ] Step 1 — Failing OpenAPI test. Add assertions to generate-openapi.test.ts (follow its existing document-building pattern):
ts
it('T5: /account/materials schema exposes categoryName, categoryOrder, sellPrice', () => {
  const props = doc.components.schemas.MaterialListDto.items.properties;
  expect(props).toHaveProperty('categoryName');
  expect(props).toHaveProperty('categoryOrder');
  expect(props).toHaveProperty('sellPrice');
});

it('T5: /account/wallet schema exposes order', () => {
  expect(doc.components.schemas.WalletListDto.items.properties).toHaveProperty('order');
});

(Adjust the accessor to however the existing test reaches the schema — the point is the four new fields are present.)

  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/api/src/generate-openapi.test.ts → FAIL (openapi.json not yet regenerated).

  • [ ] Step 3 — Regenerate. pnpm verify:contract — this builds the API, regenerates openapi.json and the web client, formats, then diffs. It will report a diff the first time; that diff is the regeneration. Stage it.

  • [ ] Step 4 — Run, expect pass. pnpm vitest run apps/api/src/generate-openapi.test.ts → PASS, then pnpm verify:contract → exits 0 (no residual drift).

  • [ ] Step 5 — Commit.

bash
git add apps/api/openapi.json apps/api/src/generate-openapi.test.ts apps/web/src/api/generated
git commit -m "$(printf 'api: regenerate OpenAPI + web client for materials/wallet fields\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 6 — Promote API-key access to shared/lib + useApiKey ​

Files: Create apps/web/src/shared/lib/apiKey.ts, apps/web/src/shared/lib/useApiKey.ts; delete apps/web/src/features/account/apiKeyStorage.ts; move its test to apps/web/src/shared/lib/__tests__/apiKey.test.ts; update features/account imports; Create apps/web/src/shared/lib/__tests__/useApiKey.test.ts.

  • [ ] Step 1 — Move the store, add subscription. Copy features/account/apiKeyStorage.ts to shared/lib/apiKey.ts, preserving readApiKey/writeApiKey/clearApiKey and the existing STORAGE_KEY constant ('gw2priory.apiKey'), and add a subscription store:
ts
const listeners = new Set<() => void>();

function emit(): void {
  for (const cb of listeners) cb();
}

export function subscribe(cb: () => void): () => void {
  listeners.add(cb);
  return () => {
    listeners.delete(cb);
  };
}

export function getSnapshot(): string | null {
  return readApiKey();
}

Make writeApiKey and clearApiKey call emit() after touching localStorage. Move the old apiKeyStorage.test.ts to shared/lib/__tests__/apiKey.test.ts, updating its import path; it should still pass unchanged otherwise.

  • [ ] Step 2 — Failing hook test. Create shared/lib/__tests__/useApiKey.test.ts:
ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { clearApiKey, writeApiKey } from '../apiKey';
import { useApiKey } from '../useApiKey';

beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());

describe('useApiKey', () => {
  it('returns null then reflects write and clear', () => {
    const { result } = renderHook(() => useApiKey());
    expect(result.current).toBeNull();
    act(() => writeApiKey('k'));
    expect(result.current).toBe('k');
    act(() => clearApiKey());
    expect(result.current).toBeNull();
  });
});
  • [ ] Step 3 — Run, expect fail. pnpm vitest run apps/web/src/shared/lib/__tests__/useApiKey.test.ts → FAIL (no useApiKey).

  • [ ] Step 4 — Implement hook. Create shared/lib/useApiKey.ts:

ts
import { useSyncExternalStore } from 'react';
import { getSnapshot, subscribe } from './apiKey';

// One reactive source for the stored key: the account page's write/clear notify every reader,
// so the materials and wallet pages update without a prop or a router reload.
export function useApiKey(): string | null {
  return useSyncExternalStore(subscribe, getSnapshot);
}
  • [ ] Step 5 — Repoint the account feature. Update AccountPage.tsx and any other features/account/* importing ./apiKeyStorage to import from ../../shared/lib/apiKey. Keep the account page's behavior; it may keep using readApiKey/writeApiKey/clearApiKey directly. Delete features/account/apiKeyStorage.ts.

  • [ ] Step 6 — Run, expect pass. pnpm vitest run apps/web/src/shared/lib apps/web/src/features/account → PASS (account tests green against the new path).

  • [ ] Step 7 — Commit.

bash
git add apps/web/src/shared/lib apps/web/src/features/account
git commit -m "$(printf 'web: promote apiKey storage to shared/lib with a useApiKey hook\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 7 — Coin formatting helpers ​

Files: Create apps/web/src/shared/lib/formatCoin.ts, apps/web/src/shared/lib/__tests__/formatCoin.test.ts.

  • [ ] Step 1 — Failing test.
ts
import { describe, expect, it } from 'vitest';
import { formatCoin, formatCoinCompact } from '../formatCoin';

describe('formatCoin (full)', () => {
  it('splits copper into g/s/c, dropping only leading-zero denominations', () => {
    expect(formatCoin(100205)).toEqual([
      { denom: 'gold', amount: 10 },
      { denom: 'silver', amount: 2 },
      { denom: 'copper', amount: 5 },
    ]);
    expect(formatCoin(2405)).toEqual([
      { denom: 'silver', amount: 24 },
      { denom: 'copper', amount: 5 },
    ]);
    expect(formatCoin(43)).toEqual([{ denom: 'copper', amount: 43 }]);
    expect(formatCoin(0)).toEqual([{ denom: 'copper', amount: 0 }]);
  });
});

describe('formatCoinCompact', () => {
  it('returns only the highest non-zero denomination, floored', () => {
    expect(formatCoinCompact(100240)).toEqual({ denom: 'gold', amount: 10 });
    expect(formatCoinCompact(2405)).toEqual({ denom: 'silver', amount: 24 });
    expect(formatCoinCompact(43)).toEqual({ denom: 'copper', amount: 43 });
    expect(formatCoinCompact(0)).toEqual({ denom: 'copper', amount: 0 });
  });
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/web/src/shared/lib/__tests__/formatCoin.test.ts → FAIL.

  • [ ] Step 3 — Implement. Create formatCoin.ts:

ts
export type CoinPart = { denom: 'gold' | 'silver' | 'copper'; amount: number };

export function formatCoin(copper: number): CoinPart[] {
  const gold = Math.floor(copper / 10000);
  const silver = Math.floor((copper % 10000) / 100);
  const parts: CoinPart[] = [];
  if (gold > 0) parts.push({ denom: 'gold', amount: gold });
  if (gold > 0 || silver > 0) parts.push({ denom: 'silver', amount: silver });
  parts.push({ denom: 'copper', amount: copper % 100 });
  return parts;
}

export function formatCoinCompact(copper: number): CoinPart {
  const gold = Math.floor(copper / 10000);
  if (gold > 0) return { denom: 'gold', amount: gold };
  const silver = Math.floor((copper % 10000) / 100);
  if (silver > 0) return { denom: 'silver', amount: silver };
  return { denom: 'copper', amount: copper % 100 };
}
  • [ ] Step 4 — Run, expect pass. → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/shared/lib/formatCoin.ts apps/web/src/shared/lib/__tests__/formatCoin.test.ts
git commit -m "$(printf 'web: add formatCoin/formatCoinCompact copper formatters\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 8 — Material value helper (tp.ts) ​

Files: Create apps/web/src/shared/lib/tp.ts, apps/web/src/shared/lib/__tests__/tp.test.ts.

  • [ ] Step 1 — Failing test (covers SC4).
ts
import { describe, expect, it } from 'vitest';
import { materialValue, TP_TAX_RATE } from '../tp';

describe('materialValue', () => {
  it('is gross by default (TP_TAX_RATE 0)', () => {
    expect(TP_TAX_RATE).toBe(0);
    expect(materialValue(40, 100)).toBe(4000);
  });
  it('returns null when sellPrice is null', () => {
    expect(materialValue(40, null)).toBeNull();
  });
  it('SC4: a 0.15 tax rate yields x0.85, floored', () => {
    expect(materialValue(40, 100, 0.15)).toBe(3400); // 4000 * 0.85
  });
});
  • [ ] Step 2 — Run, expect fail. → FAIL.

  • [ ] Step 3 — Implement. Create tp.ts:

ts
// Gross today. Flip this one constant to 0.15 for net-of-tax; every value/subtotal/total that
// flows through materialValue() changes with it and nothing else does (spec P2 #5 / SC4).
export const TP_TAX_RATE = 0;

export function materialValue(
  count: number,
  sellPrice: number | null,
  taxRate: number = TP_TAX_RATE,
): number | null {
  if (sellPrice === null) return null;
  return Math.floor(count * sellPrice * (1 - taxRate));
}
  • [ ] Step 4 — Run, expect pass. → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/shared/lib/tp.ts apps/web/src/shared/lib/__tests__/tp.test.ts
git commit -m "$(printf 'web: add materialValue helper with one-constant TP tax switch\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 9 — Coin icons + Coins component ​

Files: Create apps/web/src/assets/coins/{gold,silver,copper}.png, apps/web/src/assets/coins/README.md, apps/web/src/shared/ui/Coins.tsx, apps/web/src/shared/ui/styles.ts, apps/web/src/shared/ui/__tests__/Coins.test.tsx.

  • [ ] Step 1 — Bundle the icons. Download the three official wiki coin icons (research V5) into apps/web/src/assets/coins/:
bash
curl -sSL https://wiki.guildwars2.com/images/d/d1/Gold_coin.png -o apps/web/src/assets/coins/gold.png
curl -sSL https://wiki.guildwars2.com/images/3/3c/Silver_coin.png -o apps/web/src/assets/coins/silver.png
curl -sSL https://wiki.guildwars2.com/images/e/eb/Copper_coin.png -o apps/web/src/assets/coins/copper.png

Write README.md recording the source URLs, that these are ArenaNet assets mirrored from the GW2 wiki, and the 2026-08-17 fetch date. If the app's TS config lacks Vite's client types for PNG imports, add /// <reference types="vite/client" /> (or an apps/web/src/assets/assets.d.ts declaring declare module '*.png').

  • [ ] Step 2 — Failing component test.
tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Coins } from '../Coins';

describe('Coins', () => {
  it('full variant renders every denomination with its icon', () => {
    render(<Coins value={100205} />);
    expect(screen.getByText('10')).toBeInTheDocument();
    expect(screen.getByAltText('gold')).toBeInTheDocument();
    expect(screen.getByAltText('silver')).toBeInTheDocument();
    expect(screen.getByAltText('copper')).toBeInTheDocument();
  });
  it('compact variant renders only the top denomination', () => {
    render(<Coins value={100240} variant="compact" />);
    expect(screen.getByAltText('gold')).toBeInTheDocument();
    expect(screen.queryByAltText('silver')).not.toBeInTheDocument();
  });
});
  • [ ] Step 3 — Run, expect fail. → FAIL (no Coins).

  • [ ] Step 4 — Implement. Create shared/ui/styles.ts:

ts
import { css } from '../../../styled-system/css';

export const coinStyles = css({ display: 'inline-flex', alignItems: 'center', gap: '1' });
export const coinGroupStyles = css({ display: 'inline-flex', alignItems: 'center', gap: '0.5' });
export const coinIconStyles = css({ width: '3.5', height: '3.5' });

Create shared/ui/Coins.tsx:

tsx
import copperIcon from '../../assets/coins/copper.png';
import goldIcon from '../../assets/coins/gold.png';
import silverIcon from '../../assets/coins/silver.png';
import { type CoinPart, formatCoin, formatCoinCompact } from '../lib/formatCoin';
import { coinGroupStyles, coinIconStyles, coinStyles } from './styles';

const ICONS: Record<CoinPart['denom'], string> = {
  gold: goldIcon,
  silver: silverIcon,
  copper: copperIcon,
};

export function Coins({
  value,
  variant = 'full',
}: {
  value: number;
  variant?: 'full' | 'compact';
}) {
  const parts = variant === 'compact' ? [formatCoinCompact(value)] : formatCoin(value);
  return (
    <span className={coinStyles}>
      {parts.map((part) => (
        <span key={part.denom} className={coinGroupStyles}>
          {part.amount.toLocaleString()}
          <img src={ICONS[part.denom]} alt={part.denom} className={coinIconStyles} />
        </span>
      ))}
    </span>
  );
}
  • [ ] Step 5 — Run, expect pass. → PASS.

  • [ ] Step 6 — Commit.

bash
git add apps/web/src/assets apps/web/src/shared/ui
git commit -m "$(printf 'web: add Coins component with bundled gold/silver/copper icons\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 10 — ConnectAccountPrompt ​

Files: Create apps/web/src/shared/ui/ConnectAccountPrompt.tsx, apps/web/src/shared/ui/__tests__/ConnectAccountPrompt.test.tsx; extend apps/web/src/shared/ui/styles.ts.

  • [ ] Step 1 — Failing test.
tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it } from 'vitest';
import { ConnectAccountPrompt } from '../ConnectAccountPrompt';

describe('ConnectAccountPrompt', () => {
  it('renders the message and a link to the account page', () => {
    render(
      <MemoryRouter>
        <ConnectAccountPrompt message="to see your materials" />
      </MemoryRouter>,
    );
    expect(screen.getByText(/connect your gw2 account to see your materials/i)).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /account/i })).toHaveAttribute('href', '/');
  });
});
  • [ ] Step 2 — Run, expect fail. → FAIL.

  • [ ] Step 3 — Implement. Add to shared/ui/styles.ts:

ts
export const promptStyles = css({ color: 'text.muted', display: 'flex', flexDirection: 'column', gap: '3', alignItems: 'flex-start' });
export const promptLinkStyles = css({ color: 'primary', fontWeight: 'semibold', textDecoration: 'underline' });

Create shared/ui/ConnectAccountPrompt.tsx:

tsx
import { Link } from 'react-router';
import { promptLinkStyles, promptStyles } from './styles';

export function ConnectAccountPrompt({ message }: { message: string }) {
  return (
    <div className={promptStyles}>
      <p>Connect your GW2 account {message}.</p>
      <Link to="/" className={promptLinkStyles}>
        Go to Account
      </Link>
    </div>
  );
}
  • [ ] Step 4 — Run, expect pass. → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/shared/ui
git commit -m "$(printf 'web: add reusable ConnectAccountPrompt no-key gate\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 11 — Web data hooks (useMaterials, useWallet) ​

Files: Create apps/web/src/api/useMaterials.ts, apps/web/src/api/useWallet.ts; Modify apps/web/src/api/index.ts; Test apps/web/src/api/__tests__/useMaterials.test.ts.

  • [ ] Step 1 — Failing test. Mirror the existing useAccount test (it stubs apiFetch / query and asserts the parse + that the raw key is not in the queryKey). Minimal shape:
ts
import { describe, expect, it } from 'vitest';
import { getAccountControllerMaterialsQueryKey } from '../generated/endpoints/account/account';

it('T11: material queryKey folds a hash, never the raw key', () => {
  // useMaterials builds ['/api/account/materials', hashKey(key)]; assert the raw key is absent.
  // (Follow useAccount.test.ts for the full render-hook harness with a QueryClient + Suspense.)
  expect(getAccountControllerMaterialsQueryKey()).toEqual(['/api/account/materials']);
});

Expand this to the full useAccount.test.ts harness (QueryClientProvider + Suspense) asserting useMaterials('secret') returns the parsed array and the devtools queryKey contains hashKey('secret'), not 'secret'.

  • [ ] Step 2 — Run, expect fail. → FAIL (no useMaterials).

  • [ ] Step 3 — Implement useMaterials.ts (copy useAccount.ts exactly, swapping the endpoint):

ts
import { useSuspenseQuery } from '@tanstack/react-query';
import type { z } from 'zod';
import { hashKey } from '../shared/lib/hashKey';
import { getAccountControllerMaterialsQueryOptions } from './generated/endpoints/account/account';
import { AccountControllerMaterialsResponse } from './generated/endpoints/account/account.zod';
import { suspenseOptions } from './suspenseOptions';

export type Material = z.infer<typeof AccountControllerMaterialsResponse>[number];

export function useMaterials(apiKey: string): Material[] {
  const query = useSuspenseQuery(
    suspenseOptions(
      getAccountControllerMaterialsQueryOptions({
        request: { headers: { Authorization: `Bearer ${apiKey}` } },
        query: { queryKey: ['/api/account/materials', hashKey(apiKey)] as const },
      }),
    ),
  );
  return AccountControllerMaterialsResponse.parse(query.data.data);
}

Create useWallet.ts the same way (...WalletQueryOptions, AccountControllerWalletResponse, key '/api/account/wallet', export type WalletEntry = ...[number]).

Export from api/index.ts:

ts
export { type Material, useMaterials } from './useMaterials';
export { type WalletEntry, useWallet } from './useWallet';
  • [ ] Step 4 — Run, expect pass. pnpm vitest run apps/web/src/api → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/api
git commit -m "$(printf 'web: add useMaterials/useWallet suspense hooks\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 12 — Materials feature ​

Files: Create apps/web/src/features/materials/{groupMaterials.ts,MaterialsView.tsx,MaterialsPage.tsx,styles.ts,routes.tsx}; Test apps/web/src/features/materials/__tests__/{groupMaterials.test.ts,MaterialsPage.test.tsx}.

  • [ ] Step 1 — Failing groupMaterials test.
ts
import { describe, expect, it } from 'vitest';
import type { Material } from '../../../api';
import { groupMaterials } from '../groupMaterials';

const row = (over: Partial<Material>): Material => ({
  id: 1, count: 1, category: 6, categoryName: 'Basic', categoryOrder: 1,
  name: 'X', icon: null, rarity: 'Basic', sellPrice: null, ...over,
});

describe('groupMaterials', () => {
  it('groups by category (sorted by order), sums values, excludes null, totals', () => {
    const { groups, grandTotal } = groupMaterials([
      row({ id: 1, category: 6, categoryOrder: 1, count: 10, sellPrice: 100 }), // 1000
      row({ id: 2, category: 29, categoryName: 'Inter', categoryOrder: 2, count: 5, sellPrice: 200 }), // 1000
      row({ id: 3, category: 6, categoryOrder: 1, count: 3, sellPrice: null }), // excluded
    ]);
    expect(groups.map((g) => [g.id, g.order, g.subtotal])).toEqual([[6, 1, 1000], [29, 2, 1000]]);
    expect(groups[0].items).toHaveLength(2);
    expect(grandTotal).toBe(2000);
  });
});
  • [ ] Step 2 — Run, expect fail. → FAIL.

  • [ ] Step 3 — Implement groupMaterials.ts.

ts
import type { Material } from '../../api';
import { materialValue } from '../../shared/lib/tp';

export type MaterialGroup = {
  id: number;
  name: string;
  order: number;
  items: Material[];
  subtotal: number;
};
export type GroupedMaterials = { groups: MaterialGroup[]; grandTotal: number };

export function groupMaterials(rows: Material[]): GroupedMaterials {
  const byCategory = new Map<number, MaterialGroup>();
  for (const row of rows) {
    let group = byCategory.get(row.category);
    if (group === undefined) {
      group = { id: row.category, name: row.categoryName, order: row.categoryOrder, items: [], subtotal: 0 };
      byCategory.set(row.category, group);
    }
    group.items.push(row);
    const value = materialValue(row.count, row.sellPrice);
    if (value !== null) group.subtotal += value;
  }
  const groups = [...byCategory.values()].sort((a, b) => a.order - b.order);
  const grandTotal = groups.reduce((sum, g) => sum + g.subtotal, 0);
  return { groups, grandTotal };
}
  • [ ] Step 4 — Run, expect pass. → PASS. Commit groupMaterials + test.

  • [ ] Step 5 — Failing page test. Create __tests__/MaterialsPage.test.tsx (mock the hook + key, per the AccountPage test pattern with vi.spyOn(api, 'useMaterials')):

tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import * as keyStore from '../../../shared/lib/apiKey';
import { MaterialsPage } from '../MaterialsPage';

beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
afterEach(() => vi.restoreAllMocks());

const mat = (o: Partial<api.Material>): api.Material => ({
  id: 1, count: 1, category: 6, categoryName: 'Basic Crafting Materials', categoryOrder: 1,
  name: 'Flax', icon: 'i', rarity: 'Basic', sellPrice: 100, ...o,
});

function renderConnected(rows: api.Material[]) {
  vi.spyOn(keyStore, 'getSnapshot').mockReturnValue('key');
  vi.spyOn(api, 'useMaterials').mockReturnValue(rows);
  render(<MemoryRouter><MaterialsPage /></MemoryRouter>);
}

describe('MaterialsPage', () => {
  it('P1 #1: renders a titled section per category', () => {
    renderConnected([mat({ id: 1 }), mat({ id: 2, category: 29, categoryName: 'Intermediate', categoryOrder: 2 })]);
    expect(screen.getByRole('heading', { name: /basic crafting materials/i })).toBeInTheDocument();
    expect(screen.getByRole('heading', { name: /intermediate/i })).toBeInTheDocument();
  });

  it('P1 #2: count:0 slot is dimmed with no count', () => {
    renderConnected([mat({ id: 2, count: 0, sellPrice: null })]);
    expect(screen.queryByText('0')).not.toBeInTheDocument();
    expect(screen.getByTestId('slot-2')).toHaveAttribute('data-dimmed', 'true');
  });

  it('P2 #4: null sellPrice shows a dash and is excluded from the total', () => {
    renderConnected([mat({ id: 1, count: 2, sellPrice: null })]);
    expect(screen.getByTestId('slot-1')).toHaveTextContent('—');
  });

  it('P1 #4: with no stored key, shows the connect prompt and does not call useMaterials', () => {
    vi.spyOn(keyStore, 'getSnapshot').mockReturnValue(null);
    const hook = vi.spyOn(api, 'useMaterials');
    render(<MemoryRouter><MaterialsPage /></MemoryRouter>);
    expect(screen.getByText(/connect your gw2 account/i)).toBeInTheDocument();
    expect(hook).not.toHaveBeenCalled();
  });
});
  • [ ] Step 6 — Run, expect fail. → FAIL.

  • [ ] Step 7 — Implement the view, page, styles, routes.

styles.ts (rarity border via a cva — every rarity enumerated so Panda emits the classes, no literal colors):

ts
import { css, cva } from '../../../styled-system/css';

export const titleStyles = css({ fontSize: '3xl', fontWeight: 'bold', color: 'text.strong', mb: '2' });
export const totalStyles = css({ color: 'text.muted', mb: '6', display: 'flex', gap: '2', alignItems: 'center' });
export const sectionStyles = css({ mb: '8' });
export const sectionHeaderStyles = css({ display: 'flex', gap: '3', alignItems: 'baseline', mb: '4' });
export const sectionTitleStyles = css({ fontSize: 'xl', fontWeight: 'semibold', color: 'text.strong' });
export const gridStyles = css({ display: 'grid', gap: '3', gridTemplateColumns: 'repeat(auto-fill, minmax(84px, 1fr))' });
export const slotStyles = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1', p: '2', borderRadius: 'md', bg: 'card', borderWidth: '1px', borderColor: 'border' });
export const valueStyles = css({ fontSize: 'xs', color: 'text.muted' });
export const countStyles = css({ fontSize: 'xs', fontWeight: 'semibold', color: 'text.strong' });

export const slotIcon = cva({
  base: { width: '10', height: '10', flexShrink: '0', borderWidth: '2px', borderStyle: 'solid', borderRadius: 'sm' },
  variants: {
    rarity: {
      Basic: { borderColor: 'rarity.basic' },
      Fine: { borderColor: 'rarity.fine' },
      Masterwork: { borderColor: 'rarity.masterwork' },
      Rare: { borderColor: 'rarity.rare' },
      Exotic: { borderColor: 'rarity.exotic' },
      Ascended: { borderColor: 'rarity.ascended' },
      Legendary: { borderColor: 'rarity.legendary' },
    },
    dimmed: { true: { opacity: '0.35' } },
  },
});

MaterialsView.tsx:

tsx
import { useMaterials } from '../../api';
import { Coins } from '../../shared/ui/Coins';
import { materialValue } from '../../shared/lib/tp';
import { groupMaterials } from './groupMaterials';
import {
  countStyles, gridStyles, sectionHeaderStyles, sectionStyles, sectionTitleStyles,
  slotIcon, slotStyles, titleStyles, totalStyles, valueStyles,
} from './styles';

const RARITIES = ['Basic', 'Fine', 'Masterwork', 'Rare', 'Exotic', 'Ascended', 'Legendary'] as const;
type Rarity = (typeof RARITIES)[number];
const asRarity = (r: string): Rarity => (RARITIES as readonly string[]).includes(r) ? (r as Rarity) : 'Basic';

export function MaterialsView({ apiKey }: { apiKey: string }) {
  const { groups, grandTotal } = groupMaterials(useMaterials(apiKey));
  return (
    <>
      <h1 className={titleStyles}>Materials</h1>
      <p className={totalStyles}>Total value: <Coins value={grandTotal} /></p>
      {groups.map((group) => (
        <section key={group.id} className={sectionStyles}>
          <div className={sectionHeaderStyles}>
            <h2 className={sectionTitleStyles}>{group.name}</h2>
            <Coins value={group.subtotal} />
          </div>
          <div className={gridStyles}>
            {group.items.map((item) => {
              const value = materialValue(item.count, item.sellPrice);
              return (
                <div key={item.id} data-testid={`slot-${item.id}`} data-dimmed={item.count === 0} className={slotStyles}>
                  {item.icon !== null && (
                    <img src={item.icon} alt={item.name} title={item.name} loading="lazy"
                      className={slotIcon({ rarity: asRarity(item.rarity), dimmed: item.count === 0 })} />
                  )}
                  {item.count > 0 && <span className={countStyles}>{item.count.toLocaleString()}</span>}
                  <span className={valueStyles}>{value === null ? '—' : <Coins value={value} variant="compact" />}</span>
                </div>
              );
            })}
          </div>
        </section>
      ))}
    </>
  );
}

MaterialsPage.tsx:

tsx
import { useApiKey } from '../../shared/lib/useApiKey';
import { ConnectAccountPrompt } from '../../shared/ui/ConnectAccountPrompt';
import { MaterialsView } from './MaterialsView';

export function MaterialsPage() {
  const apiKey = useApiKey();
  if (apiKey === null) return <ConnectAccountPrompt message="to see your materials" />;
  return <MaterialsView apiKey={apiKey} />;
}

routes.tsx:

tsx
import type { RouteObject } from 'react-router';
import { MaterialsPage } from './MaterialsPage';

export const routes: RouteObject[] = [{ path: '/materials', element: <MaterialsPage /> }];
  • [ ] Step 8 — Run, expect pass. pnpm vitest run apps/web/src/features/materials → PASS.

  • [ ] Step 9 — Commit.

bash
git add apps/web/src/features/materials
git commit -m "$(printf 'web: add Materials page (category sections, values, dimmed zeros)\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 13 — Wallet feature ​

Files: Create apps/web/src/features/wallet/{WalletView.tsx,WalletPage.tsx,styles.ts,routes.tsx}; Test apps/web/src/features/wallet/__tests__/WalletPage.test.tsx.

  • [ ] Step 1 — Failing page test.
tsx
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import * as keyStore from '../../../shared/lib/apiKey';
import { WalletPage } from '../WalletPage';

beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
afterEach(() => vi.restoreAllMocks());

function renderConnected(rows: api.WalletEntry[]) {
  vi.spyOn(keyStore, 'getSnapshot').mockReturnValue('key');
  vi.spyOn(api, 'useWallet').mockReturnValue(rows);
  render(<MemoryRouter><WalletPage /></MemoryRouter>);
}

describe('WalletPage', () => {
  it('P3 #1/#2: pins Coin to the top even when Gem sorts before it by order', () => {
    renderConnected([
      { id: 4, value: 500, name: 'Gem', icon: 'g', order: 100 },
      { id: 1, value: 123456789, name: 'Coin', icon: 'c', order: 101 },
      { id: 2, value: 5000, name: 'Karma', icon: 'k', order: 102 },
    ]);
    const rows = screen.getAllByTestId(/^currency-/);
    expect(rows[0]).toHaveAttribute('data-testid', 'currency-1'); // Coin first
    expect(within(rows[0]).getByAltText('gold')).toBeInTheDocument(); // g/s/c icons
    expect(rows[1]).toHaveAttribute('data-testid', 'currency-4'); // then Gem (order 100)
    expect(rows[2]).toHaveAttribute('data-testid', 'currency-2'); // then Karma (order 102)
  });

  it('P3 #3: no stored key shows the connect prompt', () => {
    vi.spyOn(keyStore, 'getSnapshot').mockReturnValue(null);
    render(<MemoryRouter><WalletPage /></MemoryRouter>);
    expect(screen.getByText(/connect your gw2 account/i)).toBeInTheDocument();
  });
});
  • [ ] Step 2 — Run, expect fail. → FAIL.

  • [ ] Step 3 — Implement styles, view, page, routes.

styles.ts:

ts
import { css } from '../../../styled-system/css';

export const titleStyles = css({ fontSize: '3xl', fontWeight: 'bold', color: 'text.strong', mb: '4' });
export const listStyles = css({ display: 'flex', flexDirection: 'column', gap: '2', maxWidth: '480px' });
export const rowStyles = css({ display: 'flex', alignItems: 'center', gap: '3', p: '2', borderRadius: 'md', bg: 'card', borderWidth: '1px', borderColor: 'border' });
export const coinRowStyles = css({ display: 'flex', alignItems: 'center', gap: '3', p: '3', borderRadius: 'md', bg: 'muted', mb: '3' });
export const iconStyles = css({ width: '8', height: '8', flexShrink: '0' });
export const nameStyles = css({ color: 'text.strong', flex: '1' });
export const amountStyles = css({ color: 'text.strong', fontWeight: 'semibold' });

WalletView.tsx:

tsx
import { useWallet } from '../../api';
import { Coins } from '../../shared/ui/Coins';
import { amountStyles, coinRowStyles, iconStyles, listStyles, nameStyles, rowStyles, titleStyles } from './styles';

const COIN_ID = 1;

export function WalletView({ apiKey }: { apiKey: string }) {
  const rows = useWallet(apiKey);
  const coin = rows.find((r) => r.id === COIN_ID);
  const rest = rows.filter((r) => r.id !== COIN_ID).sort((a, b) => a.order - b.order);
  return (
    <>
      <h1 className={titleStyles}>Wallet</h1>
      {coin !== undefined && (
        <div data-testid={`currency-${coin.id}`} className={coinRowStyles}>
          <span className={nameStyles}>{coin.name}</span>
          <Coins value={coin.value} />
        </div>
      )}
      <div className={listStyles}>
        {rest.map((row) => (
          <div key={row.id} data-testid={`currency-${row.id}`} className={rowStyles}>
            <img src={row.icon} alt="" loading="lazy" className={iconStyles} />
            <span className={nameStyles}>{row.name}</span>
            <span className={amountStyles}>{row.value.toLocaleString()}</span>
          </div>
        ))}
      </div>
    </>
  );
}

The Coin test expects currency-1 to be the first currency-* in DOM order. Keep the Coin row rendered before the list so getAllByTestId(/^currency-/) returns Coin first.

WalletPage.tsx:

tsx
import { useApiKey } from '../../shared/lib/useApiKey';
import { ConnectAccountPrompt } from '../../shared/ui/ConnectAccountPrompt';
import { WalletView } from './WalletView';

export function WalletPage() {
  const apiKey = useApiKey();
  if (apiKey === null) return <ConnectAccountPrompt message="to see your wallet" />;
  return <WalletView apiKey={apiKey} />;
}

routes.tsx:

tsx
import type { RouteObject } from 'react-router';
import { WalletPage } from './WalletPage';

export const routes: RouteObject[] = [{ path: '/wallet', element: <WalletPage /> }];
  • [ ] Step 4 — Run, expect pass. pnpm vitest run apps/web/src/features/wallet → PASS.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/features/wallet
git commit -m "$(printf 'web: add Wallet page (Coin headline pinned, currencies by order)\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 14 — Nav + route registration ​

Files: Modify apps/web/src/App.tsx, apps/web/src/main.tsx; Test apps/web/src/__tests__/App.test.tsx.

  • [ ] Step 1 — Failing nav test. Add to (or create) App.test.tsx a check that the shell renders Materials + Wallet links:
tsx
import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { describe, expect, it } from 'vitest';
import { App } from '../App';

it('T14: nav shows Materials and Wallet links', () => {
  const router = createMemoryRouter([{ element: <App />, children: [{ path: '/', element: <div /> }] }], { initialEntries: ['/'] });
  render(<RouterProvider router={router} />);
  expect(screen.getByRole('link', { name: 'Materials' })).toHaveAttribute('href', '/materials');
  expect(screen.getByRole('link', { name: 'Wallet' })).toHaveAttribute('href', '/wallet');
});
  • [ ] Step 2 — Run, expect fail. → FAIL.

  • [ ] Step 3 — Add nav links in App.tsx (between Legendaries and Health), mirroring the existing NavLink shape:

tsx
<NavLink to="/materials" className={({ isActive }) => navItem({ active: isActive })}>
  Materials
</NavLink>
<NavLink to="/wallet" className={({ isActive }) => navItem({ active: isActive })}>
  Wallet
</NavLink>
  • [ ] Step 4 — Register routes in main.tsx:
tsx
import { routes as materialsRoutes } from './features/materials/routes';
import { routes as walletRoutes } from './features/wallet/routes';
// ...
children: [...accountRoutes, ...legendariesRoutes, ...materialsRoutes, ...walletRoutes, ...healthRoutes],
  • [ ] Step 5 — Run, expect pass. pnpm vitest run apps/web/src/__tests__/App.test.tsx → PASS.

  • [ ] Step 6 — Commit.

bash
git add apps/web/src/App.tsx apps/web/src/main.tsx apps/web/src/__tests__/App.test.tsx
git commit -m "$(printf 'web: link and route the Materials and Wallet pages\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task 15 — Verify, traceability, status ​

Files: Modify specs/020-materials-and-wallet/spec.md (traceability table; status transcription on the human's word).

  • [ ] Step 1 — Full green gate. Run and confirm each passes:
bash
pnpm typecheck
pnpm test
pnpm verify:contract
pnpm docs:build
pnpm lint
  • [ ] Step 2 — Fill traceability. In spec.md, replace each traceability cell's test path with the concrete describe/it names now present (P1 #1–5, P2 #1–5, P3 #1–3, SC1–SC11). Confirm no empty cell (SC11).

  • [ ] Step 3 — Live manual record (research V2). If an inventories-scoped GW2 key is available, capture a dated note that a live /account/materials 200 returns count:0 slots in category+items[] order, and paste it under the spec's traceability or a manual-record note. If no key is available, record that it remains outstanding (the page's correctness does not depend on it — ordering is imposed client-side).

  • [ ] Step 4 — Code review. Use superpowers:requesting-code-review; address findings via superpowers:receiving-code-review.

  • [ ] Step 5 — Status → implemented (human-set). On the human's explicit word, transcribe spec.md status approved → implemented inside this branch (part of the PR diff), noting the human decided it. Commit:

bash
git add specs/020-materials-and-wallet/spec.md
git commit -m "$(printf 'specs: fill 020 traceability and mark implemented (human-set)\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>')"

Task ordering & parallelism ​

Tasks 1→5 are the API contract and must land in order (5 depends on 1–4's schemas). Tasks 6–11 are web foundations: 6, 7, 8 are independent of each other; 9 depends on 7; 10 is independent; 11 depends on 5 (generated client). Tasks 12–13 depend on 6–11; 12 and 13 are independent of each other. Task 14 depends on 12–13. Task 15 is last. A subagent-driven run can parallelise {6,7,8,10} and later {12,13}, but keep the API chain (1–5) sequential.