Skip to content

Plan 023 — Legendary profit ranking (personalized) ​

Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.

Produced alone — tasks.md stays untouched until this plan is approved in turn.

Goal ​

Add an authenticated GET /legendaries/ranking that ranks the 21 Gen-1 legendaries by the player's personal craft-and-sell profit — market cost reduced by everything they already own (summed across material storage, bank, shared inventory, and every character's inventory) — and annotates each row with its account-bound gated inputs (have/satisfied) and whether it is craftable. A new /legendaries/ranking web page renders that as a table under a Browse ⇄ Profit-ranking tab. The profit maths already exists (priceGraph → PlanSummary); this feature fans it across 21 roots, personalises it against a four-source owned-items map, and lifts the own-vs-need logic into a shared package so the api endpoint and the existing detail page use one implementation.

Approach ​

One backend orchestrator over the existing engine. A new RankingService (in the legendaries module) resolves each of the 21 Gen-1 graphs (cached RecipeGraphService.resolve, price-free), unions their distinct buyable ids via collectPricedIds, issues one gw2.prices() batch (the client chunks ≤199), and prices each graph with priceGraph(graph, priceMap). For personalisation it fetches the caller's owned-items map once — AccountService.getOwnedItems(key) summing /account/materials + /account/bank + /account/inventory (shared) + /characters/:id/inventory for every character from /characters — then, per legendary, runs the lifted aggregateNeed(pricedRoot) and mergeOwnVsNeed(need, owned) to get marketCost, myCost (= remainingCost), and the gated frontier. netSell comes from the priced root's summary. Rows sort by personalProfit desc, id asc tiebreak, null last.

Auth mirrors the account controller exactly: requireBearer (400 on missing/blank; never logs the token) and mapGw2Error, which already turns a missing-scope GW2 403 into ForbiddenException("key missing the <scope> scope") — so a key without inventories/characters surfaces as a clean 403 naming the scope, with no partial ranking.

