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:
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 (Gw2MaterialCategorySchemaundefined; currency dropsorder).[ ] Step 3 — Implement schema. In
gw2.schemas.tsaddordertoGw2CurrencySchemaand add the category schema + type/export:
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()),
});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 +TokenBucketsetup already in that file):
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 (materialsnot a function).[ ] Step 7 — Implement client method. In
gw2-client.tsimportGw2MaterialCategory/Gw2MaterialCategorySchema, then add:
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:
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.
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.getMaterialstest asserts the old shape and will be updated here (it now expectscategoryName/categoryOrderand amaterials()call). That is intended.
- [ ] Step 1 — Update/extend the failing test. Replace the existing materials test body so the fake
Gw2Servicealso stubsmaterials()andprices()(prices returns[]for now — Task 3 asserts it), and assert enrichment + sort:
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.tsadd toMaterial:
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
getMaterialsinaccount.service.ts(prices joined here too so the shape compiles; Task 3 tests the price value):
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.
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:
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.
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
orderand assert it:
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:
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:
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.
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):
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, regeneratesopenapi.jsonand 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, thenpnpm verify:contract→ exits 0 (no residual drift).[ ] Step 5 — Commit.
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.tstoshared/lib/apiKey.ts, preservingreadApiKey/writeApiKey/clearApiKeyand the existingSTORAGE_KEYconstant ('gw2priory.apiKey'), and add a subscription store:
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:
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 (nouseApiKey).[ ] Step 4 — Implement hook. Create
shared/lib/useApiKey.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.tsxand any otherfeatures/account/*importing./apiKeyStorageto import from../../shared/lib/apiKey. Keep the account page's behavior; it may keep usingreadApiKey/writeApiKey/clearApiKeydirectly. Deletefeatures/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.
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.
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:
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.
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).
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:
// 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.
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/:
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.pngWrite 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.
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:
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:
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.
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.
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:
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:
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.
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
useAccounttest (it stubsapiFetch/ query and asserts the parse + that the raw key is not in the queryKey). Minimal shape:
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(copyuseAccount.tsexactly, swapping the endpoint):
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:
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.
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
groupMaterialstest.
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.
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 withvi.spyOn(api, 'useMaterials')):
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):
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:
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:
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:
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.
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.
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:
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:
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-1to be the firstcurrency-*in DOM order. Keep the Coin row rendered before the list sogetAllByTestId(/^currency-/)returns Coin first.
WalletPage.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:
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.
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.tsxa check that the shell renders Materials + Wallet links:
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 existingNavLinkshape:
<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:
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.
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:
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 concretedescribe/itnames 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/materials200returnscount:0slots 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 viasuperpowers:receiving-code-review.[ ] Step 5 — Status → implemented (human-set). On the human's explicit word, transcribe
spec.mdstatusapproved→implementedinside this branch (part of the PR diff), noting the human decided it. Commit:
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.