Skip to content

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"; scripts build = 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/core to pnpm-workspace.yamlallowBuilds beside esbuild (research.md F9), then pnpm install and confirm it stays non-interactive.

  • [ ] Create apps/api/tsconfig.json extending ../../tsconfig.base.json: module + moduleResolutionnodenext, experimentalDecorators: true, emitDecoratorMetadata: true, lib: ["ES2023"] (no DOM), types: ["node"], noEmit: true, and "verbatimModuleSyntax": false with a comment that Nest's CommonJS + decorator model is incompatible with verbatim ESM syntax (the one place the base rule is overridden, and why). No baseUrl; use paths if any are needed (research.md F7). Add lib: ["ES2023"] to tsconfig.base.json as the non-DOM floor (research.md F8).

  • [ ] Create apps/api/.swcrc with jsc.parser.{syntax:"typescript",decorators:true}, jsc.transform.{legacyDecorator:true,decoratorMetadata:true}, module.type:"commonjs" (research.md V1 — this is the config that emits design:paramtypes).

  • [ ] Create apps/api/vitest.config.ts using unplugin-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 injected HealthService must 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() with getStatus(): { status: 'ok' } { return { status: 'ok' }; }), health.controller.ts (@Controller('health'), @Get() returning this.health.getStatus()), health.module.ts, app.module.ts (imports HealthModule), and main.ts (import 'reflect-metadata'; NestFactory.create(AppModule, new FastifyAdapter({ logger: true })) for the native Pino logger, listen on port 3000). Rerun pnpm test — PASS.

  • [ ] Extend root package.json typecheck to cover the api: tsc --noEmit && tsc --noEmit -p apps/api/tsconfig.json. Run pnpm typecheck and pnpm lint — green.

  • [ ] Confirm the test has teeth — make getStatus return { 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.ts that 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.ts with export const HealthResponse = z.object({ status: z.literal('ok') }); and export class HealthResponseDto extends createZodDto(HealthResponse) {} (nestjs-zod). Annotate the controller method with @ZodResponse({ status: 200, type: HealthResponseDto }) and register ZodValidationPipe globally in main.ts.

  • [ ] GREEN, part 2 — the emit: create apps/api/src/generate-openapi.ts exporting buildOpenApiDocument() that creates a Nest app context, calls cleanupOpenApiDoc( SwaggerModule.createDocument(app, new DocumentBuilder()....build())) without listen, and a CLI entry that writes the result to apps/api/openapi.json (the generate:openapi script). Wire SwaggerModule.setup('api-docs', …) in main.ts too. Rerun pnpm test — PASS.

  • [ ] Generate and commit the artifact: pnpm --filter @gw2priory/api generate:openapi, then confirm apps/api/openapi.json contains GET /health with an operationId. pnpm typecheck / pnpm lint green.

  • [ ] Confirm teeth — change the schema to z.object({ status: z.string() }), watch the enum assertion 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"; scripts dev = 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; dev vite @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.md V4). Gitignore apps/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.md V4), 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"], include styled-system). Run pnpm --filter @gw2priory/web exec panda codegen so styled-system exists for typecheck.

  • [ ] Create apps/web/vitest.config.ts (react plugin, environment: 'jsdom', a setupFiles that imports @testing-library/jest-dom) and add './apps/web/vitest.config.ts' to the root test.projects array.

  • [ ] 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 wrapping QueryClientProvider + a RouterProvider/BrowserRouter), and src/App.tsx (a heading styled with Panda's css() and one Base UI primitive to exercise the pipeline). Rerun pnpm test — PASS.

  • [ ] Extend root typecheck to add tsc --noEmit -p apps/web/tsconfig.json. pnpm typecheck / pnpm lint green.

  • [ ] Teeth — change the heading text, watch App.test.tsx fail, 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.ts with the two entries research.md V3 verified — one client: 'react-query' (httpClient: 'fetch', target: 'src/api/generated/endpoints', schemas: 'src/api/generated/models'), one client: 'zod' (fileExtension: '.zod.ts'), both reading input: '../api/openapi.json'. Run pnpm --filter @gw2priory/web generate:api; commit the generated src/api/generated/**.

  • [ ] RED: write apps/web/src/api/boundary.test.ts — a static scan asserting nothing outside src/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 a useHealth() that calls the generated useGetHealth hook and validates its data with the generated getHealthResponse Zod schema (research.md F10 — Orval does not wire the validator into the hook, so the facade does). Rerun pnpm test — PASS. pnpm typecheck covers the generated code — green.

  • [ ] Teeth — add import { getHealth } from './generated/endpoints/health/health' to src/App.tsx (outside the facade), watch boundary.test.ts fail, 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.tsx that calls useHealth() and branches on its state to render loading / an role="alert" error / the ok status (Panda-styled). Wire it as a route in App.tsx. Rerun pnpm 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 three tsc --noEmit invocations (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); keep test = vitest run. Add jsdom as a root dev dependency if not already present.
  • [ ] RED: write tests/monorepo/apps.test.ts, mirroring tests/ci/workflow.test.ts style — structural guards for the P2 scenarios: the root vitest.config.ts test.projects includes an api (node) and a web (jsdom) project; the root typecheck script contains all three -p targets; the build script filters both apps; Biome is not overridden per-app (no apps/**/biome.json). Run pnpm 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/dev scripts). Rerun pnpm test — PASS.
  • [ ] CI: add a pnpm build step to .github/workflows/ci.yml (after pnpm test, before pnpm docs:build), and update tests/ci/workflow.test.ts's expected ordered-checks array to ['pnpm lint','pnpm typecheck','pnpm test','pnpm build','pnpm docs:build'] (the coupling plan.md § Risks names). Run pnpm test — PASS.
  • [ ] Confirm the whole gate is green together: pnpm lint && pnpm typecheck && pnpm test && pnpm build. Confirm tests/workflow/repo-invariants.test.ts (docs/superpowers count 0, SC7/P2 #5) still passes.
  • [ ] Teeth — plant a type error in apps/api and one in apps/web, confirm pnpm typecheck fails 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:contract script: regenerate the api's openapi.json and the web's Orval client, then git 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.ts that runs verify:contract (via execFileSync) and asserts exit code 0 / empty diff. Temporarily hand-edit openapi.json, run — watch it FAIL.
  • [ ] GREEN: restore openapi.json; the test passes. Add a pnpm verify:contract step to CI (after pnpm 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, per tests/workflow/repo-invariants.test.ts SC5-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-swc for 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 through unplugin-swc reading the same .swcrc. T1's first test exercises DI, so a wrong wiring surfaces immediately. (Not separately verified in research.md V1, which ran the built app under node — flagged here as the one integration point discovery did not cover.)
  • apps/api is CommonJS with verbatimModuleSyntax: false — Nest's decorator + CJS model conflicts with the base's verbatimModuleSyntax: true; the api overrides it in its own tsconfig.json with a comment. The only base-rule override, and a documented one.
  • Panda styled-system ordering — generated by prepare on install and by the web build script; a fresh clone must run install (or panda codegen) before typecheck.
  • If any of these fights back, use superpowers:systematic-debugging, and record the resolution here before it is lost.