From f8ed5102951a58143de27b9c6ed93bb4191d5f3f Mon Sep 17 00:00:00 2001 From: ydj515 Date: Thu, 2 Jul 2026 00:47:05 +0900 Subject: [PATCH 01/60] docs: add React dashboard template design - Document the approved React dashboard template architecture - Capture tooling, routing, state, mock API, form, and testing decisions - Define implementation boundaries before rebuilding the sample project --- ...-react-sample-dashboard-template-design.md | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-react-sample-dashboard-template-design.md diff --git a/docs/superpowers/specs/2026-07-02-react-sample-dashboard-template-design.md b/docs/superpowers/specs/2026-07-02-react-sample-dashboard-template-design.md new file mode 100644 index 0000000..c196f64 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-react-sample-dashboard-template-design.md @@ -0,0 +1,412 @@ +# React Sample Dashboard Template 설계 + +## 목표 + +기존 React 샘플 프로젝트를 최신 실무형 템플릿으로 재구성한다. 예제 앱은 작고 응집도 있는 프로젝트 대시보드로 만들며, React 앱의 구조, 설정, 라우팅, 서버 상태, 클라이언트 UI 상태, 폼, 스타일링, 테스트 패턴을 한 프로젝트 안에서 확인할 수 있게 한다. + +## 확정 요구사항 + +- 기존 소스는 필요하면 삭제하고 새로 구성한다. +- Node.js는 `24.13.0`으로 고정하고 `mise.toml`에 명시한다. +- 패키지 매니저는 `pnpm`을 유지하고, `package.json`의 `packageManager`에 정확한 버전을 기록한다. +- React, Vite, TypeScript, Tailwind CSS v4, TanStack Router, TanStack Query, Zustand, MSW, React Hook Form, Zod를 사용한다. +- UI는 Tailwind와 headless/Radix 스타일 primitive 조합으로 구성한다. +- TanStack Router는 유지하되 최신 파일 기반 라우팅 구조로 재작성한다. +- import는 `@/*` 절대경로 alias를 기본으로 사용한다. +- 레이아웃, route 파일, feature 로직, shared UI, API 호출, query, store, test를 책임별로 분리한다. +- lint, format, typecheck, test, build, validate 스크립트를 포함한다. +- 테스트 범위는 unit, component, integration까지만 포함한다. +- Playwright 같은 E2E 테스트는 이번 범위에 넣지 않는다. +- 예제 도메인은 프로젝트 대시보드로 한다. +- 레이아웃은 고정 사이드바와 상단 툴바를 갖는 app shell로 한다. +- mock API 계층은 MSW로 구성한다. +- 프로젝트 생성 Dialog 하나에 React Hook Form과 Zod 예제를 넣는다. + +## 아키텍처 선택 + +기본 구조는 feature-first 아키텍처로 구성하고, TanStack Router route 파일은 얇게 유지한다. + +route 파일은 라우트 경계와 page 연결만 담당한다. 비즈니스 로직, 폼 로직, query 로직, 필터링 로직은 feature 폴더로 위임한다. feature 폴더는 API 호출, query hook, 도메인 schema, 도메인 유틸, feature 전용 UI, page, test를 함께 소유한다. shared UI와 shared utility는 도메인에 의존하지 않게 유지한다. + +이 구조는 샘플 프로젝트가 지나치게 커지는 것을 막으면서도, 실제 프로젝트 시작점으로 복사해 쓰기 좋은 형태를 제공한다. + +## 검토한 대안 + +### Feature-First 구조 + +- 장점: + - 도메인 관련 파일을 가까이 둘 수 있다. + - 기능이 늘어날 때 확장성이 좋다. + - feature 테스트를 찾기 쉽다. + - route 파일을 얇게 유지하기 좋다. +- 단점: + - 최소 샘플보다 폴더 수가 많아 보일 수 있다. + +### Layer-First 구조 + +- 장점: + - 많은 React 개발자에게 익숙하다. + - 작은 프로젝트에서는 단순하다. +- 단점: + - 관련 도메인 파일이 `components`, `hooks`, `services`, `pages`로 흩어진다. + - 기능이 커질수록 유지보수가 어려워진다. + +### Route-First 구조 + +- 장점: + - 파일 기반 라우팅과 자연스럽게 맞는다. + - 페이지 단위로 이해하기 쉽다. +- 단점: + - 재사용 UI, 도메인 로직, route 책임의 경계가 흐려질 수 있다. + - 공통 model과 API 코드를 배치하기 애매해질 수 있다. + +선택안은 feature-first 구조와 얇은 route 파일 조합이다. + +## 애플리케이션 라우트 + +- `/` + - 대시보드 홈이다. + - 프로젝트 지표, 최근 프로젝트, 상태별 카운트, 빠른 액션을 보여준다. +- `/projects` + - 프로젝트 목록 화면이다. + - TanStack Query로 서버 상태를 조회한다. + - 검색, 상태 필터, 정렬은 클라이언트 로직으로 처리한다. + - 프로젝트 생성 Dialog를 열 수 있다. +- `/projects/$projectId` + - 프로젝트 상세 화면이다. + - route param을 안정적인 Query key로 연결한다. +- `/settings` + - UI 설정 예제 화면이다. + - Zustand로 관리하는 theme, density, sidebar preference를 보여준다. +- `/signin` + - Auth layout 예제 화면이다. + - 실제 인증 구현은 포함하지 않는다. + +## 레이아웃 설계 + +app shell 레이아웃을 사용한다. + +- 데스크톱 고정 사이드바 +- 모바일 사이드바 drawer +- 페이지 제목, 검색 진입점, theme toggle, account 영역을 가진 상단 툴바 +- 반응형 spacing을 가진 main content 영역 + +사이드바 상태는 클라이언트 UI 상태이므로 Zustand가 소유한다. 프로젝트 데이터는 서버 상태이므로 Zustand에 넣지 않고 TanStack Query가 소유한다. + +## 데이터 흐름 + +### Mock API + +MSW handler는 다음 API를 제공한다. + +- `GET /api/projects` +- `GET /api/projects/:projectId` +- `POST /api/projects` +- `PATCH /api/projects/:projectId/status` + +fixture는 `src/mocks/data`에 둔다. handler는 `src/mocks/handlers.ts`에 둔다. 개발 런타임용 browser setup은 `src/mocks/browser.ts`에 둔다. 테스트용 server setup은 `src/mocks/server.ts`에 둔다. + +### Project API 계층 + +`src/features/projects/api/project-api.ts`는 fetcher 함수만 소유한다. 앱 코드는 MSW를 쓰든 실제 백엔드로 바꾸든 동일한 API 함수를 호출한다. + +### Query 계층 + +`src/features/projects/queries/project-queries.ts`는 다음 항목을 소유한다. + +- `projectsQueryOptions` +- `projectQueryOptions` +- `useProjectsQuery` +- `useProjectQuery` +- `useCreateProjectMutation` +- `useUpdateProjectStatusMutation` + +mutation 성공 시 관련 project query를 invalidate하거나 필요한 cache를 갱신한다. + +### 클라이언트 UI 상태 + +`src/stores/ui-store.ts`는 다음 상태와 액션을 소유한다. + +- `sidebarOpen` +- `theme` +- `density` +- `toggleSidebar` +- `setSidebarOpen` +- `setTheme` +- `setDensity` + +이 store에는 프로젝트 데이터, 요청 상태, API 응답 데이터를 넣지 않는다. + +## 폼 설계 + +`CreateProjectDialog`는 다음 패턴을 보여준다. + +- shared `Dialog`, `Button`, `Input`, `Label`, form error UI 사용 +- React Hook Form 기반 form state +- Zod 기반 validation +- 프로젝트 생성 mutation +- 성공 후 query invalidation +- 성공 후 Dialog 닫기와 form reset + +폼 필드는 다음으로 제한한다. + +- 프로젝트 이름 +- 담당자 +- 상태 +- 마감일 +- 짧은 설명 + +## 제안 소스 구조 + +```txt +src/ + app/ + App.tsx + router.tsx + providers/ + AppProviders.tsx + QueryProvider.tsx + styles/ + index.css + layouts/ + DashboardLayout.tsx + AuthLayout.tsx + routes/ + __root.tsx + index.tsx + signin.tsx + _dashboard.tsx + _dashboard/ + projects.index.tsx + projects.$projectId.tsx + settings.tsx + features/ + dashboard/ + pages/ + DashboardPage.tsx + components/ + MetricCard.tsx + RecentProjects.tsx + projects/ + api/ + project-api.ts + components/ + CreateProjectDialog.tsx + ProjectCard.tsx + ProjectFilters.tsx + ProjectStatusBadge.tsx + hooks/ + use-project-filters.ts + model/ + project-schema.ts + project-types.ts + project-utils.ts + pages/ + ProjectDetailPage.tsx + ProjectsPage.tsx + queries/ + project-queries.ts + __tests__/ + project-schema.test.ts + project-utils.test.ts + CreateProjectDialog.test.tsx + ProjectsPage.integration.test.tsx + settings/ + pages/ + SettingsPage.tsx + shared/ + ui/ + badge.tsx + button.tsx + card.tsx + dialog.tsx + input.tsx + label.tsx + select.tsx + skeleton.tsx + lib/ + cn.ts + format-date.ts + test/ + render-with-providers.tsx + test-query-client.ts + stores/ + ui-store.ts + mocks/ + browser.ts + handlers.ts + server.ts + data/ + projects.ts + test/ + setup.ts + main.tsx + routeTree.gen.ts + vite-env.d.ts +``` + +## 설정 파일 + +root 레벨 설정 파일은 다음 형태로 구성한다. + +```txt +mise.toml +package.json +pnpm-lock.yaml +vite.config.ts +vitest.config.ts +eslint.config.js +prettier.config.js +tsconfig.json +tsconfig.app.json +tsconfig.node.json +tsconfig.vitest.json +tsr.config.json +README.md +``` + +삭제하거나 대체할 항목은 다음과 같다. + +- `src/App.css` +- `src/index.css`의 Tailwind v3 directive +- `tailwind.config.js` +- `postcss.config.js` +- 기존 `src/components/layouts` +- 기존 `src/components/modal` +- 새 route 구조와 충돌하는 기존 route 파일 + +## 패키지와 도구 방향 + +runtime과 core dependency는 다음을 사용한다. + +- React 19 +- Vite 최신 major +- TypeScript 최신 major +- `@tanstack/react-router` +- `@tanstack/router-plugin` +- `@tanstack/router-cli` +- `@tanstack/react-query` +- `zustand` +- `tailwindcss` +- `@tailwindcss/vite` +- Radix primitive +- `react-hook-form` +- `zod` +- `@hookform/resolvers` +- `msw` +- `clsx` +- `tailwind-merge` +- `class-variance-authority` + +development dependency는 다음을 사용한다. + +- `vitest` +- `@testing-library/react` +- `@testing-library/user-event` +- `@testing-library/jest-dom` +- `jsdom` +- `eslint` +- `typescript-eslint` +- `eslint-plugin-react-hooks` +- `eslint-plugin-react-refresh` +- `eslint-plugin-testing-library` +- `prettier` +- `prettier-plugin-tailwindcss` + +## 스크립트 + +스크립트는 다음 형태로 구성한다. + +```json +{ + "dev": "pnpm generate-routes && vite", + "build": "pnpm generate-routes && tsc -b && vite build", + "preview": "vite preview", + "generate-routes": "tsr generate", + "watch-routes": "tsr watch", + "typecheck": "tsc -b --pretty false", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write --cache", + "format:check": "prettier . --check --cache", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "validate": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm test && pnpm build" +} +``` + +## Tailwind v4 방향 + +- `@tailwindcss/vite` plugin을 사용한다. +- 앱 stylesheet에는 `@import "tailwindcss";`를 사용한다. +- design token은 CSS variable로 정의한다. +- daisyUI는 제거한다. +- shared UI는 Tailwind utility와 작은 composition helper로 직접 구성한다. + +## ESLint와 Prettier 방향 + +- ESLint flat config를 유지한다. +- ESLint는 코드 품질을 담당한다. +- Prettier는 포맷팅을 담당한다. +- Prettier를 ESLint rule로 실행하지 않는다. +- generated route tree, build output, coverage output, local brainstorm artifact를 ignore한다. +- test file에는 Testing Library lint 지원을 추가한다. + +## 테스트 전략 + +Vitest와 jsdom을 사용한다. + +Unit 테스트: + +- 프로젝트 schema validation +- 프로젝트 filter와 sort utility +- formatting helper + +Component 테스트: + +- 동작 검증이 필요한 shared UI primitive +- `ProjectStatusBadge` +- `CreateProjectDialog` + +Integration 테스트: + +- MSW test server +- 테스트마다 새로 만드는 QueryClientProvider +- 프로젝트 목록 loading +- empty state와 error state +- 사용자 입력부터 mutation 성공과 목록 갱신까지 이어지는 프로젝트 생성 flow + +이번 범위에는 E2E 테스트를 넣지 않는다. + +## 복잡도 + +- 프로젝트 필터링: 시간 복잡도 `O(n)`, 공간 복잡도 `O(n)` +- 프로젝트 정렬: 시간 복잡도 `O(n log n)`, 공간 복잡도 `O(n)` +- 프로젝트 목록 렌더링: 시간 복잡도 `O(n)`, 공간 복잡도 `O(n)` +- 프로젝트 상세 렌더링: 응답 데이터가 준비된 뒤 앱 코드 기준 시간 복잡도 `O(1)`, 공간 복잡도 `O(1)` +- Zustand UI 상태 업데이트: 시간 복잡도 `O(1)`, 공간 복잡도 `O(1)` +- 안정적인 Query key 기반 cache 조회: 앱 코드 관점에서 평균 `O(1)`로 취급 +- 전체 validate 스크립트: 소스 파일 수 `f`, 테스트 수 `t`, 모듈 그래프 크기 `m` 기준 대략 `O(f + t + m)` + +## 주의사항 + +> - route 파일은 얇게 유지한다. route 파일에 filtering, mutation, form logic이 들어가기 시작하면 feature 폴더로 이동한다. +> - 서버 데이터를 Zustand에 저장하지 않는다. TanStack Query가 request lifecycle, cache, retry, invalidation을 담당한다. +> - `shared`는 도메인에 의존하지 않는다. shared UI와 utility는 project feature type을 import하지 않는다. +> - MSW handler는 직접 in-memory 함수를 호출하는 우회로가 아니라 API boundary로 취급한다. +> - auth route는 layout과 UI 예제로만 유지한다. 실제 인증 구현은 이 템플릿 재구성 범위 밖이다. +> - `src/routeTree.gen.ts`는 직접 수정하지 않는다. TanStack Router CLI로 재생성한다. + +## 구현 범위 밖 + +이번 설계는 다음 항목을 의도적으로 제외한다. + +- 실제 백엔드 연동 +- 실제 인증 +- E2E 브라우저 테스트 +- 무거운 charting library +- 서버 데이터를 위한 global state +- 대형 design system + +위 항목들은 나중에 확장할 수 있지만, 첫 번째 재구성은 깨끗하고 현대적인 React sample template에 집중한다. From 986f321578323dfb6376dec25245d8ca557217db Mon Sep 17 00:00:00 2001 From: ydj515 Date: Thu, 2 Jul 2026 01:23:53 +0900 Subject: [PATCH 02/60] docs: add React dashboard template plan - Add a task-by-task implementation plan for rebuilding the React sample - Map the approved dashboard template design to tooling, routing, state, API, UI, and test tasks - Include verification commands and execution options before implementation --- .../2026-07-02-react-dashboard-template.md | 3121 +++++++++++++++++ 1 file changed, 3121 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-react-dashboard-template.md diff --git a/docs/superpowers/plans/2026-07-02-react-dashboard-template.md b/docs/superpowers/plans/2026-07-02-react-dashboard-template.md new file mode 100644 index 0000000..56c628a --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-react-dashboard-template.md @@ -0,0 +1,3121 @@ +# React Sample Dashboard Template 구현 계획 + +> **agentic worker 필수:** 이 계획을 task 단위로 실행할 때는 `superpowers:subagent-driven-development`를 권장하며, inline으로 실행할 경우 `superpowers:executing-plans`를 사용한다. 각 단계는 추적 가능한 checkbox 문법을 사용한다. + +**Goal:** 기존 React 샘플을 Node 24.13.0, React 19, Tailwind CSS v4, TanStack Router, TanStack Query, Zustand, MSW, React Hook Form, Zod, Vitest 기반의 실무형 프로젝트 대시보드 템플릿으로 재구성한다. + +**Architecture:** feature-first 구조를 사용하고 TanStack Router route 파일은 얇게 유지한다. 서버 상태는 TanStack Query, 클라이언트 UI 상태는 Zustand, mock API는 MSW, 폼 검증은 React Hook Form과 Zod가 담당한다. + +**Tech Stack:** Node 24.13.0, pnpm, React, Vite, TypeScript, Tailwind CSS v4, TanStack Router, TanStack Query, Zustand, MSW, Radix primitives, React Hook Form, Zod, Vitest, Testing Library, ESLint, Prettier. + +--- + +## 작업 전 기준 + +- 현재 브랜치: `feat/react-dashboard-template` +- 설계 문서: `docs/superpowers/specs/2026-07-02-react-sample-dashboard-template-design.md` +- 브라우저 brainstorming 산출물 `.superpowers/`는 source artifact가 아니므로 `.gitignore`에 추가한다. +- 기존 `src` 구조는 유지 대상이 아니며, 충돌하는 파일은 새 구조로 대체한다. + +## 파일 구조 지도 + +### 생성 또는 대체할 설정 파일 + +- Create: `mise.toml` - Node 24.13.0 고정 +- Modify: `package.json` - dependency, script, engine, packageManager 정리 +- Modify: `vite.config.ts` - React, TanStack Router, Tailwind v4, tsconfig paths 설정 +- Create: `vitest.config.ts` - jsdom 테스트 설정 +- Modify: `eslint.config.js` - flat config, TS/React/Test lint 설정 +- Create: `prettier.config.js` - Prettier와 Tailwind class sort 설정 +- Modify: `tsconfig.json` - project references 유지 +- Modify: `tsconfig.app.json` - app TS 옵션과 alias +- Modify: `tsconfig.node.json` - config 파일 TS 옵션 +- Create: `tsconfig.vitest.json` - 테스트 TS 옵션 +- Modify: `tsr.config.json` - TanStack Router route 설정 +- Modify: `.gitignore` - `.superpowers`, coverage, build info 추가 +- Delete: `postcss.config.js` +- Delete: `tailwind.config.js` +- Delete: `src/App.css` + +### 생성할 source 파일 + +- `src/main.tsx` - 앱 entry +- `src/app/App.tsx` - RouterProvider 연결 +- `src/app/router.tsx` - router instance와 타입 등록 +- `src/app/providers/AppProviders.tsx` - 전체 provider 조합 +- `src/app/providers/QueryProvider.tsx` - QueryClient provider +- `src/app/styles/index.css` - Tailwind v4 import와 design token +- `src/layouts/DashboardLayout.tsx` - app shell +- `src/layouts/AuthLayout.tsx` - signin shell +- `src/routes/__root.tsx` - root route +- `src/routes/signin.tsx` - signin route +- `src/routes/_dashboard.tsx` - dashboard layout route +- `src/routes/_dashboard/index.tsx` - dashboard home route +- `src/routes/_dashboard/projects.index.tsx` - project list route +- `src/routes/_dashboard/projects.$projectId.tsx` - project detail route +- `src/routes/_dashboard/settings.tsx` - settings route +- `src/features/dashboard/pages/DashboardPage.tsx` +- `src/features/dashboard/components/MetricCard.tsx` +- `src/features/dashboard/components/RecentProjects.tsx` +- `src/features/projects/api/project-api.ts` +- `src/features/projects/components/CreateProjectDialog.tsx` +- `src/features/projects/components/ProjectCard.tsx` +- `src/features/projects/components/ProjectFilters.tsx` +- `src/features/projects/components/ProjectStatusBadge.tsx` +- `src/features/projects/hooks/use-project-filters.ts` +- `src/features/projects/model/project-schema.ts` +- `src/features/projects/model/project-types.ts` +- `src/features/projects/model/project-utils.ts` +- `src/features/projects/pages/ProjectDetailPage.tsx` +- `src/features/projects/pages/ProjectsPage.tsx` +- `src/features/projects/queries/project-queries.ts` +- `src/features/settings/pages/SettingsPage.tsx` +- `src/shared/ui/badge.tsx` +- `src/shared/ui/button.tsx` +- `src/shared/ui/card.tsx` +- `src/shared/ui/dialog.tsx` +- `src/shared/ui/input.tsx` +- `src/shared/ui/label.tsx` +- `src/shared/ui/select.tsx` +- `src/shared/ui/skeleton.tsx` +- `src/shared/lib/cn.ts` +- `src/shared/lib/format-date.ts` +- `src/shared/lib/test/render-with-providers.tsx` +- `src/shared/lib/test/test-query-client.ts` +- `src/stores/ui-store.ts` +- `src/mocks/browser.ts` +- `src/mocks/handlers.ts` +- `src/mocks/server.ts` +- `src/mocks/data/projects.ts` +- `src/test/setup.ts` + +### 생성할 테스트 파일 + +- `src/shared/lib/cn.test.ts` +- `src/shared/lib/format-date.test.ts` +- `src/stores/ui-store.test.ts` +- `src/features/projects/__tests__/project-schema.test.ts` +- `src/features/projects/__tests__/project-utils.test.ts` +- `src/features/projects/__tests__/project-api.integration.test.ts` +- `src/features/projects/__tests__/ProjectStatusBadge.test.tsx` +- `src/features/projects/__tests__/CreateProjectDialog.test.tsx` +- `src/features/projects/__tests__/ProjectsPage.integration.test.tsx` +- `src/layouts/DashboardLayout.test.tsx` + +--- + +## Task 1: 런타임, 패키지, 설정 파일 최신화 + +**Files:** +- Create: `mise.toml` +- Modify: `package.json` +- Modify: `vite.config.ts` +- Create: `vitest.config.ts` +- Modify: `eslint.config.js` +- Create: `prettier.config.js` +- Modify: `tsconfig.json` +- Modify: `tsconfig.app.json` +- Modify: `tsconfig.node.json` +- Create: `tsconfig.vitest.json` +- Modify: `tsr.config.json` +- Modify: `.gitignore` +- Delete: `postcss.config.js` +- Delete: `tailwind.config.js` +- Delete: `src/App.css` + +- [ ] **Step 1: 현재 런타임 기준 확인** + +Run: + +```bash +node --version +pnpm --version +git status --short --branch +``` + +Expected: + +```txt +v24.13.0 +11.7.0 +## feat/react-dashboard-template +``` + +- [ ] **Step 2: dependency 설치** + +Run: + +```bash +pnpm add react@latest react-dom@latest @tanstack/react-router@latest @tanstack/react-query@latest zustand@latest @radix-ui/react-dialog@latest @radix-ui/react-slot@latest react-hook-form@latest zod@latest @hookform/resolvers@latest msw@latest clsx@latest tailwind-merge@latest class-variance-authority@latest +pnpm add -D @vitejs/plugin-react-swc@latest vite@latest typescript@latest @types/node@latest @types/react@latest @types/react-dom@latest tailwindcss@latest @tailwindcss/vite@latest vite-tsconfig-paths@latest @tanstack/router-cli@latest @tanstack/router-plugin@latest vitest@latest jsdom@latest @testing-library/react@latest @testing-library/user-event@latest @testing-library/jest-dom@latest @vitest/coverage-v8@latest eslint@latest @eslint/js@latest typescript-eslint@latest eslint-plugin-react-hooks@latest eslint-plugin-react-refresh@latest eslint-plugin-testing-library@latest globals@latest prettier@latest prettier-plugin-tailwindcss@latest +``` + +Expected: + +```txt +dependencies: ++ @tanstack/react-query ++ @tanstack/react-router ++ zustand ++ msw +devDependencies: ++ vite ++ vitest ++ tailwindcss ++ @tailwindcss/vite +``` + +- [ ] **Step 3: `mise.toml` 작성** + +Write: + +```toml +[tools] +node = "24.13.0" +``` + +- [ ] **Step 4: `package.json` script와 metadata 정리** + +Set these fields: + +```json +{ + "name": "react-sample", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@11.7.0", + "engines": { + "node": "24.13.0", + "pnpm": ">=11.7.0" + }, + "scripts": { + "preinstall": "npx only-allow pnpm", + "dev": "pnpm generate-routes && vite", + "build": "pnpm generate-routes && tsc -b && vite build", + "preview": "vite preview", + "generate-routes": "tsr generate", + "watch-routes": "tsr watch", + "typecheck": "tsc -b --pretty false", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write --cache", + "format:check": "prettier . --check --cache", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "validate": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm test && pnpm build" + } +} +``` + +- [ ] **Step 5: `vite.config.ts` 작성** + +Write: + +```ts +import tailwindcss from "@tailwindcss/vite"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + TanStackRouterVite({ + routesDirectory: "./src/routes", + generatedRouteTree: "./src/routeTree.gen.ts", + }), + react(), + tailwindcss(), + tsconfigPaths(), + ], +}); +``` + +- [ ] **Step 6: `vitest.config.ts` 작성** + +Write: + +```ts +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + css: true, + coverage: { + provider: "v8", + reporter: ["text", "html"], + exclude: ["src/routeTree.gen.ts", "src/test/**", "src/**/*.d.ts"], + }, + }, +}); +``` + +- [ ] **Step 7: ESLint와 Prettier 설정 작성** + +Write `eslint.config.js`: + +```js +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import testingLibrary from "eslint-plugin-testing-library"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "dist", + "coverage", + ".superpowers", + "src/routeTree.gen.ts", + "*.tsbuildinfo", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2024, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, + { + files: ["**/*.{test,spec}.{ts,tsx}"], + ...testingLibrary.configs["flat/react"], + }, +); +``` + +Write `prettier.config.js`: + +```js +export default { + plugins: ["prettier-plugin-tailwindcss"], +}; +``` + +- [ ] **Step 8: TS 설정 작성** + +Write `tsconfig.json`: + +```json +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.vitest.json" } + ] +} +``` + +Write `tsconfig.app.json`: + +```json +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} +``` + +Write `tsconfig.node.json`: + +```json +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "eslint.config.js", + "prettier.config.js" + ] +} +``` + +Write `tsconfig.vitest.json`: + +```json +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**/*.ts"] +} +``` + +- [ ] **Step 9: 라우터 설정과 ignore 정리** + +Write `tsr.config.json`: + +```json +{ + "routesDirectory": "./src/routes", + "generatedRouteTree": "./src/routeTree.gen.ts", + "quoteStyle": "double", + "semicolons": true +} +``` + +Append to `.gitignore`: + +```gitignore + +# Local agent artifacts +.superpowers/ +.worktrees/ + +# Test and build output +coverage/ +*.tsbuildinfo +``` + +- [ ] **Step 10: 기존 설정 파일 제거** + +Run: + +```bash +rm -f postcss.config.js tailwind.config.js src/App.css +``` + +Expected: + +```txt +postcss.config.js, tailwind.config.js, src/App.css removed when present +``` + +- [ ] **Step 11: 설정 검증** + +Run: + +```bash +pnpm install +pnpm typecheck +pnpm lint +pnpm format:check +``` + +Expected: + +```txt +typecheck, lint, format:check complete without configuration loading errors +``` + +초기 source 재구성 전에는 missing module 오류가 날 수 있다. 이 경우 Task 2 이후 다시 실행한다. + +- [ ] **Step 12: Commit** + +Run: + +```bash +git add .gitignore mise.toml package.json pnpm-lock.yaml vite.config.ts vitest.config.ts eslint.config.js prettier.config.js tsconfig.json tsconfig.app.json tsconfig.node.json tsconfig.vitest.json tsr.config.json +git add -u postcss.config.js tailwind.config.js src/App.css +git commit -m "chore: modernize React project tooling" +``` + +--- + +## Task 2: 테스트 기반 shared utility와 test harness 구성 + +**Files:** +- Create: `src/shared/lib/cn.test.ts` +- Create: `src/shared/lib/format-date.test.ts` +- Create: `src/shared/lib/cn.ts` +- Create: `src/shared/lib/format-date.ts` +- Create: `src/shared/lib/test/test-query-client.ts` +- Create: `src/shared/lib/test/render-with-providers.tsx` +- Create: `src/test/setup.ts` + +- [ ] **Step 1: `cn` 실패 테스트 작성** + +Write `src/shared/lib/cn.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { cn } from "@/shared/lib/cn"; + +describe("cn", () => { + it("조건부 class와 Tailwind 충돌 class를 정리한다", () => { + expect(cn("px-2 text-sm", false && "hidden", "px-4")).toBe( + "text-sm px-4", + ); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +pnpm test src/shared/lib/cn.test.ts +``` + +Expected: + +```txt +FAIL src/shared/lib/cn.test.ts +Cannot find module '@/shared/lib/cn' +``` + +- [ ] **Step 3: `cn` 구현** + +Write `src/shared/lib/cn.ts`: + +```ts +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 4: `cn` 통과 확인** + +Run: + +```bash +pnpm test src/shared/lib/cn.test.ts +``` + +Expected: + +```txt +PASS src/shared/lib/cn.test.ts +``` + +- [ ] **Step 5: `formatDate` 실패 테스트 작성** + +Write `src/shared/lib/format-date.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { formatDate } from "@/shared/lib/format-date"; + +describe("formatDate", () => { + it("ISO 날짜를 한국어 UI에 맞는 짧은 날짜로 변환한다", () => { + expect(formatDate("2026-07-02")).toBe("2026. 07. 02."); + }); +}); +``` + +- [ ] **Step 6: 실패 확인** + +Run: + +```bash +pnpm test src/shared/lib/format-date.test.ts +``` + +Expected: + +```txt +FAIL src/shared/lib/format-date.test.ts +Cannot find module '@/shared/lib/format-date' +``` + +- [ ] **Step 7: `formatDate` 구현** + +Write `src/shared/lib/format-date.ts`: + +```ts +export function formatDate(value: string) { + return new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone: "Asia/Seoul", + }).format(new Date(`${value}T00:00:00+09:00`)); +} +``` + +- [ ] **Step 8: test setup 작성** + +Write `src/test/setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +MSW lifecycle은 `src/mocks/server.ts`가 생기는 Task 4에서 이 파일에 추가한다. + +- [ ] **Step 9: test helper 작성** + +Write `src/shared/lib/test/test-query-client.ts`: + +```ts +import { QueryClient } from "@tanstack/react-query"; + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); +} +``` + +Write `src/shared/lib/test/render-with-providers.tsx`: + +```tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderOptions } from "@testing-library/react"; +import type { ReactElement, ReactNode } from "react"; + +import { createTestQueryClient } from "@/shared/lib/test/test-query-client"; + +function TestProviders({ children }: { children: ReactNode }) { + const queryClient = createTestQueryClient(); + + return ( + {children} + ); +} + +export function renderWithProviders( + ui: ReactElement, + options?: Omit, +) { + return render(ui, { wrapper: TestProviders, ...options }); +} +``` + +- [ ] **Step 10: Commit** + +Run: + +```bash +git add src/shared/lib src/test/setup.ts +git commit -m "test: add shared test utilities" +``` + +--- + +## Task 3: 프로젝트 도메인 모델, schema, utility 구현 + +**Files:** +- Create: `src/features/projects/__tests__/project-schema.test.ts` +- Create: `src/features/projects/__tests__/project-utils.test.ts` +- Create: `src/features/projects/model/project-types.ts` +- Create: `src/features/projects/model/project-schema.ts` +- Create: `src/features/projects/model/project-utils.ts` +- Create: `src/mocks/data/projects.ts` + +- [ ] **Step 1: schema 실패 테스트 작성** + +Write `src/features/projects/__tests__/project-schema.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { createProjectSchema } from "@/features/projects/model/project-schema"; + +describe("createProjectSchema", () => { + it("프로젝트 생성 입력을 검증한다", () => { + const result = createProjectSchema.safeParse({ + name: "Design System", + owner: "Mina", + status: "active", + dueDate: "2026-08-01", + description: "공통 UI 기반을 정리한다.", + }); + + expect(result.success).toBe(true); + }); + + it("짧은 이름과 잘못된 상태를 거부한다", () => { + const result = createProjectSchema.safeParse({ + name: "A", + owner: "", + status: "unknown", + dueDate: "not-date", + description: "", + }); + + expect(result.success).toBe(false); + }); +}); +``` + +- [ ] **Step 2: utility 실패 테스트 작성** + +Write `src/features/projects/__tests__/project-utils.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import type { Project } from "@/features/projects/model/project-types"; +import { + filterProjects, + getProjectStatusCounts, + sortProjects, +} from "@/features/projects/model/project-utils"; + +const projects: Project[] = [ + { + id: "p-1", + name: "Design System", + owner: "Mina", + status: "active", + dueDate: "2026-08-01", + description: "공통 UI 기반", + updatedAt: "2026-07-01T09:00:00.000Z", + }, + { + id: "p-2", + name: "Billing Revamp", + owner: "Joon", + status: "paused", + dueDate: "2026-07-20", + description: "결제 플로우 정리", + updatedAt: "2026-06-20T09:00:00.000Z", + }, +]; + +describe("project-utils", () => { + it("검색어와 상태로 프로젝트를 필터링한다", () => { + expect( + filterProjects(projects, { search: "design", status: "active" }), + ).toHaveLength(1); + }); + + it("마감일 오름차순으로 정렬한다", () => { + expect(sortProjects(projects, "dueDate")[0]?.id).toBe("p-2"); + }); + + it("상태별 개수를 계산한다", () => { + expect(getProjectStatusCounts(projects)).toEqual({ + active: 1, + paused: 1, + completed: 0, + }); + }); +}); +``` + +- [ ] **Step 3: 실패 확인** + +Run: + +```bash +pnpm test src/features/projects/__tests__/project-schema.test.ts src/features/projects/__tests__/project-utils.test.ts +``` + +Expected: + +```txt +FAIL Cannot find module '@/features/projects/model/project-schema' +FAIL Cannot find module '@/features/projects/model/project-utils' +``` + +- [ ] **Step 4: 타입과 schema 구현** + +Write `src/features/projects/model/project-types.ts`: + +```ts +export const projectStatuses = ["active", "paused", "completed"] as const; + +export type ProjectStatus = (typeof projectStatuses)[number]; + +export type Project = { + id: string; + name: string; + owner: string; + status: ProjectStatus; + dueDate: string; + description: string; + updatedAt: string; +}; + +export type CreateProjectInput = { + name: string; + owner: string; + status: ProjectStatus; + dueDate: string; + description: string; +}; + +export type ProjectFilters = { + search: string; + status: ProjectStatus | "all"; +}; + +export type ProjectSortKey = "name" | "dueDate" | "updatedAt"; +``` + +Write `src/features/projects/model/project-schema.ts`: + +```ts +import { z } from "zod"; + +import { projectStatuses } from "@/features/projects/model/project-types"; + +export const createProjectSchema = z.object({ + name: z.string().trim().min(2, "프로젝트 이름은 2자 이상이어야 합니다."), + owner: z.string().trim().min(1, "담당자를 입력해주세요."), + status: z.enum(projectStatuses), + dueDate: z.iso.date("마감일을 YYYY-MM-DD 형식으로 입력해주세요."), + description: z.string().trim().min(1, "설명을 입력해주세요.").max(120), +}); + +export type CreateProjectFormValues = z.infer; +``` + +- [ ] **Step 5: utility 구현** + +Write `src/features/projects/model/project-utils.ts`: + +```ts +import type { + Project, + ProjectFilters, + ProjectSortKey, + ProjectStatus, +} from "@/features/projects/model/project-types"; + +export function filterProjects(projects: Project[], filters: ProjectFilters) { + const search = filters.search.trim().toLowerCase(); + + return projects.filter((project) => { + const matchesSearch = + search.length === 0 || + project.name.toLowerCase().includes(search) || + project.owner.toLowerCase().includes(search); + const matchesStatus = + filters.status === "all" || project.status === filters.status; + + return matchesSearch && matchesStatus; + }); +} + +export function sortProjects(projects: Project[], sortKey: ProjectSortKey) { + return [...projects].sort((a, b) => { + if (sortKey === "name") { + return a.name.localeCompare(b.name); + } + + return a[sortKey].localeCompare(b[sortKey]); + }); +} + +export function getProjectStatusCounts(projects: Project[]) { + const initial: Record = { + active: 0, + paused: 0, + completed: 0, + }; + + return projects.reduce((counts, project) => { + counts[project.status] += 1; + return counts; + }, initial); +} +``` + +- [ ] **Step 6: fixture 구현** + +Write `src/mocks/data/projects.ts`: + +```ts +import type { Project } from "@/features/projects/model/project-types"; + +export const projectsFixture: Project[] = [ + { + id: "project-design-system", + name: "Design System", + owner: "Mina", + status: "active", + dueDate: "2026-08-01", + description: "공통 UI 컴포넌트와 토큰을 정리합니다.", + updatedAt: "2026-07-01T09:00:00.000Z", + }, + { + id: "project-dashboard-refresh", + name: "Dashboard Refresh", + owner: "Joon", + status: "active", + dueDate: "2026-07-24", + description: "운영 대시보드의 정보 구조를 개선합니다.", + updatedAt: "2026-06-28T09:00:00.000Z", + }, + { + id: "project-billing-revamp", + name: "Billing Revamp", + owner: "Ara", + status: "paused", + dueDate: "2026-09-12", + description: "결제 플로우를 단순화합니다.", + updatedAt: "2026-06-20T09:00:00.000Z", + }, + { + id: "project-onboarding", + name: "Onboarding Flow", + owner: "Hwan", + status: "completed", + dueDate: "2026-06-30", + description: "신규 사용자 온보딩을 개선합니다.", + updatedAt: "2026-06-30T09:00:00.000Z", + }, +]; +``` + +- [ ] **Step 7: 통과 확인** + +Run: + +```bash +pnpm test src/features/projects/__tests__/project-schema.test.ts src/features/projects/__tests__/project-utils.test.ts +``` + +Expected: + +```txt +PASS src/features/projects/__tests__/project-schema.test.ts +PASS src/features/projects/__tests__/project-utils.test.ts +``` + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add src/features/projects src/mocks/data/projects.ts +git commit -m "feat: add project domain model" +``` + +--- + +## Task 4: MSW mock API와 project API/query 계층 구성 + +**Files:** +- Create: `src/features/projects/__tests__/project-api.integration.test.ts` +- Create: `src/mocks/handlers.ts` +- Create: `src/mocks/server.ts` +- Create: `src/mocks/browser.ts` +- Create: `src/features/projects/api/project-api.ts` +- Create: `src/features/projects/queries/project-queries.ts` +- Modify: `src/test/setup.ts` + +- [ ] **Step 1: API integration 실패 테스트 작성** + +Write `src/features/projects/__tests__/project-api.integration.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { + createProject, + getProject, + getProjects, + updateProjectStatus, +} from "@/features/projects/api/project-api"; + +describe("project-api", () => { + it("프로젝트 목록을 HTTP boundary를 통해 가져온다", async () => { + const projects = await getProjects(); + + expect(projects.length).toBeGreaterThan(0); + expect(projects[0]).toHaveProperty("id"); + }); + + it("프로젝트를 생성하고 상세 조회를 할 수 있다", async () => { + const created = await createProject({ + name: "Search Experience", + owner: "Nari", + status: "active", + dueDate: "2026-10-01", + description: "검색 경험을 개선합니다.", + }); + + const detail = await getProject(created.id); + + expect(detail.name).toBe("Search Experience"); + }); + + it("프로젝트 상태를 변경한다", async () => { + const updated = await updateProjectStatus("project-design-system", "paused"); + + expect(updated.status).toBe("paused"); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +pnpm test src/features/projects/__tests__/project-api.integration.test.ts +``` + +Expected: + +```txt +FAIL Cannot find module '@/features/projects/api/project-api' +``` + +- [ ] **Step 3: MSW handler 구현** + +Write `src/mocks/handlers.ts`: + +```ts +import { http, HttpResponse } from "msw"; + +import type { + CreateProjectInput, + Project, + ProjectStatus, +} from "@/features/projects/model/project-types"; +import { projectsFixture } from "@/mocks/data/projects"; + +let projects: Project[] = structuredClone(projectsFixture); + +export function resetProjectsMockData() { + projects = structuredClone(projectsFixture); +} + +export const handlers = [ + http.get("/api/projects", () => { + return HttpResponse.json(projects); + }), + + http.get("/api/projects/:projectId", ({ params }) => { + const project = projects.find((item) => item.id === params.projectId); + + if (!project) { + return HttpResponse.json( + { message: "프로젝트를 찾을 수 없습니다." }, + { status: 404 }, + ); + } + + return HttpResponse.json(project); + }), + + http.post("/api/projects", async ({ request }) => { + const body = (await request.json()) as CreateProjectInput; + const project: Project = { + ...body, + id: `project-${crypto.randomUUID()}`, + updatedAt: new Date().toISOString(), + }; + + projects = [project, ...projects]; + + return HttpResponse.json(project, { status: 201 }); + }), + + http.patch("/api/projects/:projectId/status", async ({ params, request }) => { + const body = (await request.json()) as { status: ProjectStatus }; + const project = projects.find((item) => item.id === params.projectId); + + if (!project) { + return HttpResponse.json( + { message: "프로젝트를 찾을 수 없습니다." }, + { status: 404 }, + ); + } + + const updated: Project = { + ...project, + status: body.status, + updatedAt: new Date().toISOString(), + }; + + projects = projects.map((item) => (item.id === updated.id ? updated : item)); + + return HttpResponse.json(updated); + }), +]; +``` + +- [ ] **Step 4: MSW setup 구현** + +Write `src/mocks/server.ts`: + +```ts +import { setupServer } from "msw/node"; + +import { handlers, resetProjectsMockData } from "@/mocks/handlers"; + +export const server = setupServer(...handlers); + +export { resetProjectsMockData }; +``` + +Write `src/mocks/browser.ts`: + +```ts +import { setupWorker } from "msw/browser"; + +import { handlers } from "@/mocks/handlers"; + +export const worker = setupWorker(...handlers); +``` + +Update `src/test/setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; + +import { resetProjectsMockData, server } from "@/mocks/server"; + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterEach(() => { + server.resetHandlers(); + resetProjectsMockData(); +}); + +afterAll(() => { + server.close(); +}); +``` + +- [ ] **Step 5: API 함수 구현** + +Write `src/features/projects/api/project-api.ts`: + +```ts +import type { + CreateProjectInput, + Project, + ProjectStatus, +} from "@/features/projects/model/project-types"; + +async function parseResponse(response: Response): Promise { + if (!response.ok) { + const error = (await response.json().catch(() => null)) as { + message?: string; + } | null; + + throw new Error(error?.message ?? "요청을 처리하지 못했습니다."); + } + + return response.json() as Promise; +} + +export async function getProjects() { + const response = await fetch("/api/projects"); + return parseResponse(response); +} + +export async function getProject(projectId: string) { + const response = await fetch(`/api/projects/${projectId}`); + return parseResponse(response); +} + +export async function createProject(input: CreateProjectInput) { + const response = await fetch("/api/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + return parseResponse(response); +} + +export async function updateProjectStatus( + projectId: string, + status: ProjectStatus, +) { + const response = await fetch(`/api/projects/${projectId}/status`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status }), + }); + + return parseResponse(response); +} +``` + +- [ ] **Step 6: query hook 구현** + +Write `src/features/projects/queries/project-queries.ts`: + +```ts +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { + createProject, + getProject, + getProjects, + updateProjectStatus, +} from "@/features/projects/api/project-api"; +import type { + CreateProjectInput, + ProjectStatus, +} from "@/features/projects/model/project-types"; + +export const projectKeys = { + all: ["projects"] as const, + lists: () => [...projectKeys.all, "list"] as const, + detail: (projectId: string) => [...projectKeys.all, "detail", projectId] as const, +}; + +export function projectsQueryOptions() { + return queryOptions({ + queryKey: projectKeys.lists(), + queryFn: getProjects, + }); +} + +export function projectQueryOptions(projectId: string) { + return queryOptions({ + queryKey: projectKeys.detail(projectId), + queryFn: () => getProject(projectId), + }); +} + +export function useProjectsQuery() { + return useQuery(projectsQueryOptions()); +} + +export function useProjectQuery(projectId: string) { + return useQuery(projectQueryOptions(projectId)); +} + +export function useCreateProjectMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateProjectInput) => createProject(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + +export function useUpdateProjectStatusMutation(projectId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (status: ProjectStatus) => updateProjectStatus(projectId, status), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: projectKeys.all }); + void queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) }); + }, + }); +} +``` + +- [ ] **Step 7: 통과 확인** + +Run: + +```bash +pnpm test src/features/projects/__tests__/project-api.integration.test.ts +``` + +Expected: + +```txt +PASS src/features/projects/__tests__/project-api.integration.test.ts +``` + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add src/mocks src/test/setup.ts src/features/projects/api src/features/projects/queries src/features/projects/__tests__/project-api.integration.test.ts +git commit -m "feat: add MSW project API" +``` + +--- + +## Task 5: shared UI primitive와 project status component 구현 + +**Files:** +- Create: `src/features/projects/__tests__/ProjectStatusBadge.test.tsx` +- Create: `src/shared/ui/button.tsx` +- Create: `src/shared/ui/badge.tsx` +- Create: `src/shared/ui/card.tsx` +- Create: `src/shared/ui/dialog.tsx` +- Create: `src/shared/ui/input.tsx` +- Create: `src/shared/ui/label.tsx` +- Create: `src/shared/ui/select.tsx` +- Create: `src/shared/ui/skeleton.tsx` +- Create: `src/features/projects/components/ProjectStatusBadge.tsx` + +- [ ] **Step 1: status badge 실패 테스트 작성** + +Write `src/features/projects/__tests__/ProjectStatusBadge.test.tsx`: + +```tsx +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ProjectStatusBadge } from "@/features/projects/components/ProjectStatusBadge"; +import { renderWithProviders } from "@/shared/lib/test/render-with-providers"; + +describe("ProjectStatusBadge", () => { + it("active 상태를 한국어 label로 보여준다", () => { + renderWithProviders(); + + expect(screen.getByText("진행 중")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +pnpm test src/features/projects/__tests__/ProjectStatusBadge.test.tsx +``` + +Expected: + +```txt +FAIL Cannot find module '@/features/projects/components/ProjectStatusBadge' +``` + +- [ ] **Step 3: shared UI primitive 구현** + +Write `src/shared/ui/button.tsx`: + +```tsx +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { ButtonHTMLAttributes } from "react"; + +import { cn } from "@/shared/lib/cn"; + +const buttonVariants = cva( + "inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-slate-950 text-white hover:bg-slate-800 focus-visible:outline-slate-950", + secondary: "bg-slate-100 text-slate-950 hover:bg-slate-200 focus-visible:outline-slate-400", + ghost: "text-slate-700 hover:bg-slate-100 focus-visible:outline-slate-400", + danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600", + }, + size: { + sm: "h-8 px-3 text-xs", + md: "h-10 px-4", + lg: "h-11 px-5", + icon: "size-10 p-0", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +export type ButtonProps = ButtonHTMLAttributes & + VariantProps & { + asChild?: boolean; + }; + +export function Button({ + asChild = false, + className, + size, + variant, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} +``` + +Write `src/shared/ui/badge.tsx`: + +```tsx +import { cva, type VariantProps } from "class-variance-authority"; +import type { HTMLAttributes } from "react"; + +import { cn } from "@/shared/lib/cn"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + { + variants: { + variant: { + neutral: "bg-slate-100 text-slate-700", + success: "bg-emerald-100 text-emerald-700", + warning: "bg-amber-100 text-amber-800", + info: "bg-sky-100 text-sky-700", + }, + }, + defaultVariants: { + variant: "neutral", + }, + }, +); + +export type BadgeProps = HTMLAttributes & + VariantProps; + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ); +} +``` + +Write the remaining shared UI files with these exports: + +```tsx +// src/shared/ui/input.tsx +import type { InputHTMLAttributes } from "react"; + +import { cn } from "@/shared/lib/cn"; + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ( + + ); +} +``` + +```tsx +// src/shared/ui/label.tsx +import type { LabelHTMLAttributes } from "react"; + +import { cn } from "@/shared/lib/cn"; + +export function Label({ className, ...props }: LabelHTMLAttributes) { + return ( +