Legendary profit ranking — Tasks
Status: approved Branch: 023-profit
Status is set by the human, never by the agent. proposed → 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 for the right reason, write the minimal code, watch it pass, refactor green, commit. Commands run from the repo root. node/pnpm live at /opt/homebrew/bin — export it first if the shell can't find them (export PATH="/opt/homebrew/bin:$PATH"). Every commit message is imperative, scoped (api: / web: / pkg:), and 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; lint with pnpm lint; the React-Compiler build gate with pnpm build. Regenerate the OpenAPI doc + Orval client with pnpm --filter @gw2priory/api openapi then pnpm --filter web orval (confirm the exact script names in each package.json before running — T9 depends on them).
Global Constraints in plan.md apply to every task and are not repeated per task. Dependency order: package lift (T1–T2) → backend sourcing (T3–T4) → backend ranking (T5–T9) → web (T10–T13) → verify (T14). T1–T2 must land first and keep the 021 detail-page suite green (SC8) before anything consumes the shared functions.
T1 — Lift aggregateNeed into @gw2priory/recipe-graph
Satisfies: R10 (part), SC8.
Files: Create packages/recipe-graph/src/aggregate-need.ts, packages/recipe-graph/src/__tests__/aggregate-need.test.ts; Modify packages/recipe-graph/src/index.ts; Create apps/web/src/features/legendaries/aggregateNeed.ts (thin re-export); Delete the old body from that file; Delete apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts.
[ ] Step 1 — Move the test into the package. Copy
apps/web/src/features/legendaries/__tests__/aggregateNeed.test.tstopackages/recipe-graph/src/__tests__/aggregate-need.test.ts. Change its import toimport { aggregateNeed } from '../aggregate-need';and its tree-fixture type import to the package's node type:import type { PricedTreeNode } from '../types';. Keep every existing case (multiplier walk, fractional-yield fold3.1 → 74.5, duplicate leaf merged, currency ingredients, gated tally).[ ] Step 2 — Run it red.
Run: pnpm vitest run packages/recipe-graph/src/__tests__/aggregate-need.test.ts Expected: FAIL — Cannot find module '../aggregate-need'.
[ ] Step 3 — Move the implementation. Create
packages/recipe-graph/src/aggregate-need.tswith the exact body of the current webaggregateNeed.ts, with one change: the input type. Replaceimport type { RecipeTree } from '../../api';withimport type { PricedTreeNode } from './types';and change the signature toexport function aggregateNeed(root: PricedTreeNode): NeedTotals. The walk reads onlynode.decision,node.recipe(outputCount,ingredients),node.children,node.node.{id,name,rarity},node.unitBuyPrice— all present onPricedTreeNode. Export theNeedItem/NeedTotalsinterfaces andcountGatedLeavesunchanged.[ ] Step 4 — Export from the package barrel. In
packages/recipe-graph/src/index.tsaddexport * from './aggregate-need';after the existingexport * from './types';.[ ] Step 5 — Run it green.
Run: pnpm vitest run packages/recipe-graph/src/__tests__/aggregate-need.test.ts Expected: PASS.
- [ ] Step 6 — Re-export from web, delete the web test. Replace the entire body of
apps/web/src/features/legendaries/aggregateNeed.tswith:
// Lifted into the shared package (spec 023 R10); re-exported so existing imports keep working.
export {
aggregateNeed,
countGatedLeaves,
type NeedItem,
type NeedTotals,
} from '@gw2priory/recipe-graph';Delete apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts (coverage now lives in the package).
- [ ] Step 7 — Web suite still green.
Run: pnpm vitest run apps/web/src/features/legendaries Expected: PASS — the detail-page tests that consume aggregateNeed are unaffected.
- [ ] Step 8 — Commit.
git add packages/recipe-graph/src apps/web/src/features/legendaries/aggregateNeed.ts
git rm apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts
git commit -m "pkg: lift aggregateNeed into @gw2priory/recipe-graph
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T2 — Lift + generalise mergeOwnVsNeed to an owned-items map
Satisfies: R10, SC2, SC8.
Files: Create packages/recipe-graph/src/own-vs-need.ts, packages/recipe-graph/src/__tests__/own-vs-need.test.ts; Modify packages/recipe-graph/src/index.ts; Replace body of apps/web/src/features/legendaries/mergeOwnVsNeed.ts (re-export); Modify apps/web/src/features/legendaries/OwnVsNeedProvider.tsx; Delete apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts.
- [ ] Step 1 — Failing test for the generalised signature. Create
packages/recipe-graph/src/__tests__/own-vs-need.test.ts. Port the existing web cases, but drive owned from aMapand the currencies from a wallet array as before:
import { describe, expect, it } from 'vitest';
import { mergeOwnVsNeed, needRows } from '../own-vs-need';
import type { NeedTotals } from '../aggregate-need';
const need = (): NeedTotals => ({
items: new Map([[19675, { qty: 74.5, unitBuyPrice: 200, name: 'Mystic Clover', rarity: 'Rare', gated: true }],
[19721, { qty: 250, unitBuyPrice: 3000, name: 'Glob of Ectoplasm', rarity: 'Exotic', gated: false }]]),
currencies: new Map([[2, 100]]),
});
describe('mergeOwnVsNeed (owned map)', () => {
it('ceils need, subtracts owned once, computes short and remainingCost', () => {
const owned = new Map<number, number>([[19721, 100]]); // own 100 of 250 ecto
const { rows, remainingCost } = mergeOwnVsNeed(need(), owned, []);
const ecto = rows.find((r) => r.id === 19721);
expect(ecto?.need).toBe(250);
expect(ecto?.have).toBe(100);
expect(ecto?.short).toBe(150);
expect(remainingCost).toBe(150 * 3000);
});
it('untracked buyable leaf: have undefined, full need into remainingCost', () => {
const { rows, remainingCost } = mergeOwnVsNeed(need(), new Map(), []);
const ecto = rows.find((r) => r.id === 19721);
expect(ecto?.have).toBeUndefined();
expect(remainingCost).toBe(250 * 3000);
});
});
describe('needRows (market total, ignores holdings)', () => {
it('sums ceil(need) × unitBuyPrice over buyable leaves only', () => {
expect(needRows(need()).total).toBe(250 * 3000); // gated clover excluded
});
});- [ ] Step 2 — Run it red.
Run: pnpm vitest run packages/recipe-graph/src/__tests__/own-vs-need.test.ts Expected: FAIL — Cannot find module '../own-vs-need'.
- [ ] Step 3 — Move + generalise the implementation. Create
packages/recipe-graph/src/own-vs-need.tsfrom the current webmergeOwnVsNeed.ts, changing the owned source from aMaterial[]to aMap<number, number>:
import type { NeedTotals } from './aggregate-need';
export type OwnedItems = ReadonlyMap<number, number>;
export interface OwnVsNeedRow {
kind: 'item' | 'currency';
id: number; name: string; rarity: string | null;
need: number; have?: number; short?: number;
cost: number | null; gated: boolean;
}
export interface OwnVsNeed { rows: OwnVsNeedRow[]; remainingCost: number; }
// Buyable-leaf market rows: need × price, holdings ignored. Currencies + gated excluded.
export function needRows(need: NeedTotals): { rows: OwnVsNeedRow[]; total: number } {
const rows: OwnVsNeedRow[] = [];
let total = 0;
for (const [id, item] of need.items) {
if (item.gated || item.unitBuyPrice === null) continue;
const n = Math.ceil(item.qty);
const cost = n * item.unitBuyPrice;
total += cost;
rows.push({ kind: 'item', id, name: item.name, rarity: item.rarity, need: n, cost, gated: false });
}
return { rows, total };
}
// Connected overlay: buyable items gain have/short/cost → remainingCost; currencies gain have/short
// (matched to `wallet` by id, no gold); gated items are need-only. Owned counted once (the map is
// already the summed count per id).
export function mergeOwnVsNeed(
need: NeedTotals,
owned: OwnedItems,
wallet: ReadonlyArray<{ id: number; value: number }>,
): OwnVsNeed {
const walById = new Map(wallet.map((w) => [w.id, w.value]));
const rows: OwnVsNeedRow[] = [];
let remainingCost = 0;
for (const [id, item] of need.items) {
const n = Math.ceil(item.qty);
if (item.gated) {
rows.push({ kind: 'item', id, name: item.name, rarity: item.rarity, need: n, cost: null, gated: true });
continue;
}
if (item.unitBuyPrice === null) {
rows.push({ kind: 'item', id, name: item.name, rarity: item.rarity, need: n, cost: null, gated: false });
continue;
}
const have = owned.get(id);
const short = have === undefined ? n : Math.max(0, n - have);
const cost = short * item.unitBuyPrice;
remainingCost += cost;
rows.push({ kind: 'item', id, name: item.name, rarity: item.rarity, need: n, have, short, cost, gated: false });
}
for (const [id, qty] of need.currencies) {
const n = Math.ceil(qty);
const have = walById.get(id);
rows.push({ kind: 'currency', id, name: `currency ${id}`, rarity: null, need: n,
have, short: have === undefined ? n : Math.max(0, n - have), cost: null, gated: false });
}
return { rows, remainingCost };
}(Preserve any name-resolution the original had; if the web version resolved currency names, keep that field as-is — only the owned source changed.)
[ ] Step 4 — Export from the barrel. Add
export * from './own-vs-need';topackages/recipe-graph/src/index.ts.[ ] Step 5 — Run it green.
Run: pnpm vitest run packages/recipe-graph/src/__tests__/own-vs-need.test.ts Expected: PASS.
- [ ] Step 6 — Re-export from web + build the owned map in the provider. Replace the body of
apps/web/src/features/legendaries/mergeOwnVsNeed.tswith:
// Lifted into the shared package (spec 023 R10); re-exported for existing imports.
export {
mergeOwnVsNeed, needRows,
type OwnedItems, type OwnVsNeed, type OwnVsNeedRow,
} from '@gw2priory/recipe-graph';In OwnVsNeedProvider.tsx, change the mergeOwnVsNeed(need, materials, wallet) call to build the owned map first — behaviour-identical (materials-only source, owned counted once):
const owned = new Map(materials.map((m) => [m.id, m.count]));
const ownVsNeed = mergeOwnVsNeed(need, owned, wallet);Delete apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts.
- [ ] Step 7 — 021 suite green (SC8 backstop).
Run: pnpm vitest run apps/web/src/features/legendaries && pnpm --filter web typecheck Expected: PASS — OwnVsNeedProvider, ShoppingList, LegendarySummary render identically.
- [ ] Step 8 — Commit.
git add packages/recipe-graph/src apps/web/src/features/legendaries/mergeOwnVsNeed.ts apps/web/src/features/legendaries/OwnVsNeedProvider.tsx
git rm apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts
git commit -m "pkg: lift+generalise mergeOwnVsNeed to an owned-items map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T3 — GW2 client: bank, shared inventory, characters, character inventory
Satisfies: R8.
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/__tests__/gw2-client.inventory.test.ts (or extend the existing client test).
- [ ] Step 1 — Failing test. Create
apps/api/src/gw2/__tests__/gw2-client.inventory.test.tsmirroring the existing client test'sfetchFnstub. Assert the four reads hit the right paths, parse slots, skip nulls, and map a403toGw2ForbiddenErrorwith the scope from the body:
import { describe, expect, it, vi } from 'vitest';
import { Gw2ForbiddenError } from '../gw2.errors';
import { makeClientForTest } from './helpers'; // reuse the existing test harness/factory
const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
describe('Gw2Client inventory reads', () => {
it('accountBank parses item slots and preserves null empties', async () => {
const fetchFn = vi.fn().mockResolvedValue(ok([{ id: 19721, count: 250 }, null]));
const client = makeClientForTest({ fetchFn });
const slots = await client.accountBank('key');
expect(fetchFn.mock.calls[0][0]).toContain('/account/bank');
expect(slots).toEqual([{ id: 19721, count: 250 }, null]);
});
it('characters returns names; characterInventory returns bags', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(ok(['Alt One']))
.mockResolvedValueOnce(ok({ bags: [{ id: 8, size: 20, inventory: [{ id: 19677, count: 1 }, null] }, null] }));
const client = makeClientForTest({ fetchFn });
expect(await client.characters('key')).toEqual(['Alt One']);
const inv = await client.characterInventory('key', 'Alt One');
expect(fetchFn.mock.calls[1][0]).toContain('/characters/Alt%20One/inventory');
expect(inv.bags[0]?.inventory[0]).toEqual({ id: 19677, count: 1 });
});
it('maps 403 to Gw2ForbiddenError with the scope', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: 'requires scope characters' }), { status: 403 }));
const client = makeClientForTest({ fetchFn });
await expect(client.characters('key')).rejects.toBeInstanceOf(Gw2ForbiddenError);
});
});(If the existing client test uses an inline factory rather than a helpers export, copy that setup here instead of importing it.)
- [ ] Step 2 — Run it red.
Run: pnpm vitest run apps/api/src/gw2/__tests__/gw2-client.inventory.test.ts Expected: FAIL — client.accountBank is not a function.
- [ ] Step 3 — Schemas. In
gw2.schemas.tsadd:
export const Gw2ItemSlotSchema = z.object({ id: z.number().int().positive(), count: z.number().int().nonnegative() });
export const Gw2CharacterInventorySchema = z.object({
bags: z.array(z.object({ id: z.number().int(), size: z.number().int(), inventory: z.array(Gw2ItemSlotSchema.nullable()) }).nullable()),
});
export type Gw2ItemSlot = z.infer<typeof Gw2ItemSlotSchema>;
export type Gw2CharacterInventory = z.infer<typeof Gw2CharacterInventorySchema>;(The GW2 slot objects carry more fields; the schema keeps only id+count — z.object strips the rest, which is all owned-counting needs. Use .nullable() on slots so empty slots parse.)
- [ ] Step 4 — Client reads. In
gw2-client.tsadd three array reads viaauthedCachedReadand one object read mirroringaccount()+ caching:
accountBank(apiKey: string): Promise<(Gw2ItemSlot | null)[]> {
return this.authedCachedRead('/account/bank', Gw2ItemSlotSchema.nullable(), apiKey, 'bank', 'inventories');
}
accountSharedInventory(apiKey: string): Promise<(Gw2ItemSlot | null)[]> {
return this.authedCachedRead('/account/inventory', Gw2ItemSlotSchema.nullable(), apiKey, 'shared-inventory', 'inventories');
}
characters(apiKey: string): Promise<string[]> {
return this.authedCachedRead('/characters', z.string(), apiKey, 'characters', 'characters');
}
async characterInventory(apiKey: string, name: string): Promise<Gw2CharacterInventory> {
const path = `/characters/${encodeURIComponent(name)}/inventory`;
const cacheKey = `char-inv:${createHash('sha256').update(`${apiKey}:${name}`).digest('hex')}`;
const hit = this.accountCache.get(cacheKey);
if (hit !== undefined) return hit as Gw2CharacterInventory;
await this.bucket.take();
const res = await this.fetchWithRetry(`${this.baseUrl}${path}`, path, { headers: { Authorization: `Bearer ${apiKey}` } });
if (res.status === 401) throw new Gw2UnauthorizedError('GW2 rejected the API key');
if (res.status === 403) throw new Gw2ForbiddenError(scopeFromBody(await res.json().catch(() => null)) ?? 'inventories');
this.assertSuccessStatus(res, path);
const parsed = parseOrThrow(Gw2CharacterInventorySchema, await res.json());
this.accountCache.set(cacheKey, parsed, ACCOUNT_TTL_MS);
return parsed;
}Import the new schema symbols at the top of gw2-client.ts.
[ ] Step 5 — Gw2Service passthroughs. In
gw2.service.tsadd thin wrappers (matching theaccountMaterialspassthrough):accountBank,accountSharedInventory,characters,characterInventoryeach delegating tothis.client.*.[ ] Step 6 — Run it green + types.
Run: pnpm vitest run apps/api/src/gw2/__tests__/gw2-client.inventory.test.ts && pnpm --filter @gw2priory/api typecheck Expected: PASS.
- [ ] Step 7 — Commit.
git add apps/api/src/gw2
git commit -m "api: add GW2 bank/shared-inventory/characters/character-inventory reads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T4 — AccountService.getOwnedItems (four-source owned map)
Satisfies: R4, SC5 (sourcing).
Files: Modify apps/api/src/account/account.service.ts; Test apps/api/src/account/__tests__/account.service.owned.test.ts.
- [ ] Step 1 — Failing test. Stub
Gw2Serviceso the same id appears in different sources; assert one summed map, nulls skipped, a character-only id counted:
import { describe, expect, it, vi } from 'vitest';
import { AccountService } from '../account.service';
function make(overrides: Partial<Record<string, unknown>> = {}) {
const gw2 = {
accountMaterials: vi.fn().mockResolvedValue([{ id: 19721, count: 200 }]),
accountBank: vi.fn().mockResolvedValue([{ id: 19721, count: 50 }, null]),
accountSharedInventory: vi.fn().mockResolvedValue([null]),
characters: vi.fn().mockResolvedValue(['A', 'B']),
characterInventory: vi.fn()
.mockResolvedValueOnce({ bags: [{ id: 8, size: 20, inventory: [{ id: 19677, count: 1 }, null] }] })
.mockResolvedValueOnce({ bags: [null] }),
...overrides,
};
return { service: new AccountService(gw2 as never), gw2 };
}
describe('getOwnedItems', () => {
it('sums an id across materials + bank, skips nulls, counts a character-only id', async () => {
const { service } = make();
const owned = await service.getOwnedItems('key');
expect(owned.get(19721)).toBe(250); // 200 materials + 50 bank
expect(owned.get(19677)).toBe(1); // only in character A's bag
});
});- [ ] Step 2 — Run it red.
Run: pnpm vitest run apps/api/src/account/__tests__/account.service.owned.test.ts Expected: FAIL — service.getOwnedItems is not a function.
- [ ] Step 3 — Implement. Add to
account.service.ts:
async getOwnedItems(apiKey: string): Promise<Map<number, number>> {
const owned = new Map<number, number>();
const add = (id: number, count: number) => owned.set(id, (owned.get(id) ?? 0) + count);
const [materials, bank, shared, names] = await Promise.all([
this.gw2.accountMaterials(apiKey),
this.gw2.accountBank(apiKey),
this.gw2.accountSharedInventory(apiKey),
this.gw2.characters(apiKey),
]);
for (const m of materials) add(m.id, m.count);
for (const s of bank) if (s) add(s.id, s.count);
for (const s of shared) if (s) add(s.id, s.count);
const invs = await Promise.all(names.map((n) => this.gw2.characterInventory(apiKey, n)));
for (const inv of invs)
for (const bag of inv.bags)
if (bag) for (const slot of bag.inventory) if (slot) add(slot.id, slot.count);
return owned;
}(accountMaterials returns enriched rows carrying { id, count } — use those two fields.)
- [ ] Step 4 — Run it green.
Run: pnpm vitest run apps/api/src/account/__tests__/account.service.owned.test.ts Expected: PASS.
- [ ] Step 5 — Commit.
git add apps/api/src/account
git commit -m "api: AccountService.getOwnedItems sums four owned-item sources
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T5 — GEN1_IDS shared constant
Satisfies: R1.
Files: Modify apps/api/src/legendaries/legendaries.data.ts; Test apps/api/src/legendaries/__tests__/legendaries.data.test.ts (extend).
- [ ] Step 1 — Failing test.
import { GEN1_IDS } from '../legendaries.data';
it('GEN1_IDS is the 21 Gen-1 weapon ids', () => {
expect(GEN1_IDS).toHaveLength(21);
expect(GEN1_IDS[0]).toBe(30684);
expect(GEN1_IDS.at(-1)).toBe(30704);
});[ ] Step 2 — Run red. Run:
pnpm vitest run apps/api/src/legendaries/__tests__/legendaries.data.test.ts→ FAIL (GEN1_IDSundefined).[ ] Step 3 — Implement. In
legendaries.data.tsadd, afterLEGENDARY_IDS:
export const GEN1_IDS: readonly number[] = LEGENDARY_IDS.filter((e) => e.generation === 1).map((e) => e.id);[ ] Step 4 — Run green. Same command → PASS.
[ ] Step 5 — Commit.
git add apps/api/src/legendaries/legendaries.data.ts apps/api/src/legendaries/__tests__/legendaries.data.test.ts
git commit -m "api: export GEN1_IDS from legendaries.data
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T6 — ranking.schema.ts (RankingRow contract)
Satisfies: R1, R3, R5.
Files: Create apps/api/src/legendaries/ranking.schema.ts; Test apps/api/src/legendaries/__tests__/ranking.schema.test.ts.
- [ ] Step 1 — Failing test.
import { RankingList, RankingRow } from '../ranking.schema';
it('accepts a well-formed row and rejects a negative id', () => {
const row = { id: 30704, name: 'Twilight', icon: null, subtype: 'Greatsword',
netSell: 1, marketCost: 1, myCost: 1, personalProfit: 0,
gatedInputs: [{ id: 19678, name: 'Gift of Battle', needed: 1, have: 0, satisfied: false }], craftable: false };
expect(RankingRow.parse(row)).toEqual(row);
expect(() => RankingList.parse([{ ...row, id: -1 }])).toThrow();
});[ ] Step 2 — Run red. → FAIL (module missing).
[ ] Step 3 — Implement
ranking.schema.tsexactly as theplan.mdData & contracts block specifies (GatedInput,RankingRow,RankingList,class RankingListDto extends createZodDto(RankingList)), plusexport type RankingRow = z.infer<typeof RankingRow>.[ ] Step 4 — Run green. → PASS.
[ ] Step 5 — Commit.
api: add ranking.schema (RankingRow contract)(trailer as above).
T7 — RankingService pure helpers
Satisfies: R3, R5, R6.
Files: Create apps/api/src/legendaries/ranking.service.ts (helpers only this task); Test apps/api/src/legendaries/__tests__/ranking.helpers.test.ts.
- [ ] Step 1 — Failing test for
toRankingRow,byPersonalProfitDescIdAsc,unionBuyableIds:
import { byPersonalProfitDescIdAsc, toRankingRow, unionBuyableIds } from '../ranking.service';
it('sorts by personalProfit desc, id asc, nulls last', () => {
const rows = [
{ id: 2, personalProfit: 10 }, { id: 1, personalProfit: 10 },
{ id: 3, personalProfit: null }, { id: 4, personalProfit: 50 },
] as never[];
expect([...rows].sort(byPersonalProfitDescIdAsc).map((r) => r.id)).toEqual([4, 1, 2, 3]);
});
it('toRankingRow: myCost=remainingCost, personalProfit=netSell-myCost, gated have from owned map', () => {
const need = { items: new Map([[19678, { qty: 1, unitBuyPrice: null, name: 'Gift of Battle', rarity: null, gated: true }]]), currencies: new Map() };
const summary = { rootId: 30704, totalCraftCost: 1000, rootBuyPrice: null, netSell: 1200, profit: 200 };
const owned = new Map<number, number>();
const row = toRankingRow(30704, { name: 'Twilight', icon: null, subtype: 'Greatsword' }, summary, need as never, /*remainingCost*/ 900, owned);
expect(row.myCost).toBe(900);
expect(row.personalProfit).toBe(300); // 1200 - 900
expect(row.gatedInputs).toEqual([{ id: 19678, name: 'Gift of Battle', needed: 1, have: 0, satisfied: false }]);
expect(row.craftable).toBe(false);
});[ ] Step 2 — Run red. → FAIL (module missing).
[ ] Step 3 — Implement the helpers in
ranking.service.ts(the class shell comes in T8; export the helpers now):
import { collectPricedIds } from '../recipe-graph/pricing';
import type { ResolvedGraph } from '@gw2priory/recipe-graph';
import { needRows, type NeedTotals } from '@gw2priory/recipe-graph';
import type { RankingRow } from './ranking.schema';
export function unionBuyableIds(graphs: ResolvedGraph[]): number[] {
return [...new Set(graphs.flatMap(collectPricedIds))];
}
export function byPersonalProfitDescIdAsc(a: Pick<RankingRow, 'id' | 'personalProfit'>, b: Pick<RankingRow, 'id' | 'personalProfit'>): number {
if (a.personalProfit === null && b.personalProfit === null) return a.id - b.id;
if (a.personalProfit === null) return 1;
if (b.personalProfit === null) return -1;
return b.personalProfit - a.personalProfit || a.id - b.id;
}
export function toRankingRow(
id: number,
meta: { name: string; icon: string | null; subtype: string | null },
summary: { netSell: number | null; totalCraftCost: number | null },
need: NeedTotals,
remainingCost: number,
owned: ReadonlyMap<number, number>,
): RankingRow {
const marketCost = needRows(need).total;
const netSell = summary.netSell;
const myCost = remainingCost;
const personalProfit = netSell === null ? null : netSell - myCost;
const gatedInputs = [...need.items.values()]
.map((item, i) => ({ item, id: [...need.items.keys()][i] }))
.filter(({ item }) => item.gated)
.map(({ item, id: gid }) => {
const needed = Math.ceil(item.qty);
const have = owned.get(gid) ?? 0;
return { id: gid, name: item.name, needed, have, satisfied: have >= needed };
});
return { id, name: meta.name, icon: meta.icon, subtype: meta.subtype,
netSell, marketCost, myCost, personalProfit,
gatedInputs, craftable: gatedInputs.every((g) => g.satisfied) };
}(Iterate need.items entries directly with for (const [gid, item] of need.items) in the real code — the .map/.filter above is illustrative; use the entry form to avoid the index dance.)
[ ] Step 4 — Run green. → PASS.
[ ] Step 5 — Commit.
api: add RankingService pure helpers (sort, toRankingRow, union).
T8 — RankingService.rank orchestration + module wiring
Satisfies: R1, R4, R7, SC1–SC4.
Files: Modify apps/api/src/legendaries/ranking.service.ts, apps/api/src/legendaries/legendaries.module.ts; Test apps/api/src/legendaries/__tests__/ranking.service.test.ts.
- [ ] Step 1 — Failing test with stubbed
RecipeGraphService/Gw2Service/AccountService. Two fake Gen-1 graphs, a fixed price map, an owned map; assert order,myCost ≤ marketCost, oneprices()call, market invariant:
import { describe, expect, it, vi } from 'vitest';
import { RankingService } from '../ranking.service';
// Build two minimal ResolvedGraphs whose priceGraph yields known summaries; see recipe-graph.service.bifrost.test.ts for graph fixture shape.
it('ranks by personalProfit, prices in ONE batch, myCost ≤ marketCost', async () => {
const prices = vi.fn().mockResolvedValue([/* price rows for the union */]);
const recipeGraph = { resolve: vi.fn().mockImplementation((id) => fakeGraph(id)) };
const account = { getOwnedItems: vi.fn().mockResolvedValue(new Map()) };
const svc = new RankingService(recipeGraph as never, { prices } as never, account as never);
const rows = await svc.rank('key');
expect(prices).toHaveBeenCalledTimes(1); // SC4
expect(rows.map((r) => r.personalProfit)).toEqual([...rows].map((r) => r.personalProfit).sort((a, b) => (b ?? -Infinity) - (a ?? -Infinity)));
for (const r of rows) if (r.myCost !== null && r.marketCost !== null) expect(r.myCost).toBeLessThanOrEqual(r.marketCost);
});(Reuse the graph-fixture helper style from recipe-graph.service.bifrost.test.ts; keep two ids so ordering is observable. If constructing real graphs is heavy, stub priceGraph is not an option — the invariant marketCost == summary.totalCraftCost must exercise the real fold; use a small 2-node buyable graph.)
[ ] Step 2 — Run red. → FAIL (
svc.ranknot a function).[ ] Step 3 — Implement
rankon theRankingServiceclass:
@Injectable()
export class RankingService {
constructor(
private readonly recipeGraph: RecipeGraphService,
private readonly gw2: Gw2Service,
private readonly account: AccountService,
) {}
async rank(apiKey: string): Promise<RankingRow[]> {
const graphs = await Promise.all(GEN1_IDS.map((id) => this.recipeGraph.resolve(id)));
const priced = await this.gw2.prices(unionBuyableIds(graphs));
const priceMap: PriceMap = new Map(priced.map((p) => [p.id, { buys: p.buys, sells: p.sells }]));
const owned = await this.account.getOwnedItems(apiKey);
const rows = graphs.map((graph, i) => {
const root = priceGraph(graph, priceMap).root;
const need = aggregateNeed(root);
const { remainingCost } = mergeOwnVsNeed(need, owned, []);
const meta = legendaryMeta(GEN1_IDS[i], root); // name/icon/subtype from root.node + the metadata list
return toRankingRow(GEN1_IDS[i], meta, root.summary, need, remainingCost, owned);
});
return rows.sort(byPersonalProfitDescIdAsc);
}
}Resolve meta (name/icon/subtype) from the existing legendaries metadata (LegendariesService.list / the hydrated list) so identity matches the catalog; if the priced root.node already carries name, use it for name and pull icon/subtype from the metadata list. Import RecipeGraphService, Gw2Service, AccountService as value imports (Nest DI) with the same biome-ignore note the other controllers use.
[ ] Step 4 — Wire the module. In
legendaries.module.tsaddRankingServicetoproviders, and importRecipeGraphModule,Gw2Module,AccountModule(whichever export the three services). Add alegendaries.module.test.tsassertion thatRankingServiceresolves from the compiled module.[ ] Step 5 — Run green + types.
Run: pnpm vitest run apps/api/src/legendaries/__tests__/ranking.service.test.ts && pnpm --filter @gw2priory/api typecheck Expected: PASS.
- [ ] Step 6 — Commit.
api: RankingService.rank orchestrates the 21-legendary ranking.
T9 — Controller endpoint + OpenAPI/Orval regen
Satisfies: R2, R9, SC6.
Files: Modify apps/api/src/legendaries/legendaries.controller.ts; Test apps/api/src/legendaries/__tests__/legendaries.controller.test.ts; regenerated apps/web/src/api/generated/** + apps/api OpenAPI artifact.
- [ ] Step 1 — Failing controller test. Stub
RankingService; assert400on missing header,403+scope onGw2ForbiddenError,200list on success:
it('400 when Authorization is missing', async () => {
await expect(controller.ranking(undefined)).rejects.toBeInstanceOf(BadRequestException);
});
it('maps Gw2ForbiddenError(characters) to 403 naming the scope', async () => {
ranking.rank.mockRejectedValue(new Gw2ForbiddenError('characters'));
await expect(controller.ranking('Bearer k')).rejects.toMatchObject({ status: 403 });
});[ ] Step 2 — Run red. → FAIL (
controller.rankingundefined).[ ] Step 3 — Implement. Add to
legendaries.controller.ts: injectRankingService(value import), add the two private helpersrequireBearer+mapGw2Errorcopied verbatim fromaccount.controller.ts, and:
@Get('ranking')
@ZodResponse({ status: 200, type: RankingListDto })
async ranking(@Headers('authorization') authorization?: string): Promise<RankingRow[]> {
const key = this.requireBearer(authorization);
try {
return await this.ranking.rank(key);
} catch (e) {
this.mapGw2Error(e);
}
}Ensure @Get('ranking') is declared before any future @Get(':id') (none exists today).
[ ] Step 4 — Run green. → PASS. Then
pnpm --filter @gw2priory/api typecheck.[ ] Step 5 — Regenerate the contract. Run the OpenAPI export then Orval (exact scripts per the header note). Verify
apps/web/src/api/generated/endpoints/legendaries/*now exportsgetLegendariesControllerRankingQueryOptionsandLegendariesControllerRankingResponse(Zod). Commit the regenerated files with the endpoint.[ ] Step 6 — Commit.
git add apps/api/src/legendaries apps/web/src/api/generated apps/api/openapi.* 2>/dev/null
git commit -m "api: add authenticated GET /legendaries/ranking + regen client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"T10 — useLegendaryRanking web hook
Satisfies: R11/R12 (data).
Files: Create apps/web/src/api/useLegendaryRanking.ts; Modify apps/web/src/api/index.ts; Test apps/web/src/api/__tests__/useLegendaryRanking.test.tsx.
[ ] Step 1 — Failing test mirroring
useMaterials.test.tsx(msw + Suspense wrapper): serve/api/legendaries/rankinga one-row list, assert the hook returns the parsed row and sends theAuthorizationheader.[ ] Step 2 — Run red. → FAIL (module missing).
[ ] Step 3 — Implement, mirroring
useMaterials.ts:
import { useSuspenseQuery } from '@tanstack/react-query';
import type { z } from 'zod';
import { hashKey } from '../shared/lib/hashKey';
import { getLegendariesControllerRankingQueryOptions } from './generated/endpoints/legendaries/legendaries';
import { LegendariesControllerRankingResponse } from './generated/endpoints/legendaries/legendaries.zod';
import { suspenseOptions } from './suspenseOptions';
export type RankingRow = z.infer<typeof LegendariesControllerRankingResponse>[number];
export function useLegendaryRanking(apiKey: string): RankingRow[] {
const query = useSuspenseQuery(
suspenseOptions(
getLegendariesControllerRankingQueryOptions({
request: { headers: { Authorization: `Bearer ${apiKey}` } },
query: { queryKey: ['/api/legendaries/ranking', hashKey(apiKey)] as const },
}),
),
);
return LegendariesControllerRankingResponse.parse(query.data.data);
}Re-export from api/index.ts. (Confirm the generated symbol names from T9's output; adjust the import paths to match.)
[ ] Step 4 — Run green. → PASS.
[ ] Step 5 — Commit.
web: add useLegendaryRanking suspense hook.
T11 — RankingTable component
Satisfies: R13, SC1, P2 badges.
Files: Create apps/web/src/features/legendaries/RankingTable.tsx; Modify apps/web/src/features/legendaries/styles.ts; Test apps/web/src/features/legendaries/__tests__/RankingTable.test.tsx.
- [ ] Step 1 — Failing test (stub
useLegendaryRanking): renders rows in given order; showsmyCostandpersonalProfitviaCoins; renders a ✗ badge for an unsatisfied gated input and a craftable indicator when all satisfied.
vi.mock('../../../api', () => ({ useLegendaryRanking: () => FIXTURE_ROWS }));
it('renders rows with coins costs, gated badges and craftable state', () => {
render(<RankingTable apiKey="k" />, { wrapper });
expect(screen.getByText('Twilight')).toBeInTheDocument();
expect(screen.getByLabelText(/Gift of Battle.*missing/i)).toBeInTheDocument();
});[ ] Step 2 — Run red. → FAIL (module missing).
[ ] Step 3 — Implement
RankingTable(suspends onuseLegendaryRanking(apiKey)), rendering a table: icon/name/subtype,marketCost → myCostandpersonalProfitthrough the existingCoinscomponent, gated-input badges (have/✗ with an accessible label), and a craftable indicator. Add colocatedcvas instyles.tsusing existing tokens only (card,border,muted,primary,rarity.*).[ ] Step 4 — Run green + build gate (React Compiler):
pnpm vitest run <file> && pnpm --filter web build.[ ] Step 5 — Commit.
web: add RankingTable.
T12 — Layout + tabs + routes (precedence)
Satisfies: R11.
Files: Create apps/web/src/features/legendaries/LegendariesLayout.tsx; Modify apps/web/src/features/legendaries/routes.tsx; Test apps/web/src/features/legendaries/__tests__/routes.test.tsx (extend).
- [ ] Step 1 — Failing test:
/legendaries/rankingrendersRankingPage, not the:iddetail;/legendariesrenders the catalog under the tab layout.
it('T12/R11: /legendaries/ranking resolves the ranking page, not the :id detail', () => {
renderRoute('/legendaries/ranking');
expect(screen.getByRole('tab', { name: /profit ranking/i })).toBeInTheDocument();
expect(screen.queryByTestId('legendary-detail')).not.toBeInTheDocument();
});[ ] Step 2 — Run red. → FAIL.
[ ] Step 3 — Implement
LegendariesLayout(tabNavLinks to/legendariesand/legendaries/ranking+<Outlet/>), and restructureroutes.tsx:
export const routes: RouteObject[] = [
{ path: '/legendaries', element: <LegendariesLayout />, children: [
{ index: true, element: <LegendariesPage /> },
{ path: 'ranking', element: <RankingPage /> },
] },
{ path: '/legendaries/:id', element: <LegendaryDetailPage /> },
];[ ] Step 4 — Run green. → PASS.
[ ] Step 5 — Commit.
web: add Browse/Ranking tabs + ranking route.
T13 — RankingPage key gate + missing-scope prompt
Satisfies: R12, P2 #5, SC7.
Files: Create apps/web/src/features/legendaries/RankingPage.tsx, apps/web/src/features/legendaries/ReconnectScopesPrompt.tsx; Test apps/web/src/features/legendaries/__tests__/RankingPage.test.tsx.
[ ] Step 1 — Failing tests: no key →
ConnectAccountPromptand no ranking request (spy on the hook/fetch); a thrown 403-missing-scope error →ReconnectScopesPrompt.[ ] Step 2 — Run red. → FAIL.
[ ] Step 3 — Implement
RankingPage:useApiKey()null →ConnectAccountPrompt(noRankingTablemounted, so no request). Else renderRankingTableinside a boundary that catches the query error and, when it is a403whose message names a scope, rendersReconnectScopesPrompt(message: reconnect a key withinventories+characters); otherwise re-throws to the shell QueryBoundary.ReconnectScopesPromptreuses theConnectAccountPromptvisual shell.[ ] Step 4 — Run green + build.
pnpm vitest run <file> && pnpm --filter web build.[ ] Step 5 — Commit.
web: RankingPage key gate + reconnect-scopes prompt.
T14 — Verify + traceability + status
Satisfies: R14, SC9; closes the spec.
Files: Modify specs/023-profit/spec.md (traceability table + status).
- [ ] Step 1 — Full gates.
Run: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build Expected: all green. Fix any failure via superpowers:systematic-debugging before proceeding.
[ ] Step 2 — Fill the traceability table in
spec.md: map each P1/P2 scenario and SC1–SC9 to the concrete test id created above (e.g.SC4 → ranking.service.test.ts "prices in ONE batch"). Every row filled; no blanks.[ ] Step 3 — Union-size observation. Add a dated one-line note (in the PR description or
research.mdF-section) recording the actualunionBuyableIds(...)length observed against live data — the SC4 count that the spec deliberately did not gate on.[ ] Step 4 — Live sourcing check. With a real fully-scoped key, hit
GET /legendaries/rankingonce; confirm a Gift of Exploration/Battle held in bank or a character surfaces ashave ≥ 1on the rows that need it. Record the dated result (research V1 caveat / plan Open questions).[ ] Step 5 — Status → implemented. On the human's instruction, transcribe
Status: implementedintospec.md(inside this branch, part of the PR diff — per Definition of done). Commit.
git add specs/023-profit/spec.md
git commit -m "specs: 023 traceability complete; mark implemented
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"- [ ] Step 6 — Request review (
superpowers:requesting-code-review) and open the PR once green.