The own-vs-need logic is lifted from apps/web into @gw2priory/recipe-graph: aggregateNeed (retargeted to the package's PricedTreeNode) and a generalised mergeOwnVsNeed that takes a unified owned-items Map<number, number> instead of a raw materials array. The web detail page (021) migrates to the shared functions, building its owned map from materials exactly as today — behaviour-preserving, its tests unchanged.

Frontend is Suspense composition. A key-gated useLegendaryRanking(apiKey) suspense hook (mirroring useMaterials: Authorization: Bearer header, hashKey(apiKey) in the query key, response re-parsed with the generated RankingList Zod schema) feeds a RankingTable. A layout route renders the Browse ⇄ Ranking tabs around the catalog and the ranking; the shell's Suspense + QueryBoundary own load and error, with a no-key ConnectAccountPrompt and a 403-missing-scope reconnect prompt.

Architecture ​

RankingService is the only new orchestration; every dependency it calls already exists or is a thin new read. Data flow:

GET /legendaries/ranking  (LegendariesController.ranking; requireBearer → 400; mapGw2Error → 401/403)
└─ RankingService.rank(apiKey)
   ├─ graphs   = await Promise.all(GEN1_IDS.map(id => recipeGraph.resolve(id)))   [cached, price-free]
   ├─ priceMap = new Map( (await gw2.prices( unionOf(graphs, collectPricedIds) )).map(…) )   [ONE batch]
   ├─ owned    = await account.getOwnedItems(apiKey)     Map<itemId, count>
   │              = Σ counts over materials + bank + sharedInventory + Σ characterInventories
   └─ rows = GEN1_IDS.map(id => {
        const priced = priceGraph(graphs[id], priceMap)        // → .root.summary { netSell, totalCraftCost }
        const need   = aggregateNeed(priced.root)              // package (lifted)
        const { rows, remainingCost } = mergeOwnVsNeed(need, owned)   // package (generalised)
        return toRankingRow(id, meta, priced.root.summary, need, remainingCost, owned)
      }).sort(byPersonalProfitDescIdAsc)

AccountService.getOwnedItems(key) ─▶ Map<number,number>   (new; sums four sources)
Gw2Service/Gw2Client (new reads):  accountBank · accountSharedInventory · characters · characterInventory

Web:

/legendaries                 LegendariesLayout  (tabs: Browse ⇄ Profit ranking) + <Outlet/>
  ├─ index                   LegendariesPage      (catalog — unchanged)
  └─ /ranking                RankingPage          key-gated: no key → ConnectAccountPrompt
                             └─ RankingTable       useLegendaryRanking(apiKey) [suspends]
                                                   rows: identity · marketCost→myCost · personalProfit
                                                         · gated badges (have/✓✗) · craftable
/legendaries/:id             LegendaryDetailPage  (unchanged; now imports own-vs-need from the package)

Tech stack ​

NestJS 11 + nestjs-zod (@ZodResponse Zod-first contract → OpenAPI → Orval), Zod 4, the existing Gw2Client/RecipeGraphService/priceGraph engine (apps/api); React 19, react-router 8, @tanstack/react-query 5 (suspense), Panda CSS, the Orval-generated client (apps/web); @gw2priory/recipe-graph + @gw2priory/domain shared packages; Vitest + Testing Library + jsdom. No new dependencies — the engine, the account/pricing reads, Coins, useApiKey, ConnectAccountPrompt, and the rarity/card/border/muted/primary tokens all already ship.

Global Constraints ​

Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.

From docs/architecture/typescript.md:

  • No any. Not in app code, not in tests. Use unknown plus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why.
  • No non-null assertions (!) to silence the compiler.
  • No @ts-expect-error without a comment explaining what is expected and when it can be removed.
  • Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
  • Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
  • Match the style of surrounding code. No new dependency without justification in the spec or plan.
  • moduleResolution: "node" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.md's api build model.

From docs/architecture/stack.md:

  • Monorepo, pnpm workspaces.
  • apps/api — NestJS (TypeScript).
  • apps/web — React (TypeScript).
  • packages/* — shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.
  • Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
  • Vitest everywhere, both apps.
  • Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
  • Static data (items, station recipes) is immutable: cache hard.
  • Prices are volatile: short TTL, recomputed live.
  • GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec, 429 on overflow. Batch up to 200 ids per ?ids= call.
  • All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
  • API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's localStorage — and sent per request as Authorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-side localStorage is plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).

From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.

File Structure ​

Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.

Shared package — the lift (R10) ​

PathChangeResponsibility
packages/recipe-graph/src/aggregate-need.tsnewPure aggregateNeed(root: PricedTreeNode): NeedTotals — the decided-tree, outputCount-aware multiplier walk over item children + recipe.ingredients currencies, plus the gated frontier tally. Moved from apps/web, retargeted to the package's PricedTreeNode. React-free.
packages/recipe-graph/src/own-vs-need.tsnewPure mergeOwnVsNeed(need: NeedTotals, owned: OwnedItems): OwnVsNeed and needRows(need): { rows; total } — ceil totals, subtract owned once (short = max(0, ceil(need) − owned)), remainingCost. owned is a Map<number, number> (id → summed count), not a materials array.
packages/recipe-graph/src/index.tsmodifyexport * from './aggregate-need.ts' and './own-vs-need.ts' alongside the existing types.ts.
packages/recipe-graph/src/__tests__/aggregate-need.test.tsnewMoved/adapted from the web aggregateNeed.test.ts (multiplier walk, fractional yield, duplicate leaf, currencies, gated tally).
packages/recipe-graph/src/__tests__/own-vs-need.test.tsnewMoved/adapted from the web mergeOwnVsNeed.test.ts, plus the owned-map generalisation (owned summed from a Map, owned-once, untracked → full need).

Web migration to the package (R10, behaviour-preserving) ​

PathChangeResponsibility
apps/web/src/features/legendaries/aggregateNeed.tsdeleteRe-exported from the package instead.
apps/web/src/features/legendaries/mergeOwnVsNeed.tsdeleteRe-exported from the package instead.
apps/web/src/features/legendaries/OwnVsNeedProvider.tsxmodifyImport aggregateNeed/mergeOwnVsNeed from @gw2priory/recipe-graph; build the owned map from useMaterials() (new Map(materials.map(m => [m.id, m.count]))) and keep the wallet-driven currency rows as today — behaviour unchanged.
apps/web/src/features/legendaries/__tests__/aggregateNeed.test.tsdeleteCoverage moves to the package test.
apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.tsdeleteCoverage moves to the package test.

Backend — new GW2 reads (R8) ​

PathChangeResponsibility
apps/api/src/gw2/gw2.schemas.tsmodifyAdd Gw2ItemSlotSchema ({ id: number, count: number }, nullable slot), Gw2CharacterInventorySchema ({ bags: (…|null)[] } with inventory: (Gw2ItemSlot|null)[]), and a character-name array schema.
apps/api/src/gw2/gw2-client.tsmodifyFour reads via the existing authedCachedRead helper: accountBank(key) (/account/bank, scope inventories), accountSharedInventory(key) (/account/inventory, inventories), characters(key) (/characters, characters), characterInventory(key, name) (/characters/{name}/inventory, inventories).
apps/api/src/gw2/gw2.service.tsmodifyPassthrough wrappers for the four new client reads (thin, as for accountMaterials).

Backend — owned-items aggregation (R4) ​

PathChangeResponsibility
apps/api/src/account/account.service.tsmodifygetOwnedItems(apiKey): Promise<Map<number, number>> — fetch materials + bank + shared inventory, then characters → characterInventory per name; fold every non-null slot's count into one id→count map. Skips null slots.

Backend — the ranking endpoint (R1–R7, R9) ​

PathChangeResponsibility
apps/api/src/legendaries/ranking.schema.tsnewZod GatedInput, RankingRow, RankingList; class RankingListDto extends createZodDto(RankingList). Source of truth for @ZodResponse → OpenAPI → Orval.
apps/api/src/legendaries/ranking.service.tsnewRankingService.rank(apiKey): Promise<RankingRow[]> — the orchestrator in Architecture; injects RecipeGraphService, Gw2Service, AccountService. Pure helpers unionBuyableIds(graphs), toRankingRow(...), byPersonalProfitDescIdAsc.
apps/api/src/legendaries/legendaries.controller.tsmodifyAdd @Get('ranking') @ZodResponse({ status: 200, type: RankingListDto }); requireBearer(authorization) + mapGw2Error (same private helpers as account.controller — extract to a shared mixin? no: copy the two tiny methods, matching the existing per-controller pattern). Registered before any future :id; no param route exists today.
apps/api/src/legendaries/legendaries.module.tsmodifyProvide RankingService; import RecipeGraphModule, Gw2Module, AccountModule (whichever expose RecipeGraphService/Gw2Service/AccountService).
apps/api/src/legendaries/legendaries.data.tsmodifyExport GEN1_IDS: readonly number[] (the 21 ids 30684–30704) derived from the existing LEGENDARY_IDS (generation === 1), so the ranking and any test share one source.

Frontend — the ranking page (R11–R13) ​

PathChangeResponsibility
apps/web/src/api/useLegendaryRanking.tsnewSuspense hook over getLegendariesControllerRankingQueryOptions({ request: { headers: { Authorization: \Bearer ${apiKey}` } }, query: { queryKey: ['/api/legendaries/ranking', hashKey(apiKey)] } }); response re-parsed with the generated LegendariesControllerRankingResponseZod schema. ExposesRankingRow[]and aRankingRow` type.
apps/web/src/api/index.tsmodifyRe-export useLegendaryRanking + RankingRow.
apps/web/src/features/legendaries/LegendariesLayout.tsxnewLayout route: the Browse ⇄ Profit-ranking tab header (NavLinks to /legendaries and /legendaries/ranking) + <Outlet/>.
apps/web/src/features/legendaries/RankingPage.tsxnewRouted /legendaries/ranking. Key gate: useApiKey() null → ConnectAccountPrompt; else render RankingTable. A boundary maps a 403-missing-scope error to a reconnect-with-scopes prompt.
apps/web/src/features/legendaries/RankingTable.tsxnewSuspends on useLegendaryRanking(apiKey); renders the sorted rows — identity (icon/name/subtype), marketCost → myCost (both via Coins), personalProfit (via Coins), per-gated-input have/missing badges, a craftable indicator.
apps/web/src/features/legendaries/ReconnectScopesPrompt.tsxnewThe missing-scope message ("reconnect a key with inventories + characters"), reusing the ConnectAccountPrompt visual shell.
apps/web/src/features/legendaries/routes.tsxmodifyNest /legendaries under LegendariesLayout with { index: true → LegendariesPage } and { path: 'ranking' → RankingPage }; keep /legendaries/:id a sibling. ranking out-ranks :id by static-over-dynamic specificity (asserted).
apps/web/src/features/legendaries/styles.tsmodifyColocated cvas for the tab header, the ranking table rows/cells, gated badges, and the craftable indicator — existing tokens only.

Data & contracts ​

New contract (Zod, ranking.schema.ts) — drives OpenAPI and the Orval client:

GatedInput = z.object({ id: z.number().int().positive(), name: z.string(),
                        needed: z.number().int().nonnegative(), have: z.number().int().nonnegative(),
                        satisfied: z.boolean() })
RankingRow = z.object({
  id: z.number().int().positive(), name: z.string(), icon: z.string().nullable(),
  subtype: z.string().nullable(),
  netSell: z.number().int().nullable(), marketCost: z.number().int().nullable(),
  myCost: z.number().int().nullable(), personalProfit: z.number().int().nullable(),
  gatedInputs: z.array(GatedInput), craftable: z.boolean(),
})
RankingList = z.array(RankingRow)

Owned map (internal, api + package): OwnedItems = Map<number, number> (item id → summed count over all four sources). mergeOwnVsNeed(need, owned) reads it; the web builds it from materials, the api from getOwnedItems.

Derivations (in toRankingRow, from the priced root + need + owned):

  • netSell = priced.root.summary.netSell.
  • marketCost = needRows(need).total (Σ ceil(need) × unitBuyPrice over buyable leaves). Invariant: equals priced.root.summary.totalCraftCost (the frontier is the chosen buyable decomposition) — asserted.
  • myCost = mergeOwnVsNeed(need, owned).remainingCost.
  • personalProfit = netSell === null || myCost === null ? null : netSell − myCost.
  • gatedInputs = each gated frontier item → { id, name, needed: ceil(qty), have: owned.get(id) ?? 0, satisfied: have ≥ needed }.
  • craftable = every gated input satisfied.

No change to the existing /recipe-graph/:itemId, /account/*, or /legendaries (list) contracts. The lifted aggregateNeed/mergeOwnVsNeed keep the same observable behaviour for the detail page.

Test strategy ​

  • Package pure functions (unit, no DOM): the moved aggregate-need / own-vs-need suites keep their cases (multiplier walk, fractional yield → expected, duplicate leaf merged, currencies, gated tally; ceil, owned-once, untracked → full need) and gain the owned-map cases (P1 #2/#3, P2, SC2/SC5/SC8).
  • getOwnedItems (unit, stubbed Gw2Service): sums the same id across materials + bank + shared inventory + two characters' bags into one count; null slots skipped; an id in only a character inventory still counted (P2 #1, R4, SC5).
  • RankingService (unit, stubbed RecipeGraphService/Gw2Service/AccountService): with two stubbed Gen-1 graphs + a fixed price map + a stubbed owned map — rows ordered by personalProfit desc with id-asc tiebreak and null last (P1 #1/#4, SC1); myCost ≤ marketCost, = marketCost when owned is empty (P1 #2/#3, SC2); personalProfit = netSell − myCost (P1 #2, SC3); one gw2.prices() call over the union via a call spy (P1 #5, SC4); gated have/satisfied and craftable from the owned map (P2 #1/#2/#3, SC5); craftable never reorders (P2 #4). Asserts the marketCost == summary.totalCraftCost invariant.
  • LegendariesController.ranking (Nest e2e-lite, stubbed RankingService/errors): missing/blank Authorization → 400, no token logged (P1 #6, SC6); a Gw2ForbiddenError('characters') from the service → 403 "key missing the characters scope" (P2 #5, SC6); happy path → 200 RankingList.
  • Web (Testing Library, stubbed hooks): RankingTable renders the ordered rows with Coins costs, gated have/missing badges, craftable indicator (SC1, P2); RankingPage no-key → ConnectAccountPrompt and no ranking request (spy) (SC7, P1 #6); a 403-missing-scope error → ReconnectScopesPrompt (P2 #5, SC7). routes.test.tsx — /legendaries/ranking resolves RankingPage, not the :id detail (R11).
  • Migration safety (SC8): the existing 021 detail-page suite (LegendaryDetailView, LegendarySummary, ShoppingList, OwnVsNeedProvider, …) runs unchanged and green after the lift.
  • Invariants (SC9): conventions/tokens guards stay green (no new tokens; costs via Coins); the docs/superpowers count-stays-zero guard holds; pnpm typecheck/lint/test/build all green.
  • Not directly tested: live GW2 bank/inventory/character responses (auth-only — a dated manual check against a real key during implementation); the exact buyable-id union size (a one-off logged observation, not a gate — SC4 asserts the single-call path, not a count).

Alternatives considered ​

  • Split account sourcing into a prerequisite spec 024 — the four-source owned map is a reusable account capability. Kept in 023 per the human's "expand sourcing in this spec" decision; isolated as getOwnedItems + four client reads so it stays independently testable and easy to extract later.
  • Compute the ranking client-side (21 tree fetches + the web own-vs-need per row) — rejected in the spec: heavier client, 21 round-trips, and the engine output would not be golden-testable server-side.
  • Graceful degradation on partial scopes (compute an "unknown-have" ranking from whatever scopes are present) — rejected (spec Out-of-scope): it reintroduces the misleading annotation discovery flagged; a missing scope is a 403 + reconnect prompt.
  • Duplicate own-vs-need in apps/api instead of lifting to the package — rejected: two implementations of one rule drift; two consumers (web detail + api ranking) is exactly when a shared package is earned.
  • A tokeninfo pre-check for scopes — rejected as unnecessary: the reactive 403 → Gw2ForbiddenError(<scope>) path already names the missing scope (research F2); a probe would add a call for no behavioural gain.

Risks ​

  • Type reconciliation for the lift. aggregateNeed moves from the web's Orval-generated RecipeTree to the package's PricedTreeNode; if the generated shape has drifted, structural typing breaks. Mitigation: the generated type derives from the same Zod schema as PricedTreeNode (research V3); the migration task typechecks the detail page against the package function before deleting the web copies, and if a field mismatches, aggregateNeed takes the narrow read-only interface it actually uses. The 021 suite running unchanged (SC8) is the backstop.
  • Character-inventory fan-out cost/latency. N characters → N /characters/:id/inventory calls per cold ranking. Mitigation: each read is cached per-user (ACCOUNT_TTL) like materials; well inside 300/min for a typical account; the calls are independent (Promise.all). Logged if it ever nears the budget.
  • Route precedence /legendaries/ranking vs /legendaries/:id. Mitigation: ranking is a static child of the layout route; static-over-dynamic specificity resolves it, asserted by routes.test.tsx.
  • marketCost vs summary.totalCraftCost drift. If the frontier sum and the engine roll-up disagree, personalProfit-with-empty-owned wouldn't equal market profit. Mitigation: the invariant is asserted in the RankingService test; a mismatch fails fast rather than shipping two profit definitions.

Open questions ​

  • Live bank/inventory/character response fidelity — reality answers during implementation (auth-only endpoints): a dated manual check against a real key, recorded, not a code gate.
  • Whether to also widen the detail page's owned sourcing to the four-source map — deliberately not in this plan (spec Out-of-scope); the lift keeps the detail page materials-only and behaviour-identical. A follow-up may align them.