Skip to content

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

PathChangeResponsibility
api/useRecipeTree.tsnewSuspense hook over getRecipeGraphControllerGetQueryOptions({ itemId }), response Zod-parsed with RecipeGraphControllerGetResponse; keyless. Exposes the parsed PricedTree root and a RecipeTree type.
api/index.tsmodifyRe-export useRecipeTree + its RecipeTree type (barrel, as for the other hooks).
features/legendaries/useLegendary.tsnewuseLegendary(id): Legendary | undefined — selector over useLegendaries() (find by id); no fetch of its own.
features/legendaries/aggregateNeed.tsnewPure 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.tsnewPure mergeOwnVsNeed(need, materials, wallet): OwnVsNeed — ceil totals, subtract owned once, {need,have,short} rows + remainingCost. React-free.
features/legendaries/OwnVsNeedProvider.tsxnewKey-gated boundary: mounts useMaterials/useWallet, computes mergeOwnVsNeed, provides OwnVsNeed | null via context; renders ConnectAccountPrompt when no key.
features/legendaries/LegendarySummary.tsxnewThe two goal blocks (Craft + Profit), reading the root summary and (from context) the remaining cost.
features/legendaries/LegendaryTree.tsxnewOne 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.tsxnewEnd-of-page block: buyable leaves + total; when context present, have/short columns + your-remaining-cost.
features/legendaries/LegendaryDetailView.tsxnewSuspends on useRecipeTree + useLegendary; lays out header + OwnVsNeedProvider(summary, tree, shopping list).
features/legendaries/LegendaryDetailPage.tsxnewRouted 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.tsmodifyAdd colocated cvas: gift card, decision badges, tree guide-lines, goal stat, table, shopping-list rows.
features/legendaries/routes.tsxmodifyAdd { path: '/legendaries/:id', element: <LegendaryDetailPage /> }.
features/legendaries/__tests__/*newColocated 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 carry node, count, recipe (chosen, with outputCount + ingredients), children, decision, unitBuyPrice, lineCost, and the root also summary).
  • NeedTotals = { items: Map<number, number>; currencies: Map<number, number>; gatedLeafIds: Set<number> } (expected quantities, per aggregateNeed).
  • OwnVsNeedRow = { kind: 'item' | 'currency'; id: number; name: string; need: number; have?: number; short: number; unitBuyPrice: number | null } and OwnVsNeed = { rows: OwnVsNeedRow[]; remainingCost: number } (per mergeOwnVsNeed).

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 from recipe.ingredients, and the gated-leaf tally. mergeOwnVsNeed — ceil (74.5 → 77), owned-once (own 250 / need 500 → short 250), untracked leaf (have undefined, 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/account tests already do.
  • Route (SC10): rendering the list and following a card Link lands on the detail page.
  • Invariants (SC11): existing conventions.test.ts / tokens guard / docs/superpowers count guard stay green; costs go through Coins, so no colour literals are added.
  • Not directly tested: the live 404 for 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/plan aggregation 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 primary and 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 mergeOwnVsNeed once 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 + Suspense wrapper) — resolved in tasks.md by following the existing features/account / features/materials test pattern.