Tasks 004 — App foundation
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. Exact file paths and their responsibilities are in plan.md § File Structure; the exact pinned versions are in plan.md § Tech stack (and research.md's table). Reach for pnpm --filter @gw2priory/api / --filter @gw2priory/web to scope commands to one app.
Reminder — the plan-mode gate (CLAUDE.md § Gates): these tasks are not a licence to implement. Before writing any code the agent enters plan mode, presents the implementation plan, and starts only once the human approves it. This file is the input to that plan-mode session, not a bypass of it.
Ordering. T1 → T2 build the api and its contract; T3 → T5 build the web app against the committed openapi.json; T6 → T7 consolidate the guardrails. T3 and T4 depend on T2's openapi.json existing.
T1 — API skeleton boots on Fastify (Pino) via SWC, serves /health
Satisfies: R1, R4, R5, P1 #1, SC2. Also lays the api's typecheck/test wiring that T6 finalises.
This is the task that proves the api's compile/run model end to end: SWC emits the decorator metadata NestJS DI needs (research.md V1), and the same metadata must be emitted under Vitest, so the api's Vitest project runs through unplugin-swc reading the same .swcrc.
[ ] Add the api workspace and its deps. Create
apps/api/package.json(@gw2priory/api,private,"type": "commonjs"; scriptsbuild=swc src -d dist --strip-leading-paths,dev,start=node dist/main.js,generate:openapi). Install (pinned,plan.md§ Tech stack): runtime@nestjs/core @nestjs/common @nestjs/platform-fastify fastify reflect-metadata; dev@swc/core @swc/cli unplugin-swc @nestjs/testing vitest typescript. Add@swc/coretopnpm-workspace.yamlallowBuildsbesideesbuild(research.mdF9), thenpnpm installand confirm it stays non-interactive.[ ] Create
apps/api/tsconfig.jsonextending../../tsconfig.base.json:module+moduleResolutionnodenext,experimentalDecorators: true,emitDecoratorMetadata: true,lib: ["ES2023"](no DOM),types: ["node"],noEmit: true, and"verbatimModuleSyntax": falsewith a comment that Nest's CommonJS + decorator model is incompatible with verbatim ESM syntax (the one place the base rule is overridden, and why). NobaseUrl; usepathsif any are needed (research.mdF7). Addlib: ["ES2023"]totsconfig.base.jsonas the non-DOM floor (research.mdF8).[ ] Create
apps/api/.swcrcwithjsc.parser.{syntax:"typescript",decorators:true},jsc.transform.{legacyDecorator:true,decoratorMetadata:true},module.type:"commonjs"(research.mdV1 — this is the config that emitsdesign:paramtypes).[ ] Create
apps/api/vitest.config.tsusingunplugin-swc's Vite plugin so tests get the same metadata emit, and register it as a project in the root config:```ts // apps/api/vitest.config.ts import swc from 'unplugin-swc'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [swc.vite()], // reads .swcrc → decorator metadata in tests test: { name: 'api', root: import.meta.dirname, environment: 'node', include: ['src/**/*.test.ts'] }, }); ``` Convert root `vitest.config.ts` to `test.projects` (`research.md` V6), keeping the existing suites as their own project so nothing stops running: ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ { test: { name: 'workspace', environment: 'node', include: ['packages/**/*.test.ts', 'tests/**/*.test.ts'] } }, './apps/api/vitest.config.ts', ], }, }); ```[ ] RED: write
apps/api/src/health/health.controller.test.ts, named for its criterion. Boot the app on the Fastify adapter in-process and inject a request — this exercises DI (the injectedHealthServicemust produce the body) and so proves SWC metadata under Vitest:```ts import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { Test } from '@nestjs/testing'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AppModule } from '../app.module'; describe('T1 — GET /health', () => { let app: NestFastifyApplication; beforeAll(async () => { const ref = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = ref.createNestApplication<NestFastifyApplication>(new FastifyAdapter()); await app.init(); await app.getHttpAdapter().getInstance().ready(); }); afterAll(async () => { await app.close(); }); it('SC2 / P1 #1: returns 200 {status:"ok"} with DI resolved', async () => { const res = await app.inject({ method: 'GET', url: '/health' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ status: 'ok' }); }); }); ``` Run `pnpm test` — watch it FAIL because `../app.module` does not exist (not because of a transform error; if DI/metadata errors appear, that is the `unplugin-swc` wiring, fix that first).[ ] GREEN: create the minimal Nest app per
plan.md§ File Structure —src/health/health.service.ts(@Injectable()withgetStatus(): { status: 'ok' } { return { status: 'ok' }; }),health.controller.ts(@Controller('health'),@Get()returningthis.health.getStatus()),health.module.ts,app.module.ts(importsHealthModule), andmain.ts(import 'reflect-metadata';NestFactory.create(AppModule, new FastifyAdapter({ logger: true }))for the native Pino logger,listenon port 3000). Rerunpnpm test— PASS.[ ] Extend root
package.jsontypecheckto cover the api:tsc --noEmit && tsc --noEmit -p apps/api/tsconfig.json. Runpnpm typecheckandpnpm lint— green.[ ] Confirm the test has teeth — make
getStatusreturn{ status: 'down' }, watch the test fail, restore.[ ] Commit (
api: skeleton on Fastify via SWC, GET /health).
Verified by: apps/api/src/health/health.controller.test.ts; pnpm typecheck clean.
T2 — API contract: one Zod schema drives validation and the OpenAPI document
Satisfies: R2, R3, R11 (api half), P3 #1, SC5.
[ ] RED: write
apps/api/src/generate-openapi.test.tsthat runs the emit function and asserts the document is built from the Zod schema, not a hand-authored DTO:```ts import { describe, expect, it } from 'vitest'; import { buildOpenApiDocument } from './generate-openapi'; describe('T2 — OpenAPI from Zod', () => { it('P3 #1 / SC5: documents GET /health with a Zod-derived response', async () => { const doc = await buildOpenApiDocument(); const ok = doc.paths?.['/health']?.get?.responses?.['200'] as Record<string, any>; const ref: string = ok.content['application/json'].schema.$ref; const schema = doc.components!.schemas![ref.split('/').pop()!] as Record<string, any>; expect(schema.properties.status.enum).toEqual(['ok']); // z.literal('ok') → enum:['ok'] }); }); ``` Run `pnpm test` — FAIL (`buildOpenApiDocument` missing).[ ] GREEN, part 1 — the schema: create
apps/api/src/health/health.schema.tswithexport const HealthResponse = z.object({ status: z.literal('ok') });andexport class HealthResponseDto extends createZodDto(HealthResponse) {}(nestjs-zod). Annotate the controller method with@ZodResponse({ status: 200, type: HealthResponseDto })and registerZodValidationPipeglobally inmain.ts.[ ] GREEN, part 2 — the emit: create
apps/api/src/generate-openapi.tsexportingbuildOpenApiDocument()that creates a Nest app context, callscleanupOpenApiDoc( SwaggerModule.createDocument(app, new DocumentBuilder()....build()))withoutlisten, and a CLI entry that writes the result toapps/api/openapi.json(thegenerate:openapiscript). WireSwaggerModule.setup('api-docs', …)inmain.tstoo. Rerunpnpm test— PASS.[ ] Generate and commit the artifact:
pnpm --filter @gw2priory/api generate:openapi, then confirmapps/api/openapi.jsoncontainsGET /healthwith anoperationId.pnpm typecheck/pnpm lintgreen.[ ] Confirm teeth — change the schema to
z.object({ status: z.string() }), watch theenumassertion fail, restore.[ ] Commit (
api: Zod health schema → OpenAPI document + committed openapi.json).
Verified by: apps/api/src/generate-openapi.test.ts; committed apps/api/openapi.json.
T3 — Web shell: Vite + React renders styled (Panda + Base UI), with the dev proxy
Satisfies: R6, R7, R12, P1 #4. Also lays the web typecheck/test wiring that T6 finalises.
[ ] Scaffold the web workspace: create
apps/web/package.json(@gw2priory/web,private,"type": "module"; scriptsdev=vite,build=panda codegen && vite build,preview,generate:api=orval,prepare=panda codegen). Install (pinned):react react-dom react-router @tanstack/react-query @base-ui/react; devvite @vitejs/plugin-react @pandacss/dev orval vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/dom. Note the package name is@base-ui/react(research.mdV4). Gitignoreapps/web/styled-system/.[ ] Create the build/tooling config per
plan.md§ File Structure:vite.config.ts(react plugin +server.proxy = { '/api': { target: 'http://localhost:3000', changeOrigin: true } }— the dev proxy,R12/P1 #4),postcss.config.cjs({ plugins: { '@pandacss/dev/postcss': {} } }— Panda has no Vite plugin,research.mdV4),panda.config.ts(jsxFramework: 'react',outdir: 'styled-system', include./src/**/*.{ts,tsx}),tsconfig.json(extends base;lib: ["ES2023", "DOM","DOM.Iterable"],jsx: "react-jsx",moduleResolution: "bundler",types: ["vite/client"], includestyled-system). Runpnpm --filter @gw2priory/web exec panda codegensostyled-systemexists for typecheck.[ ] Create
apps/web/vitest.config.ts(react plugin,environment: 'jsdom', asetupFilesthat imports@testing-library/jest-dom) and add'./apps/web/vitest.config.ts'to the roottest.projectsarray.[ ] RED: write
apps/web/src/App.test.tsx— renders the shell and asserts a Base UI primitive and a Panda class are present:```tsx import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { App } from './App'; describe('T3 — web shell', () => { it('R6/R7: renders the app title', () => { render(<App />); expect(screen.getByRole('heading', { name: /gw2 priory/i })).toBeInTheDocument(); }); }); ``` Add a second test file `apps/web/src/vite-proxy.test.ts` that imports the Vite config and asserts `config.server.proxy['/api'].target` points at the api origin (`P1 #4`). Run `pnpm test` — FAIL.[ ] GREEN: create
index.html,src/main.tsx(React root wrappingQueryClientProvider+ aRouterProvider/BrowserRouter), andsrc/App.tsx(a heading styled with Panda'scss()and one Base UI primitive to exercise the pipeline). Rerunpnpm test— PASS.[ ] Extend root
typecheckto addtsc --noEmit -p apps/web/tsconfig.json.pnpm typecheck/pnpm lintgreen.[ ] Teeth — change the heading text, watch
App.test.tsxfail, restore.[ ] Commit (
web: Vite + React shell with Panda + Base UI and dev proxy).
Verified by: apps/web/src/App.test.tsx, apps/web/src/vite-proxy.test.ts.
T4 — Web client generated from the OpenAPI document, behind the src/api facade
Satisfies: R8, R9, R11 (web half), P3 #3, SC6.
[ ] Create
apps/web/orval.config.tswith the two entriesresearch.mdV3 verified — oneclient: 'react-query'(httpClient: 'fetch',target: 'src/api/generated/endpoints',schemas: 'src/api/generated/models'), oneclient: 'zod'(fileExtension: '.zod.ts'), both readinginput: '../api/openapi.json'. Runpnpm --filter @gw2priory/web generate:api; commit the generatedsrc/api/generated/**.[ ] RED: write
apps/web/src/api/boundary.test.ts— a static scan asserting nothing outsidesrc/api/imports from the generated directory (R9/SC6):```ts import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const webSrc = join(import.meta.dirname, '..'); // apps/web/src function walk(dir: string): string[] { return readdirSync(dir).flatMap((e) => { const p = join(dir, e); if (statSync(p).isDirectory()) return p.includes('/api') ? [] : walk(p); return /\.tsx?$/.test(p) ? [p] : []; }); } describe('T4 — API boundary', () => { it('SC6 / R9: only src/api imports the generated client', () => { const offenders = walk(webSrc).filter((f) => /from ['"].*api\/generated/.test(readFileSync(f, 'utf8')), ); expect(offenders).toEqual([]); }); }); ``` Run `pnpm test` — it PASSES trivially now (no consumers yet). Make it meaningful: also assert the facade **does** export a hook, so the test fails until the facade exists — `expect(() => require('./index')).not.toThrow()` is not enough under ESM; instead import `useHealth` from `./index` at top and assert it is a function. Run — FAIL (facade missing).[ ] GREEN: create
apps/web/src/api/index.ts— the facade. Re-export auseHealth()that calls the generateduseGetHealthhook and validates its data with the generatedgetHealthResponseZod schema (research.mdF10 — Orval does not wire the validator into the hook, so the facade does). Rerunpnpm test— PASS.pnpm typecheckcovers the generated code — green.[ ] Teeth — add
import { getHealth } from './generated/endpoints/health/health'tosrc/App.tsx(outside the facade), watchboundary.test.tsfail, remove it.[ ] Commit (
web: Orval client from openapi.json behind src/api facade).
Verified by: apps/web/src/api/boundary.test.ts; pnpm typecheck clean over generated code.
T5 — Health round-trip page renders loading, error, and ok
Satisfies: R10, P1 #2, P1 #3, SC1 (automated half).
[ ] RED: write
apps/web/src/routes/health-page.test.tsx. Mock the facade module so the hook’s state is controlled, and assert the three renders:```tsx import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { HealthPage } from './health-page'; import * as api from '../api'; afterEach(() => vi.restoreAllMocks()); const mock = (v: unknown) => vi.spyOn(api, 'useHealth').mockReturnValue(v as ReturnType<typeof api.useHealth>); describe('T5 — health page', () => { it('P1 #2 / SC1: shows ok when resolved', () => { mock({ status: 'success', data: { status: 'ok' } }); render(<HealthPage />); expect(screen.getByText(/ok/i)).toBeInTheDocument(); }); it('P1 #3: shows an error state on failure', () => { mock({ status: 'error', error: new Error('down') }); render(<HealthPage />); expect(screen.getByRole('alert')).toBeInTheDocument(); }); it('P1 #3: shows loading while pending', () => { mock({ status: 'pending' }); render(<HealthPage />); expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); }); ``` (Match the mock's shape to what `useHealth` actually returns; adjust field names after the facade.) Run `pnpm test` — FAIL (`HealthPage` missing).[ ] GREEN: create
apps/web/src/routes/health-page.tsxthat callsuseHealth()and branches on its state to render loading / anrole="alert"error / theokstatus (Panda-styled). Wire it as a route inApp.tsx. Rerunpnpm test— PASS.[ ] Teeth — invert a branch (render loading on success), watch the resolved test fail, restore.
[ ] Commit (
web: health round-trip page (loading/error/ok)).
Verified by: apps/web/src/routes/health-page.test.tsx.
T6 — One root typecheck / test / lint / build, and CI covers both apps
Satisfies: R13, R14, R15, R16, P2 #1, P2 #2, P2 #3, P2 #4, P2 #5, SC3, SC7.
- [ ] Finalise root scripts in
package.json:typecheck= the threetsc --noEmitinvocations (root,apps/api,apps/web);build=pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/web build;dev= run both apps (e.g.pnpm -r --parallel dev); keeptest=vitest run. Addjsdomas a root dev dependency if not already present. - [ ] RED: write
tests/monorepo/apps.test.ts, mirroringtests/ci/workflow.test.tsstyle — structural guards for the P2 scenarios: the rootvitest.config.tstest.projectsincludes anapi(node) and aweb(jsdom) project; the roottypecheckscript contains all three-ptargets; thebuildscript filters both apps; Biome is not overridden per-app (noapps/**/biome.json). Runpnpm test— FAIL until the structure matches. - [ ] GREEN: make the assertions true (they largely are after T1/T3 — this task closes any gaps and adds the
build/devscripts). Rerunpnpm test— PASS. - [ ] CI: add a
pnpm buildstep to.github/workflows/ci.yml(afterpnpm test, beforepnpm docs:build), and updatetests/ci/workflow.test.ts's expected ordered-checks array to['pnpm lint','pnpm typecheck','pnpm test','pnpm build','pnpm docs:build'](the couplingplan.md§ Risks names). Runpnpm test— PASS. - [ ] Confirm the whole gate is green together:
pnpm lint && pnpm typecheck && pnpm test && pnpm build. Confirmtests/workflow/repo-invariants.test.ts(docs/superpowers count 0,SC7/P2 #5) still passes. - [ ] Teeth — plant a type error in
apps/apiand one inapps/web, confirmpnpm typecheckfails for each; remove them. - [ ] Commit (
ci: build both apps under one typecheck/test/lint/build; CI runs build).
Verified by: tests/monorepo/apps.test.ts, updated tests/ci/workflow.test.ts; a full green lint/typecheck/test/build locally and on the PR.
T7 — Contract no-drift guard
Satisfies: R11, P3 #2, SC4.
- [ ] Add a root
verify:contractscript: regenerate the api'sopenapi.jsonand the web's Orval client, thengit diff --exit-code -- apps/api/openapi.json apps/web/src/api/generated— a non-empty diff means committed artifacts are stale. - [ ] RED: write
tests/contract/no-drift.test.tsthat runsverify:contract(viaexecFileSync) and asserts exit code 0 / empty diff. Temporarily hand-editopenapi.json, run — watch it FAIL. - [ ] GREEN: restore
openapi.json; the test passes. Add apnpm verify:contractstep to CI (afterpnpm build) so drift fails the pipeline. - [ ] Confirm teeth — as above, the hand-edit already proved it.
- [ ] Commit (
ci: contract no-drift guard for openapi.json + generated client).
Verified by: tests/contract/no-drift.test.ts; the CI verify:contract step.
Final step — fill the spec's traceability table
- [ ] With every task's test named, transcribe
spec.md's Traceability table from placeholders to the actual test names/paths above (the table was designed in the spec; this is transcription, pertests/workflow/repo-invariants.test.tsSC5-style checks). Commit (specs: fill 004 traceability). - [ ] Do not set
spec.md→implemented: that status is the human's, set at step 6 after review and verification (superpowers:verification-before-completion).
Notes
Staging area for decisions and surprises found during implementation. Move each into spec.md, research.md, or docs/ before closing the feature (step 6) — this section is not a home.
Assumptions this task list makes that the plan-mode session and TDD should confirm early (each is self-verifying via T1/T3's first test, so a wrong guess fails fast rather than silently):
unplugin-swcfor the api's Vitest project — Vitest's default esbuild transform does not emit decorator metadata, so Nest DI would fail in tests even though the SWC build works. The api project runs throughunplugin-swcreading the same.swcrc. T1's first test exercises DI, so a wrong wiring surfaces immediately. (Not separately verified inresearch.mdV1, which ran the built app under node — flagged here as the one integration point discovery did not cover.)apps/apiis CommonJS withverbatimModuleSyntax: false— Nest's decorator + CJS model conflicts with the base'sverbatimModuleSyntax: true; the api overrides it in its owntsconfig.jsonwith a comment. The only base-rule override, and a documented one.- Panda
styled-systemordering — generated byprepareon install and by the webbuildscript; a fresh clone must run install (orpanda codegen) beforetypecheck. - If any of these fights back, use
superpowers:systematic-debugging, and record the resolution here before it is lost.