Plan 021 — [Feature name]
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
Every legendary card on the list currently links into a void; after this feature, opening /legendaries/:id lands on a working plan page. For a Gen-1 legendary it shows two goal summaries (Craft, Profit), the full buy-vs-craft dependency tree broken into gift cards, and an end-of-page shopping list — all with no account required — and, once a GW2 key is connected, it personalises: the shopping list gains have/short columns and a your-remaining-cost total, and the Profit goal's material cost becomes that remaining cost. It is built entirely on the web side from endpoints that already exist.
Approach
Frontend composition, no backend change. One new suspense hook, useRecipeTree(itemId), wraps the keyless GET /recipe-graph/:itemId (mirroring useLegendaries: useSuspenseQuery over the generated query options, response re-parsed with the generated RecipeGraphControllerGetResponse Zod schema). The header record comes from a useLegendary(id) selector over the existing useLegendaries() list — no new endpoint. Own-vs-need is two pure functions: aggregateNeed(tree) — the outputCount-aware multiplier walk over the decided tree that returns expected per-item and per-currency totals plus a gated-leaf tally — and mergeOwnVsNeed(need, materials, wallet) — which ceilings those totals, subtracts owned once, and yields per-leaf {need, have, short} rows plus remainingCost.
Personalisation lives behind a key gate: a child boundary mounted only when useApiKey() is non-null calls the suspending useMaterials/useWallet and computes mergeOwnVsNeed, then supplies the result (via context) to the Profit goal and the shopping list — so the account hooks are never called conditionally, and the not-connected path renders the shared ConnectAccountPrompt. Presentation reuses the spec-020 Coins component and existing tokens (rarity.*, card, border, muted, primary) — no new tokens. A non-Gen-1 legendary resolves to a root-only leaf (root.children.length === 0, research V-gen); the page detects that and renders header + buy price + a "Gen-1 only" note instead of a tree.
Architecture
LegendaryDetailPage (routed) owns the branch decisions — not-found (useRecipeTree 404) and the non-Gen-1 leaf case — and otherwise renders LegendaryDetailView, which suspends on useRecipeTree + useLegendary and lays out the single column. The pure functions have no React dependency and are tested in isolation. Data flow:
LegendaryDetailPage (route :id; not-found + non-Gen-1 branch)
└─ LegendaryDetailView useRecipeTree(id) ⊕ useLegendary(id) [suspends]
├─ <header> rarity name · Generation N · type/subtype/weight
├─ OwnVsNeedProvider ← key-gated: mounts useMaterials/useWallet,
│ computes mergeOwnVsNeed, provides via context
│ (absent key → ConnectAccountPrompt, provides null)
├─ LegendarySummary Craft goal (summary) + Profit goal (ctx.remainingCost ?? craftCost)
├─ LegendaryTree one card per top-level Gift → nested collapsible table
└─ ShoppingList buyable leaves + total; ctx present → have/short + your-remaining-cost
aggregateNeed(tree) ─▶ { items: Map, currencies: Map, gatedLeafIds: Set } (pure)
mergeOwnVsNeed(need, materials, wallet) ─▶ { rows: Row[], remainingCost } (pure)Tech stack
React 19, react-router 8, @tanstack/react-query 5 (suspense), Zod 4, Panda CSS, the Orval-generated client, Vitest + Testing Library + jsdom. No new dependencies — every piece (suspense hooks, the generated /recipe-graph client, Coins, useApiKey, ConnectAccountPrompt, the rarity/card/border/ muted/primary tokens) already ships in apps/web.
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. All web-side (apps/web/src); no apps/api change. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
api/useRecipeTree.ts | new | Suspense hook over getRecipeGraphControllerGetQueryOptions({ itemId }), response Zod-parsed with RecipeGraphControllerGetResponse; keyless. Exposes the parsed PricedTree root and a RecipeTree type. |
api/index.ts | modify | Re-export useRecipeTree + its RecipeTree type (barrel, as for the other hooks). |
features/legendaries/useLegendary.ts | new | useLegendary(id): Legendary | undefined — selector over useLegendaries() (find by id); no fetch of its own. |
features/legendaries/aggregateNeed.ts | new | Pure aggregateNeed(root): NeedTotals — the decided-tree outputCount multiplier walk over item children + recipe.ingredients currencies, plus the gated-leaf tally. React-free. |
features/legendaries/mergeOwnVsNeed.ts | new | Pure mergeOwnVsNeed(need, materials, wallet): OwnVsNeed — ceil totals, subtract owned once, {need,have,short} rows + remainingCost. React-free. |
features/legendaries/OwnVsNeedProvider.tsx | new | Key-gated boundary: mounts useMaterials/useWallet, computes mergeOwnVsNeed, provides OwnVsNeed | null via context; renders ConnectAccountPrompt when no key. |
features/legendaries/LegendarySummary.tsx | new | The two goal blocks (Craft + Profit), reading the root summary and (from context) the remaining cost. |
features/legendaries/LegendaryTree.tsx | new | One card per top-level Gift → indented collapsible table (rarity name + swatch, decision badge, ×count, line cost; gated badge-only; gift subtotal + "N not buyable"). |
features/legendaries/ShoppingList.tsx | new | End-of-page block: buyable leaves + total; when context present, have/short columns + your-remaining-cost. |
features/legendaries/LegendaryDetailView.tsx | new | Suspends on useRecipeTree + useLegendary; lays out header + OwnVsNeedProvider(summary, tree, shopping list). |
features/legendaries/LegendaryDetailPage.tsx | new | Routed component: branches not-found (404) and non-Gen-1 (root.children.length === 0) → header + buy price + "Gen-1 only" note; else LegendaryDetailView. |
features/legendaries/styles.ts | modify | Add colocated cvas: gift card, decision badges, tree guide-lines, goal stat, table, shopping-list rows. |
features/legendaries/routes.tsx | modify | Add { path: '/legendaries/:id', element: <LegendaryDetailPage /> }. |
features/legendaries/__tests__/* | new | Colocated tests (see Test strategy). |
Data & contracts
No API or OpenAPI change — the feature consumes the already-generated /recipe-graph/:itemId client (getRecipeGraphControllerGetQueryOptions, RecipeGraphControllerGetResponse — a recursive Zod schema) and the existing /account/materials, /account/wallet, /legendaries hooks. Web-only internal types introduced (not contracts):
RecipeTree = z.infer<typeof RecipeGraphControllerGetResponse>(the priced root; nodes carrynode,count,recipe(chosen, withoutputCount+ingredients),children,decision,unitBuyPrice,lineCost, and the root alsosummary).NeedTotals = { items: Map<number, number>; currencies: Map<number, number>; gatedLeafIds: Set<number> }(expected quantities, peraggregateNeed).OwnVsNeedRow = { kind: 'item' | 'currency'; id: number; name: string; need: number; have?: number; short: number; unitBuyPrice: number | null }andOwnVsNeed = { rows: OwnVsNeedRow[]; remainingCost: number }(permergeOwnVsNeed).
Test strategy
- Pure functions (unit, no DOM):
aggregateNeed— a fixture tree exercises the multiplier walk (per-edge counts × path multiplicity), the fractional-yield fold (a Mystic-Clover-shaped node,outputCount 3.1, → expected 74.5), a leaf appearing in two branches merged into one total, currency ingredients read fromrecipe.ingredients, and the gated-leaf tally.mergeOwnVsNeed— ceil (74.5 → 77), owned-once (own 250 / need 500 → short 250), untracked leaf (haveundefined, full need to cost), currency matched to wallet,remainingCost = Σ ceil(short) × unitBuyPrice. These carry SC8/SC9 and P2 #1–5. - Components (Testing Library, stubbed hooks): a shared small Twilight-shaped priced-tree fixture. Tests cover header (SC1), Craft goal incl. saves/costs label sign + gated count (SC2), Profit goal both connected and not (SC3), gift-card tree with collapse + gated badge-only + subtotal (SC4), shopping list visitor-vs-connected (SC5), the no-key path issuing no account request via a spy (SC6), not-found + non-Gen-1 note (SC7). Stub the data hooks the way
features/materials/accounttests already do. - Route (SC10): rendering the list and following a card
Linklands on the detail page. - Invariants (SC11): existing
conventions.test.ts/ tokens guard /docs/superpowerscount guard stay green; costs go throughCoins, so no colour literals are added. - Not directly tested: the live
404for a genuinely-unknown id (research V1 caveat) — a dated manual check during implementation stands in; the visual correctness of tree guide-lines — human review.
Alternatives considered
- A backend
/legendaries/:id/planaggregation endpoint — rejected: it forces the API key onto a currently-public endpoint (two-mode contract), where web composition keeps each query's natural auth posture; the server-side optimizer belongs to the later profit-ranking spec (spec Endpoints section). - The sketch's 3-way profit cost-basis switch — rejected for the MVP: folded to a single account-driven remaining cost (brainstorm), the opportunity-cost/farmed bases deferred.
- Showing expected-value fractional need (74.5 clovers) — rejected: ceil to a shopping integer is the chosen display (the expected value still drives the cost math).
- The sketch's legendary-purple accent / colour-dot coins — rejected: reuse the DS teal
primaryand the spec-020 coin icons; no new tokens (design-system.md).
Risks
- The multiplier walk is the one piece of real logic and easy to get subtly wrong (per-edge vs cumulative, dividing by
outputCount, currencies off-tree). Mitigation: it is a pure function with a fixture covering fractional yield, duplicate leaves, and currencies before any component consumes it. - The personalisation spans non-adjacent regions (Profit goal above the tree, shopping list below). Mitigation: a single key-gated context provider computes
mergeOwnVsNeedonce and feeds both — no duplicate fetch, no conditional hook call. - A non-Gen-1 legendary must not render a broken tree. Mitigation: detect
root.children.length === 0(V-gen confirmed) in the Page and branch before the tree renders.
Open questions
- Live 404 for an unknown id — reality answers, during implementation (research V1 caveat): a dated manual check against the running api, recorded, not a code gate.
- Web page-test mechanism (stub the data hooks vs an
msw+Suspensewrapper) — resolved intasks.mdby following the existingfeatures/account/features/materialstest pattern.