Skip to content

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.ts to packages/recipe-graph/src/__tests__/aggregate-need.test.ts. Change its import to import { 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 fold 3.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.ts with the exact body of the current web aggregateNeed.ts, with one change: the input type. Replace import type { RecipeTree } from '../../api'; with import type { PricedTreeNode } from './types'; and change the signature to export function aggregateNeed(root: PricedTreeNode): NeedTotals. The walk reads only node.decision, node.recipe (outputCount, ingredients), node.children, node.node.{id,name,rarity}, node.unitBuyPrice — all present on PricedTreeNode. Export the NeedItem/NeedTotals interfaces and countGatedLeaves unchanged.

  • [ ] Step 4 — Export from the package barrel. In packages/recipe-graph/src/index.ts add export * from './aggregate-need'; after the existing export * 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.ts with:
ts
// 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.
bash
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 a Map and the currencies from a wallet array as before:
ts
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.ts from the current web mergeOwnVsNeed.ts, changing the owned source from a Material[] to a Map<number, number>:
ts
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'; to packages/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.ts with:
ts
// 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):

ts
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.
bash
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.ts mirroring the existing client test's fetchFn stub. Assert the four reads hit the right paths, parse slots, skip nulls, and map a 403 to Gw2ForbiddenError with the scope from the body:
ts
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.ts add:
ts
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.ts add three array reads via authedCachedRead and one object read mirroring account() + caching:
ts
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.ts add thin wrappers (matching the accountMaterials passthrough): accountBank, accountSharedInventory, characters, characterInventory each delegating to this.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.
bash
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 Gw2Service so the same id appears in different sources; assert one summed map, nulls skipped, a character-only id counted:
ts
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:
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.
bash
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.
ts
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_IDS undefined).

  • [ ] Step 3 — Implement. In legendaries.data.ts add, after LEGENDARY_IDS:

ts
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.

bash
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.
ts
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.ts exactly as the plan.md Data & contracts block specifies (GatedInput, RankingRow, RankingList, class RankingListDto extends createZodDto(RankingList)), plus export 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:
ts
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):

ts
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, one prices() call, market invariant:
ts
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.rank not a function).

  • [ ] Step 3 — Implement rank on the RankingService class:

ts
@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.ts add RankingService to providers, and import RecipeGraphModule, Gw2Module, AccountModule (whichever export the three services). Add a legendaries.module.test.ts assertion that RankingService resolves 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; assert 400 on missing header, 403+scope on Gw2ForbiddenError, 200 list on success:
ts
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.ranking undefined).

  • [ ] Step 3 — Implement. Add to legendaries.controller.ts: inject RankingService (value import), add the two private helpers requireBearer + mapGw2Error copied verbatim from account.controller.ts, and:

ts
@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 exports getLegendariesControllerRankingQueryOptions and LegendariesControllerRankingResponse (Zod). Commit the regenerated files with the endpoint.

  • [ ] Step 6 — Commit.

bash
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/ranking a one-row list, assert the hook returns the parsed row and sends the Authorization header.

  • [ ] Step 2 — Run red. → FAIL (module missing).

  • [ ] Step 3 — Implement, mirroring useMaterials.ts:

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; shows myCost and personalProfit via Coins; renders a ✗ badge for an unsatisfied gated input and a craftable indicator when all satisfied.
tsx
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 on useLegendaryRanking(apiKey)), rendering a table: icon/name/subtype, marketCost → myCost and personalProfit through the existing Coins component, gated-input badges (have/✗ with an accessible label), and a craftable indicator. Add colocated cvas in styles.ts using 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/ranking renders RankingPage, not the :id detail; /legendaries renders the catalog under the tab layout.
tsx
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 (tab NavLinks to /legendaries and /legendaries/ranking + <Outlet/>), and restructure routes.tsx:

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 → ConnectAccountPrompt and 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 (no RankingTable mounted, so no request). Else render RankingTable inside a boundary that catches the query error and, when it is a 403 whose message names a scope, renders ReconnectScopesPrompt (message: reconnect a key with inventories + characters); otherwise re-throws to the shell QueryBoundary. ReconnectScopesPrompt reuses the ConnectAccountPrompt visual 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.md F-section) recording the actual unionBuyableIds(...) 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/ranking once; confirm a Gift of Exploration/Battle held in bank or a character surfaces as have ≥ 1 on 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: implemented into spec.md (inside this branch, part of the PR diff — per Definition of done). Commit.

bash
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.