Skip to content

Tasks 032 — Recipe graph: cacheable, priceless, live station recipes ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task.

Build-window note. T1–T2 keep the whole monorepo green (the endpoint response is unchanged). T4 flips the /recipe-graph contract to priceless: the api stays green, but the web build/tests go red until T6 restores them. T4–T6 are the contract migration and must all land before the branch is merge-ready; the reviewer gates each diff on its own tests, not on a green web build mid-migration.


T1 — RecipeIndexService resolves curated-then-live; committed index gone ​

Satisfies: R4, R6, P2 #3 (and begins R5).

  • [ ] RED: rewrite apps/api/src/static-data/recipe-index.service.test.ts for the 2-arg constructor (curated, station — no RECIPE_INDEX_DATA): a forge-covered id returns its curated recipe and does not call station.getRecipes (spy asserts 0 calls — P2 #3); a non-forge id returns the live station.getRecipes options; a non-forge id with no recipe is a [] leaf. Watch it fail against the current 3-arg service.
  • [ ] GREEN: edit recipe-index.service.ts — remove the RECIPE_INDEX_DATA token, the @Injected data param and the index Map; recipesFor(id) = curated.getRecipe(id) short-circuit, else toOptions(null, await station.getRecipes(id)). Import StationRecipe from ./station-data.service (drop the ./recipe-index.schema import). Delete data/station-recipes.ts and recipe-index.schema.ts. Edit static-data.module.ts (remove the station-recipes import and the { provide: RECIPE_INDEX_DATA, useValue: stationRecipeIndex } provider). Update static-data.module.test.ts and the two resolver tests that build the service 3-arg (recipe-graph.service.index-equivalence.test.ts, recipe-graph.service.warm-cache.test.ts) to the 2-arg form.
  • [ ] REFACTOR: only with tests green.
  • [ ] Confirm teeth — make recipesFor always call station.getRecipes (drop the forge short-circuit), watch the P2 #3 "0 station calls" assertion fail, restore.
  • [ ] Commit.

Verified by: recipe-index.service.test.ts (curated-then-live, P2 #3), static-data.module.test.ts, recipe-graph.service.warm-cache.test.ts (SC4: 2nd resolve = 0 fetches), recipe-graph.service.index-equivalence.test.ts (P2 #1/R6: live == former index). pnpm --filter @gw2priory/api test green.


T2 — Delete the sync builder, CLI, and script; guard their absence ​

Satisfies: R5, SC5.

  • [ ] RED: add apps/api/src/static-data/generate-recipe-index-removed.test.ts (or fold into an existing static-data test) asserting, via fs, that data/station-recipes.ts, generate-recipe-index.ts, generate-recipe-index.cli.ts, and recipe-index.schema.ts do not exist, and that apps/api/package.json has no generate:recipe-index script. Watch it fail (the CLI/script still exist).
  • [ ] GREEN: delete generate-recipe-index.ts, generate-recipe-index.cli.ts, generate-recipe-index.test.ts, recipe-index.schema.test.ts, recipe-index.data.test.ts, recipe-index.coverage.test.ts. Remove the generate:recipe-index line from apps/api/package.json. Remove the stale RECIPE_INDEX_DATA example comment in conventions/guards.ts:77 only if no guard test uses it as its fixture (grep first; if one does, leave it or substitute another token-injected param).
  • [ ] Confirm teeth — restore one deleted path, watch the guard test fail, delete again.
  • [ ] Commit.

Verified by: the new removal guard test (SC5); pnpm --filter @gw2priory/api test and pnpm lint green (no dangling imports to the deleted files).


T3 — Add the priceless RecipeGraphDto (additive) ​

Satisfies: R1 (schema half), R3 (shape carries every node + all options).

  • [ ] RED: extend recipe-graph.schema.test.ts to parse a flat ResolvedGraph fixture ({ rootId, nodes: { "19721": { id, name, rarity, vendorValue, classification, leaf, recipes } } }) through RecipeGraphDto.schema and to reject a body carrying unitCost/decision/summary as unknown keys (or assert they are absent). Watch it fail (no RecipeGraphDto yet).
  • [ ] GREEN: in recipe-graph.schema.ts add const ResolvedGraphSchema: z.ZodType<ResolvedGraph> = z.object({ rootId: z.number().int().positive(), nodes: z.record(z.string(), GraphNodeSchema) }) and export class RecipeGraphDto extends createZodDto(ResolvedGraphSchema) {}. Reuse the existing GraphNodeSchema/RecipeOptionSchema/ RecipeEdgeSchema. Leave the priced DTOs in place for now (removed in T4).
  • [ ] Confirm teeth — drop rootId from the schema, watch the parse test fail, restore.
  • [ ] Commit.

Verified by: recipe-graph.schema.test.ts (priceless DTO validates; R1/R3). api build + test green.


T4 — Controller returns the priceless graph; remove priced DTOs; regenerate OpenAPI ​

Satisfies: R1, R2, R3, R8 (surface declaration), R9, P1 #1/#2/#3/#4, SC1, SC2. Build window: api stays green; web goes red until T6.

  • [ ] RED: rewrite recipe-graph.controller.test.ts — GET /recipe-graph/:itemId returns { rootId, nodes } with none of unitBuyPrice/craftCost/unitCost/lineCost/decision/summary (SC1/P1 #1); two calls with different X-GW2-Key/Authorization return byte-identical bodies (SC2/P1 #2); an unknown id still 404s (P1 #4). Add/extend recipe-graph.service.test.ts to assert a node reachable only via a non-first recipe option is present in nodes with all its options (R3/P1 #3). Watch them fail (controller still returns the priced tree).
  • [ ] GREEN: recipe-graph.controller.ts → return await this.recipeGraph.resolve(itemId) typed Promise<ResolvedGraph>, @ZodResponse({ status: 200, type: RecipeGraphDto }); keep the 404 mapping (resolve throws no ItemNotFoundError today — confirm: a null root name does not throw in resolve; if P1 #4 needs the 404, surface an unknown root from resolve the same way resolvePriced does, without a prices call). Remove PricedTreeNodeSchema, PlanSummarySchema, DecisionSchema, RecipeTreeDto from recipe-graph.schema.ts after grepping that nothing but the old controller/tests imported them. Add a ponytail: comment at the resolve/expand seam in recipe-graph.service.ts naming the cold-resolve ceiling + the deferred DFS-parallelisation upgrade path (research.md Q2). Regenerate the OpenAPI artifact (pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/api generate:openapi, which writes apps/api/openapi.json) and update generate-openapi.test.ts to assert the flat, non-recursive /recipe-graph schema (replacing the SC12 recursive-$ref assertion). Correct the mcp.tools.test.ts SC15 comment: the tool no longer mirrors the REST route — Surface A (priceless) and Surface B (priced) differ by design (R9); the test body (mocked resolvePriced, .toBe(root)) is unchanged.
  • [ ] Confirm teeth — point the controller back at resolvePriced, watch the SC1 "no price fields" assertion fail, restore.
  • [ ] Commit.

Verified by: recipe-graph.controller.test.ts, recipe-graph.service.test.ts, generate-openapi.test.ts, mcp.tools.test.ts; pnpm --filter @gw2priory/api test && pnpm --filter @gw2priory/api build green. (Web is expected red here — restored in T6.)


T5 — Regenerate the web API client from the new OpenAPI ​

Satisfies: enables R10 (the web types now describe the flat graph).

  • [ ] Run pnpm --filter @gw2priory/web generate:api (orval, config apps/web/orval.config.ts) against the OpenAPI regenerated in T4 (apps/api/openapi.json). This rewrites apps/web/src/api/generated/** to the flat ResolvedGraph model.
  • [ ] Verify the generated RecipeGraphControllerGetResponse is the flat shape and no longer the recursive priced node.
  • [ ] Commit (generated files only — no hand edits).

Verified by: git diff shows only apps/web/src/api/generated/**; the new model matches the OpenAPI. (Web still red until T6 — hand code references old fields.)


T6 — Web detail view consumes the flat graph, renders structure only ​

Satisfies: R10, SC6 (web half).

  • [ ] RED: update apps/web/src/api/__tests__/useRecipeTree.test.tsx — useRecipeTree returns a priceless tree projected from the flat graph (walk from rootId, expand each node's first recipe option's item ingredients; no price-based choice); 404 still throws NotFoundError. Update features/legendaries/__tests__/fixtures.ts to priceless graph fixtures and the detail-view tests (LegendaryDetailView, ShoppingList, LegendarySummary, LegendaryTree) to assert structure (item, quantity, provenance) and the absence of cost/decision/profit UI. Watch them fail.
  • [ ] GREEN: rewrite useRecipeTree.ts to fetch the flat graph and project the priceless tree; adapt LegendaryTree.tsx (node structure, no per-node cost/decision), ShoppingList.tsx (materials list, no cost column), LegendarySummary.tsx (hide/placeholder the profit block). Leave ranking (useLegendaryRanking, RankingPage, LegendariesLayout) untouched — verify by grep they are not edited.
  • [ ] REFACTOR: only with web tests green.
  • [ ] Confirm teeth — assert a price value in one detail test, watch it fail (no prices now), restore to the structure assertion.
  • [ ] Commit.

Verified by: useRecipeTree.test.tsx, the legendary-detail tests; pnpm --filter @gw2priory/web test && pnpm --filter @gw2priory/web build green (mind the React-compiler build gate — pnpm build, not just vitest).


T7 — Park the curated-package divergence in docs/gaps/ ​

Satisfies: the spec's Parked finding.

  • [ ] Create docs/gaps/curated-recipes-workspace-package.md: the curated forge/precursor dataset is the workspace package @gw2priory/legendary-recipes, single-consumer (apps/api only), which diverges from the epic's "app-side, not a workspace package" wording. A record, not a proposal — deferred to a later spec; note it changes no behaviour and is orthogonal to caching. Link spec 032.
  • [ ] Commit.

Verified by: file exists; pnpm docs:build green (VitePress compiles it).


T8 — Verify end to end; traceability; status → implemented ​

Satisfies: SC6, the CLAUDE.md definition of done.

  • [ ] Run the full gate from the repo root: pnpm lint && pnpm test && pnpm build && pnpm docs:build. All green.
  • [ ] Run the app (the run skill / pnpm dev) and open a legendary detail page: the recipe structure renders; no price UI; no console errors. Record the observation.
  • [ ] Fill the spec.md Traceability table — every scenario/criterion → the real test name from T1–T6.
  • [ ] On the human's decision, transcribe spec.md status approved → implemented (in-branch, part of the PR diff) — and say it was the human's call, not the agent's.
  • [ ] Commit; open the PR (032-recipe-graph-cacheable → main) via superpowers:finishing-a-development-branch.

Verified by: the full gate green; the running app; the completed traceability table; superpowers: requesting-code-review on the branch before merge.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • Watch for resolve()'s 404 behaviour (T4): resolvePriced throws ItemNotFoundError after the prices fetch; resolve currently does not. If P1 #4 requires the 404, add the unknown-root check to resolve (no prices call) rather than reintroducing pricing.