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 · characterInventoryWeb:
/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. Useunknownplus 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-errorwithout 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"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.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,
429on 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 asAuthorization: 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-sidelocalStorageis 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)
| Path | Change | Responsibility |
|---|---|---|
packages/recipe-graph/src/aggregate-need.ts | new | Pure 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.ts | new | Pure 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.ts | modify | export * from './aggregate-need.ts' and './own-vs-need.ts' alongside the existing types.ts. |
packages/recipe-graph/src/__tests__/aggregate-need.test.ts | new | Moved/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.ts | new | Moved/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)
| Path | Change | Responsibility |
|---|---|---|
apps/web/src/features/legendaries/aggregateNeed.ts | delete | Re-exported from the package instead. |
apps/web/src/features/legendaries/mergeOwnVsNeed.ts | delete | Re-exported from the package instead. |
apps/web/src/features/legendaries/OwnVsNeedProvider.tsx | modify | Import 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.ts | delete | Coverage moves to the package test. |
apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts | delete | Coverage moves to the package test. |
Backend — new GW2 reads (R8)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/gw2/gw2.schemas.ts | modify | Add 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.ts | modify | Four 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.ts | modify | Passthrough wrappers for the four new client reads (thin, as for accountMaterials). |
Backend — owned-items aggregation (R4)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/account/account.service.ts | modify | getOwnedItems(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)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/legendaries/ranking.schema.ts | new | Zod GatedInput, RankingRow, RankingList; class RankingListDto extends createZodDto(RankingList). Source of truth for @ZodResponse → OpenAPI → Orval. |
apps/api/src/legendaries/ranking.service.ts | new | RankingService.rank(apiKey): Promise<RankingRow[]> — the orchestrator in Architecture; injects RecipeGraphService, Gw2Service, AccountService. Pure helpers unionBuyableIds(graphs), toRankingRow(...), byPersonalProfitDescIdAsc. |
apps/api/src/legendaries/legendaries.controller.ts | modify | Add @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.ts | modify | Provide RankingService; import RecipeGraphModule, Gw2Module, AccountModule (whichever expose RecipeGraphService/Gw2Service/AccountService). |
apps/api/src/legendaries/legendaries.data.ts | modify | Export 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)
| Path | Change | Responsibility |
|---|---|---|
apps/web/src/api/useLegendaryRanking.ts | new | Suspense 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.ts | modify | Re-export useLegendaryRanking + RankingRow. |
apps/web/src/features/legendaries/LegendariesLayout.tsx | new | Layout route: the Browse ⇄ Profit-ranking tab header (NavLinks to /legendaries and /legendaries/ranking) + <Outlet/>. |
apps/web/src/features/legendaries/RankingPage.tsx | new | Routed /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.tsx | new | Suspends 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.tsx | new | The missing-scope message ("reconnect a key with inventories + characters"), reusing the ConnectAccountPrompt visual shell. |
apps/web/src/features/legendaries/routes.tsx | modify | Nest /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.ts | modify | Colocated 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) × unitBuyPriceover buyable leaves). Invariant: equalspriced.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 inputsatisfied.
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-needsuites 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, stubbedGw2Service): sums the same id across materials + bank + shared inventory + two characters' bags into one count;nullslots skipped; an id in only a character inventory still counted (P2 #1, R4, SC5).RankingService(unit, stubbedRecipeGraphService/Gw2Service/AccountService): with two stubbed Gen-1 graphs + a fixed price map + a stubbed owned map — rows ordered bypersonalProfitdesc withid-asc tiebreak andnulllast (P1 #1/#4, SC1);myCost ≤ marketCost,= marketCostwhen owned is empty (P1 #2/#3, SC2);personalProfit = netSell − myCost(P1 #2, SC3); onegw2.prices()call over the union via a call spy (P1 #5, SC4); gatedhave/satisfiedandcraftablefrom the owned map (P2 #1/#2/#3, SC5);craftablenever reorders (P2 #4). Asserts themarketCost == summary.totalCraftCostinvariant.LegendariesController.ranking(Nest e2e-lite, stubbedRankingService/errors): missing/blankAuthorization→400, no token logged (P1 #6, SC6); aGw2ForbiddenError('characters')from the service →403"key missing the characters scope" (P2 #5, SC6); happy path →200RankingList.- Web (Testing Library, stubbed hooks):
RankingTablerenders the ordered rows withCoinscosts, gated have/missing badges, craftable indicator (SC1, P2);RankingPageno-key →ConnectAccountPromptand no ranking request (spy) (SC7, P1 #6); a 403-missing-scope error →ReconnectScopesPrompt(P2 #5, SC7).routes.test.tsx—/legendaries/rankingresolvesRankingPage, not the:iddetail (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 viaCoins); thedocs/superpowerscount-stays-zero guard holds;pnpm typecheck/lint/test/buildall 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
tokeninfopre-check for scopes — rejected as unnecessary: the reactive403 → 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.
aggregateNeedmoves from the web's Orval-generatedRecipeTreeto the package'sPricedTreeNode; if the generated shape has drifted, structural typing breaks. Mitigation: the generated type derives from the same Zod schema asPricedTreeNode(research V3); the migration task typechecks the detail page against the package function before deleting the web copies, and if a field mismatches,aggregateNeedtakes 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/inventorycalls 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/rankingvs/legendaries/:id. Mitigation:rankingis a static child of the layout route; static-over-dynamic specificity resolves it, asserted byroutes.test.tsx. marketCostvssummary.totalCraftCostdrift. 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 theRankingServicetest; 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.