Files
web-page/docs/superpowers/plans/2026-07-09-fe-module-refactor-subscription.md

553 lines
23 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FE 모듈 구조 컨벤션 + Subscription 분해 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `Subscription.jsx`(1647줄)를 기능 모듈 구조(shell + components/ + utils)로 **동작 보존 분해**하고, 컨벤션을 CLAUDE.md/README에 기록한다.
**Architecture:** 순수 추출(코드 이동)만. 상수·순수헬퍼→`subscriptionUtils.js`, 각 sub-component→`components/*.jsx`, `Subscription.jsx`는 shell로 축소. 각 Task는 코드를 옮기고 import를 배선한 뒤 build+전체 테스트 green으로 동작 보존을 확인. 로직/JSX/스타일 한 줄도 바꾸지 않음.
**Tech Stack:** React 18 + Vite, Vitest + @testing-library/react.
## Global Constraints
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼의 본문(JSX·로직)은 **verbatim(그대로)**. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
- 경로: `Subscription.jsx`(`src/pages/subscription/`)는 api를 `'../../api'`로 import. `components/*.jsx``'../../../api'`, utils는 `'../subscriptionUtils'`. shell은 CSS `'./Subscription.css'` import 유지.
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리.
- `/realestate/listings` 교차링크(shell의 `<Link>`)와 `PullToRefresh`/`FAB`/탭 동작 유지.
- 커밋은 web-ui 경로, 변경 파일만 `git add`. 커밋 trailer:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
```
---
### Task 1: `subscriptionUtils.js` — 상수·순수헬퍼 추출 + 테스트
**Files:**
- Create: `src/pages/subscription/subscriptionUtils.js`
- Test: `src/pages/subscription/subscriptionUtils.test.js`
- Modify: `src/pages/subscription/Subscription.jsx` (해당 정의 제거 + import 추가)
**Interfaces:**
- Produces: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`, `extractTier(reasons)`, `fmt(d)`, `fmtFull(d)`, `getDDays(d)`, `getDDayColor(d)`, `fmtDateTime(d)`, `apiPatch(path,body)`, `fmtPrice(v)`.
- [ ] **Step 1: Write the failing test** — Create `src/pages/subscription/subscriptionUtils.test.js`:
```js
import { describe, it, expect } from 'vitest';
import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
describe('extractTier', () => {
it('자치구 티어 문자열에서 등급 추출', () => {
expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
});
it('미매치/빈 입력 → null', () => {
expect(extractTier(['면적 적합'])).toBe(null);
expect(extractTier(null)).toBe(null);
});
});
describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
const iso = (offsetDays) => {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
};
it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
expect(getDDays(iso(0))).toBe('D-Day');
expect(getDDays(iso(5))).toBe('D-5');
expect(getDDays(iso(-3))).toBe('D+3');
expect(getDDays(null)).toBe(null);
});
it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
expect(getDDayColor(iso(-1))).toBe('#f87171');
expect(getDDayColor(iso(2))).toBe('#f59e0b');
expect(getDDayColor(iso(6))).toBe('#00d4ff');
expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
});
});
describe('fmtPrice (현 구현 동작 고정)', () => {
it('억/만 변환', () => {
expect(fmtPrice(10000)).toBe('1억');
expect(fmtPrice(15000)).toBe('1.5억');
expect(fmtPrice(9999)).toBe('9,999만');
expect(fmtPrice(null)).toBe(null);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js`
Expected: FAIL — `Failed to resolve import "./subscriptionUtils.js"`.
- [ ] **Step 3: Create subscriptionUtils.js** (아래 내용 — `Subscription.jsx` 현재 상수/헬퍼를 그대로 옮김; `_diffDays`는 내부 유지·미export):
```js
/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
export const STATUS_CONFIG = {
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
};
export const HOUSE_TYPE_LABELS = {
'01': '국민주택',
'02': '민영주택',
'03': '도시형생활주택',
};
export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
export const DEFAULT_PROFILE = {
name: '', age: '', is_homeless: false, is_householder: false,
subscription_months: '', subscription_amount: '',
family_members: '', has_dependents: false, children_count: '',
is_newlywed: false, marriage_months: '',
has_newborn: false, is_first_home: false, income_level: '',
preferred_regions: '', preferred_types: '',
min_area: '', max_area: '', max_price: '',
preferred_districts: {},
min_match_score: 70,
notify_enabled: true,
};
export function extractTier(reasons) {
for (const r of reasons || []) {
const m = r.match(/자치구 ([SABCD])티어/);
if (m) return m[1];
}
return null;
}
export const fmt = (d) => {
if (!d) return '-';
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
};
export const fmtFull = (d) => {
if (!d) return '-';
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
};
const _diffDays = (d) => {
if (!d) return null;
const [y, m, day] = d.split('-').map(Number);
const target = new Date(y, m - 1, day);
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((target - today) / 86400000);
};
export const getDDays = (d) => {
const diff = _diffDays(d);
if (diff === null) return null;
if (diff === 0) return 'D-Day';
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
};
export const getDDayColor = (d) => {
const diff = _diffDays(d);
if (diff === null) return 'var(--text-dim)';
if (diff <= 0) return '#f87171';
if (diff <= 3) return '#f59e0b';
if (diff <= 7) return '#00d4ff';
return 'var(--text-dim)';
};
export const fmtDateTime = (d) => {
if (!d) return '-';
return new Date(d).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
};
export async function apiPatch(path, body) {
const opts = {
method: 'PATCH',
headers: { 'Accept': 'application/json' },
};
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export const fmtPrice = (v) => {
if (v == null) return null;
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
return `${v.toLocaleString()}만`;
};
```
- [ ] **Step 4: Wire Subscription.jsx** — `src/pages/subscription/Subscription.jsx`에서: 위 상수/헬퍼의 **기존 정의(현 파일 상단 STATUS_CONFIG~fmtPrice, 로컬 apiPatch 포함)를 삭제**하고, 최상단 import 블록에 추가:
```js
import {
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
} from './subscriptionUtils';
```
(이 시점엔 컴포넌트들이 아직 Subscription.jsx 내부에 있으므로 import한 심볼을 그대로 사용. 나머지 컴포넌트 정의는 유지.)
- [ ] **Step 5: Run tests + build**
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js` → PASS.
Run: `npm run test:run` → 전체 green.
Run: `npm run build` → `✓ built` (Subscription 동작 보존).
- [ ] **Step 6: Commit**
```bash
git add src/pages/subscription/subscriptionUtils.js src/pages/subscription/subscriptionUtils.test.js src/pages/subscription/Subscription.jsx
git commit -m "refactor(subscription): 상수·순수헬퍼를 subscriptionUtils로 추출 + 테스트"
```
---
### Task 2: `StatusBadge` 컴포넌트 추출 + 스모크
**Files:**
- Create: `src/pages/subscription/components/StatusBadge.jsx`
- Test: `src/pages/subscription/components/StatusBadge.test.jsx`
- Modify: `src/pages/subscription/Subscription.jsx`
**Interfaces:**
- Consumes (Task 1): `STATUS_CONFIG`.
- Produces: `StatusBadge` 기본 export — `({ status, size })`.
- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/subscription/components/StatusBadge.test.jsx`:
```jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import StatusBadge from './StatusBadge.jsx';
describe('StatusBadge', () => {
it('status 라벨 렌더', () => {
render(<StatusBadge status="청약중" />);
expect(screen.getByText('청약중')).toBeInTheDocument();
});
it('미정의 status → "알 수 없음" 폴백', () => {
render(<StatusBadge status={null} />);
expect(screen.getByText('알 수 없음')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx`
Expected: FAIL — `Failed to resolve import "./StatusBadge.jsx"`.
- [ ] **Step 3: Create StatusBadge.jsx** (현 `Subscription.jsx`의 StatusBadge 정의를 그대로 옮기고 상단 import 추가):
```jsx
import React from 'react';
import { STATUS_CONFIG } from '../subscriptionUtils';
function StatusBadge({ status, size }) {
const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
return (
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
{status || '알 수 없음'}
</span>
);
}
export default StatusBadge;
```
- [ ] **Step 4: Wire Subscription.jsx** — StatusBadge 정의 삭제 + import 추가:
```js
import StatusBadge from './components/StatusBadge';
```
- [ ] **Step 5: Run tests + build**
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx` → PASS.
Run: `npm run test:run` → green. `npm run build` → `✓ built`.
- [ ] **Step 6: Commit**
```bash
git add src/pages/subscription/components/StatusBadge.jsx src/pages/subscription/components/StatusBadge.test.jsx src/pages/subscription/Subscription.jsx
git commit -m "refactor(subscription): StatusBadge 컴포넌트 추출 + 스모크"
```
---
### Task 3: 표현 컴포넌트 추출 (AnnouncementCard / AnnouncementDetail / CalendarView)
**Files:**
- Create: `src/pages/subscription/components/AnnouncementCard.jsx`
- Create: `src/pages/subscription/components/AnnouncementDetail.jsx`
- Create: `src/pages/subscription/components/CalendarView.jsx`
- Modify: `src/pages/subscription/Subscription.jsx`
**Interfaces:**
- Consumes (Task 1/2): utils 헬퍼, `StatusBadge`.
- Produces: 각 기본 export — `AnnouncementCard({item,isSelected,onClick,onBookmark})`, `AnnouncementDetail({item,onBookmark})`, `CalendarView({items,onDaySelect})`.
- [ ] **Step 1: Create the three components (본문은 현 Subscription.jsx 정의 verbatim, 상단 import만 추가)**
`AnnouncementCard.jsx` — 상단:
```jsx
import React from 'react';
import { STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
```
이어서 현 `Subscription.jsx`의 `function AnnouncementCard(...) { ... }` 본문을 그대로 붙이고 파일 끝에 `export default AnnouncementCard;`.
`AnnouncementDetail.jsx` — 상단:
```jsx
import React, { useState } from 'react';
import { HOUSE_TYPE_LABELS, fmt, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
```
이어서 현 `AnnouncementDetail(...)` 본문 verbatim + `export default AnnouncementDetail;`. (내부 `useState`(detailTab) 사용하므로 useState import 포함.)
`CalendarView.jsx` — 상단:
```jsx
import React, { useState, useMemo } from 'react';
import { STATUS_CONFIG } from '../subscriptionUtils';
```
이어서 현 `CalendarView(...)` 본문 verbatim + `export default CalendarView;`. (내부 `useState`/`useMemo` 사용.)
> 각 컴포넌트가 실제로 참조하는 심볼만 import에 포함되어야 함(빌드가 미사용/누락을 검출). 위 목록은 현 코드 사용 기준.
- [ ] **Step 2: Wire Subscription.jsx** — 세 컴포넌트 정의 삭제 + import 추가:
```js
import AnnouncementCard from './components/AnnouncementCard';
import AnnouncementDetail from './components/AnnouncementDetail';
import CalendarView from './components/CalendarView';
```
(AnnouncementsTab이 아직 Subscription.jsx 내부에 있으므로 import한 세 컴포넌트를 그대로 사용.)
- [ ] **Step 3: Run tests + build**
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run build` → `✓ built`. Run: `npm run lint` → 신규 3파일 0 에러.
- [ ] **Step 4: Commit**
```bash
git add src/pages/subscription/components/AnnouncementCard.jsx src/pages/subscription/components/AnnouncementDetail.jsx src/pages/subscription/components/CalendarView.jsx src/pages/subscription/Subscription.jsx
git commit -m "refactor(subscription): 공고 카드/상세/캘린더 컴포넌트 추출"
```
---
### Task 4: 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소
**Files:**
- Create: `src/pages/subscription/components/DashboardTab.jsx`
- Create: `src/pages/subscription/components/AnnouncementsTab.jsx`
- Create: `src/pages/subscription/components/MatchesTab.jsx`
- Create: `src/pages/subscription/components/ProfileTab.jsx`
- Modify: `src/pages/subscription/Subscription.jsx` (shell로 축소)
**Interfaces:**
- Consumes: utils 헬퍼, StatusBadge, AnnouncementCard/AnnouncementDetail/CalendarView, 기존 `DistrictTierEditor`/`NotificationSettings`.
- Produces: 각 기본 export (자립형, props 없음).
- [ ] **Step 1: Create the four tab components (본문 verbatim + 상단 import)**
`DashboardTab.jsx` — 상단:
```jsx
import React, { useState, useEffect } from 'react';
import { apiGet, apiPost } from '../../../api';
import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
```
현 `DashboardTab()` 본문 verbatim + `export default DashboardTab;`.
`AnnouncementsTab.jsx` — 상단:
```jsx
import React, { useState, useEffect } from 'react';
import { apiGet, apiDelete } from '../../../api';
import { STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
import AnnouncementCard from './AnnouncementCard';
import AnnouncementDetail from './AnnouncementDetail';
import CalendarView from './CalendarView';
```
현 `AnnouncementsTab()` 본문 verbatim + `export default AnnouncementsTab;`.
`MatchesTab.jsx` — 상단:
```jsx
import React, { useState, useEffect } from 'react';
import { apiGet, apiPost } from '../../../api';
import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
```
현 `MatchesTab()` 본문 verbatim + `export default MatchesTab;`.
`ProfileTab.jsx` — 상단:
```jsx
import React, { useState, useEffect } from 'react';
import { apiGet, apiPut } from '../../../api';
import { DEFAULT_PROFILE } from '../subscriptionUtils';
import DistrictTierEditor from './DistrictTierEditor';
import NotificationSettings from './NotificationSettings';
```
현 `ProfileTab()` 본문 verbatim + `export default ProfileTab;`.
> import 심볼은 각 탭이 실제 사용하는 것 기준(위 목록). 빌드가 누락/미사용 검출.
- [ ] **Step 2: Reduce Subscription.jsx to the shell** — 4개 탭 정의 삭제. 파일을 아래로 정리(현 shell 본문 verbatim 유지, import만 재구성):
```jsx
import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import { TABS } from './subscriptionUtils';
import DashboardTab from './components/DashboardTab';
import AnnouncementsTab from './components/AnnouncementsTab';
import MatchesTab from './components/MatchesTab';
import ProfileTab from './components/ProfileTab';
import './Subscription.css';
// ── Subscription (Main) ──
function Subscription() {
const [activeTab, setActiveTab] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);
const handleFABClick = useCallback(() => {
setActiveTab(1);
}, []);
return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="sub">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
실매물 · 안전마진 →
</Link>
</div>
<div className="sub-header">
<div>
<p className="sub-kicker">Real Estate</p>
<h1>청약 관리</h1>
<p className="sub-desc">
공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서.
</p>
</div>
</div>
<div className="sub-tabs-bar">
<div className="sub-tabs">
{TABS.map((tab, i) => (
<button
key={tab}
className={`sub-tab${activeTab === i ? ' is-active' : ''}`}
onClick={() => setActiveTab(i)}
>
{tab}
</button>
))}
</div>
</div>
<div className="sub-body">
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
</div>
<FAB onClick={handleFABClick} label="공고 목록" />
</div>
</PullToRefresh>
);
}
export default Subscription;
```
- [ ] **Step 3: Run full suite + lint + build**
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run lint` → 신규 파일 0 에러. Run: `npm run build` → `✓ built`. `Subscription.jsx`가 ~70줄 shell로 축소됐는지 확인(`wc -l`).
- [ ] **Step 4: Commit**
```bash
git add src/pages/subscription/components/DashboardTab.jsx src/pages/subscription/components/AnnouncementsTab.jsx src/pages/subscription/components/MatchesTab.jsx src/pages/subscription/components/ProfileTab.jsx src/pages/subscription/Subscription.jsx
git commit -m "refactor(subscription): 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소"
```
- [ ] **Step 5: Manual verification (dev server)**
`npm run dev` → `http://localhost:3007/realestate` 접속. 4탭(대시보드/공고 목록/매칭 결과/내 프로필) 전환, 공고 카드 선택→상세, 캘린더 뷰, 즐겨찾기 토글, 프로필 저장이 리팩토링 전과 **동일 동작**인지 확인. 실매물 교차링크 동작 확인.
---
### Task 5: Part A — 기능 모듈 구조 컨벤션 문서화 (CLAUDE.md + README)
**Files:**
- Modify: `CLAUDE.md` (web-ui 루트)
- Modify: `README.md`
- [ ] **Step 1: web-ui/CLAUDE.md — "기능 모듈 구조" 섹션 추가** (문서 상단 "주요 파일 위치" 또는 페이지 구조 근처):
```markdown
## 기능 모듈 구조 (컨벤션)
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`).
```
src/pages/<feature>/
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
├── <Feature>.css # 스타일 (shell에서 1회 import)
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
├── hooks/ # 상태·API·부수효과 훅 (선택)
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
```
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
```
그리고 페이지 구조 표의 `/realestate` 행 설명에 "(모듈 분해: shell + components/8 + subscriptionUtils)" 취지 반영.
- [ ] **Step 2: README.md — 컨벤션 문단 추가** (프로젝트 통계/구조 근처):
```markdown
### 기능 모듈 구조
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`)
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md README.md
git commit -m "docs: 기능 모듈 구조 컨벤션 하네스(CLAUDE.md)·README 기록"
```
---
## Self-Review 결과
**Spec coverage** (설계 §1§7):
- Part A 컨벤션+문서 → Task 5 ✅
- Part B file map(utils/StatusBadge/카드3/탭4/shell) → Task 1/2/3/4 ✅
- §3 subscriptionUtils 이동 항목 → Task 1 ✅ (local apiPatch 이동 포함)
- §4 테스트(utils 유닛 + StatusBadge 스모크 + build/lint/수동) → Task 1/2 + 각 Task build/lint + Task 4 수동 ✅
- §6 완료기준 → 각 Task 검증 + Task 5 ✅
**Placeholder scan:** 신규 코드(utils/StatusBadge/테스트/shell/docs)는 전체 코드 명시. 대형 컴포넌트는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 옮길 원본이 파일에 존재, import 심볼 목록 명시). ✅
**Type consistency:** utils export 심볼(Task1) ↔ Task2/3/4 import 일치. 컴포넌트 기본 export ↔ shell import 일치. api 경로(shell `../../api`, components `../../../api`, utils `../subscriptionUtils`) 일치. ✅
**참고:** 각 Task는 이전 Task 산출물(utils→StatusBadge→카드→탭→shell)에 순차 의존. 순서 준수 필수. 리팩토링이라 RED는 "신규 파일 미존재(import 실패)"로 성립, GREEN은 추출 후 통과 + 전체 회귀 0.