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.tsfor the 2-arg constructor (curated,station— noRECIPE_INDEX_DATA): a forge-covered id returns its curated recipe and does not callstation.getRecipes(spy asserts 0 calls —P2 #3); a non-forge id returns the livestation.getRecipesoptions; 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 theRECIPE_INDEX_DATAtoken, the@Injecteddataparam and the indexMap;recipesFor(id)=curated.getRecipe(id)short-circuit, elsetoOptions(null, await station.getRecipes(id)). ImportStationRecipefrom./station-data.service(drop the./recipe-index.schemaimport). Deletedata/station-recipes.tsandrecipe-index.schema.ts. Editstatic-data.module.ts(remove thestation-recipesimport and the{ provide: RECIPE_INDEX_DATA, useValue: stationRecipeIndex }provider). Updatestatic-data.module.test.tsand 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
recipesForalways callstation.getRecipes(drop the forge short-circuit), watch theP2 #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, viafs, thatdata/station-recipes.ts,generate-recipe-index.ts,generate-recipe-index.cli.ts, andrecipe-index.schema.tsdo not exist, and thatapps/api/package.jsonhas nogenerate:recipe-indexscript. 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 thegenerate:recipe-indexline fromapps/api/package.json. Remove the staleRECIPE_INDEX_DATAexample comment inconventions/guards.ts:77only 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.tsto parse a flatResolvedGraphfixture ({ rootId, nodes: { "19721": { id, name, rarity, vendorValue, classification, leaf, recipes } } }) throughRecipeGraphDto.schemaand to reject a body carryingunitCost/decision/summaryas unknown keys (or assert they are absent). Watch it fail (noRecipeGraphDtoyet). - [ ] GREEN: in
recipe-graph.schema.tsaddconst ResolvedGraphSchema: z.ZodType<ResolvedGraph> = z.object({ rootId: z.number().int().positive(), nodes: z.record(z.string(), GraphNodeSchema) })andexport class RecipeGraphDto extends createZodDto(ResolvedGraphSchema) {}. Reuse the existingGraphNodeSchema/RecipeOptionSchema/RecipeEdgeSchema. Leave the priced DTOs in place for now (removed in T4). - [ ] Confirm teeth — drop
rootIdfrom 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/:itemIdreturns{ rootId, nodes }with none ofunitBuyPrice/craftCost/unitCost/lineCost/decision/summary(SC1/P1 #1); two calls with differentX-GW2-Key/Authorizationreturn byte-identical bodies (SC2/P1 #2); an unknown id still404s (P1 #4). Add/extendrecipe-graph.service.test.tsto assert a node reachable only via a non-first recipe option is present innodeswith 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)typedPromise<ResolvedGraph>,@ZodResponse({ status: 200, type: RecipeGraphDto }); keep the 404 mapping (resolvethrows noItemNotFoundErrortoday — confirm: a null root name does not throw inresolve; ifP1 #4needs the 404, surface an unknown root fromresolvethe same wayresolvePriceddoes, without a prices call). RemovePricedTreeNodeSchema,PlanSummarySchema,DecisionSchema,RecipeTreeDtofromrecipe-graph.schema.tsafter grepping that nothing but the old controller/tests imported them. Add aponytail:comment at theresolve/expandseam inrecipe-graph.service.tsnaming the cold-resolve ceiling + the deferred DFS-parallelisation upgrade path (research.mdQ2). Regenerate the OpenAPI artifact (pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/api generate:openapi, which writesapps/api/openapi.json) and updategenerate-openapi.test.tsto assert the flat, non-recursive/recipe-graphschema (replacing the SC12 recursive-$refassertion). Correct themcp.tools.test.tsSC15 comment: the tool no longer mirrors the REST route — Surface A (priceless) and Surface B (priced) differ by design (R9); the test body (mockedresolvePriced,.toBe(root)) is unchanged. - [ ] Confirm teeth — point the controller back at
resolvePriced, watch theSC1"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, configapps/web/orval.config.ts) against the OpenAPI regenerated in T4 (apps/api/openapi.json). This rewritesapps/web/src/api/generated/**to the flatResolvedGraphmodel. - [ ] Verify the generated
RecipeGraphControllerGetResponseis 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—useRecipeTreereturns a priceless tree projected from the flat graph (walk fromrootId, expand each node's first recipe option's item ingredients; no price-based choice);404still throwsNotFoundError. Updatefeatures/legendaries/__tests__/fixtures.tsto 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.tsto fetch the flat graph and project the priceless tree; adaptLegendaryTree.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/apionly), 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
runskill /pnpm dev) and open a legendary detail page: the recipe structure renders; no price UI; no console errors. Record the observation. - [ ] Fill the
spec.mdTraceability table — every scenario/criterion → the real test name from T1–T6. - [ ] On the human's decision, transcribe
spec.mdstatusapproved→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) viasuperpowers: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):resolvePricedthrowsItemNotFoundErrorafter the prices fetch;resolvecurrently does not. IfP1 #4requires the 404, add the unknown-root check toresolve(no prices call) rather than reintroducing pricing.