Files
web-page/docs/superpowers/plans/2026-07-10-fe-module-refactor-instacards.md
2026-07-10 11:29:17 +09:00

282 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
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.
# InstaCards.jsx 분해 리팩토링 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:** `src/pages/insta/InstaCards.jsx`(1013줄)를 shell(~140줄) + `components/`11 + `hooks/usePollTask` + `instaUtils`(+test)로 동작 보존 분해한다.
**Architecture:** behavior-preserving 순수 추출. 각 컴포넌트/훅/헬퍼를 현 정의 그대로(verbatim) 새 파일로 옮기고 import로 연결. 로직/JSX/스타일/prop shape 불변. 컴포넌트를 이름으로 찾아(정의 시그니처 기준) 옮기며, 매 task 후 build+lint+전체 test green(회귀 0)을 유지한다.
**Tech Stack:** React 18 + Vite + Vitest + @testing-library/react. 설계: `docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md`.
## Global Constraints
- **behavior-preserving**: 옮기는 코드는 verbatim. 로직/JSX/className/prop 참조/스타일 변경 금지. 리팩토링은 "정의를 파일로 이동 + import 연결"만.
- **line 번호는 pre-refactor(1013줄) 기준 힌트**. 추출이 진행되면 InstaCards.jsx 줄이 바뀌므로 **컴포넌트/함수 이름·시그니처로 위치를 찾을 것**(예: `function KeywordsPanel({ onCreateSlate }) {`).
- **import 경로**: `components/*.jsx`·`hooks/*.js` → api `'../../../api'`, utils `'../instaUtils'`, 형제 컴포넌트 `'./X'`, 훅 `'../hooks/usePollTask'`. shell(`InstaCards.jsx`) → 컴포넌트 `'./components/X'`, 훅 `'./hooks/usePollTask'`, utils `'./instaUtils'`, `PullToRefresh` `'../../components/PullToRefresh'`, CSS `'./InstaCards.css'`.
- **import 최소·완전**: 각 파일은 실제 참조하는 심볼만 import. 매 task에서 shell의 이제 안 쓰는 api/컴포넌트 import 제거. `npm run lint`(no-unused-vars)와 `npm run build`가 누락·미사용을 검출 → 둘 다 0 에러로 몰 것.
- **각 컴포넌트는 default export**.
- **검증 게이트(매 task)**: `npm run test:run` 전체 green(회귀 0, 시작 165) + `npm run lint` 신규/수정 파일 0 에러 + `npm run build` `✓ built`.
- 단일 사용 상수 co-locate: `CATEGORIES`/`KEYWORDS_PER_PAGE` → KeywordsPanel.jsx, `PROMPT_NAMES` → PromptTemplatesEditor.jsx, `CATEGORY_COLORS` → ExternalTrendsPanel.jsx. 다중 사용 `fmtDate` → instaUtils.js.
---
## 파일 구조 (분해 후)
```
src/pages/insta/
├── InstaCards.jsx # shell(~140줄): 탭바+상태헤더+progress배너+handleCreateSlate+2탭 조합
├── InstaCards.css # 변경 없음
├── instaUtils.js # fmtDate
├── instaUtils.test.js # fmtDate 유닛
├── hooks/usePollTask.js # { taskId, task, start, clear }
└── components/
├── StatusBadge.jsx TaskStatusBox.jsx TriggerPanel.jsx KeywordsPanel.jsx
├── SlatesPanel.jsx SlateDetail.jsx PagesStrip.jsx
└── AccountFocusPanel.jsx ExternalTrendsPanel.jsx PreferenceImpactPanel.jsx PromptTemplatesEditor.jsx
```
**shell이 직접 렌더하는 7 컴포넌트**: TriggerPanel, KeywordsPanel, SlatesPanel, PromptTemplatesEditor, AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel. (StatusBadge/TaskStatusBox/SlateDetail/PagesStrip은 다른 컴포넌트가 렌더 → shell import 아님.)
**컴포넌트 참조 그래프**: TriggerPanel→usePollTask+TaskStatusBox / ExternalTrendsPanel→TaskStatusBox+CATEGORY_COLORS+fmtDate / SlatesPanel→SlateDetail+StatusBadge+fmtDate / SlateDetail→PagesStrip+StatusBadge / PromptTemplatesEditor→fmtDate+PROMPT_NAMES.
---
### Task 1: 기반 — instaUtils(+test) + usePollTask 훅 + 리프 컴포넌트(StatusBadge, TaskStatusBox)
**Files:**
- Create: `src/pages/insta/instaUtils.js`, `src/pages/insta/instaUtils.test.js`
- Create: `src/pages/insta/hooks/usePollTask.js`
- Create: `src/pages/insta/components/StatusBadge.jsx`, `src/pages/insta/components/TaskStatusBox.jsx`
- Modify: `src/pages/insta/InstaCards.jsx` (정의 4개 제거 + import)
**Interfaces:**
- Produces: `fmtDate(iso) → string`; `usePollTask(onDone?) → { taskId, task, start(id), clear() }`; `StatusBadge({ status })`; `TaskStatusBox({ task })` (모두 default export, usePollTask는 named 아님 default).
- [ ] **Step 1: instaUtils.test.js 작성(RED)**
```js
import { describe, it, expect } from 'vitest';
import { fmtDate } from './instaUtils';
describe('instaUtils.fmtDate', () => {
it('returns empty string for falsy input', () => {
expect(fmtDate('')).toBe('');
expect(fmtDate(null)).toBe('');
expect(fmtDate(undefined)).toBe('');
});
it('returns a non-empty string for a valid ISO date (locale/tz-dependent format)', () => {
const out = fmtDate('2026-07-10T09:30:00Z');
expect(typeof out).toBe('string');
expect(out.length).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: RED 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: FAIL(`fmtDate`/모듈 미존재).
- [ ] **Step 3: instaUtils.js 작성(GREEN)** — 현 `InstaCards.jsx``fmtDate`(2634) verbatim, `export` 부여:
```js
// src/pages/insta/instaUtils.js
export function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
```
- [ ] **Step 4: GREEN 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: PASS(2 tests).
- [ ] **Step 5: hooks/usePollTask.js 작성** — 현 `InstaCards.jsx``usePollTask`(4582) 함수 본문 verbatim 이동, `import { useState, useEffect, useRef } from 'react';`(실제 사용 훅) + `import { getInstaTask } from '../../../api';` + `export default function usePollTask(onDone) { ... }`. 내부 `getInstaTask` 참조 그대로.
- [ ] **Step 6: components/StatusBadge.jsx 작성** — 현 `StatusBadge`(3642) verbatim: `import React from 'react'; const StatusBadge = ({ status }) => (<span className={\`ic-status-badge ic-status-badge--${status || 'draft'}\`}>{status || 'draft'}</span>); export default StatusBadge;` (JSX는 원본과 동일하게).
- [ ] **Step 7: components/TaskStatusBox.jsx 작성** — 현 `TaskStatusBox`(8499) verbatim 이동. `import React from 'react';` + 원본이 참조하는 심볼(예: 상태 텍스트/StatusBadge? — 원본 본문 확인해 실제 참조만 import; 순수 표현이면 React만). `export default TaskStatusBox;`.
- [ ] **Step 8: InstaCards.jsx 수정**`fmtDate`/`usePollTask`/`StatusBadge`/`TaskStatusBox` 인라인 정의 4개 제거. 상단에 추가:
```jsx
import { fmtDate } from './instaUtils';
import usePollTask from './hooks/usePollTask';
import StatusBadge from './components/StatusBadge';
import TaskStatusBox from './components/TaskStatusBox';
```
아직 인라인인 컴포넌트(TriggerPanel/ExternalTrendsPanel/SlatesPanel/SlateDetail/PromptTemplatesEditor)가 이 심볼들을 참조 → 동일 모듈 scope에서 import된 심볼 사용(동작 보존). api import 블록에서 이제 shell 파일 어디서도 안 쓰는 것이 있으면 제거(단 아직 인라인 컴포넌트가 쓰는 것은 유지). `getInstaTask`는 usePollTask로 이동했지만 shell `handleCreateSlate`가 여전히 직접 사용 → 유지.
- [ ] **Step 9: 검증** — Run: `npm run test:run`(전체 green, ≥165+2=167), `npm run lint`(0 에러), `npm run build`(`✓ built`).
- [ ] **Step 10: Commit**
```bash
git add src/pages/insta/instaUtils.js src/pages/insta/instaUtils.test.js src/pages/insta/hooks/usePollTask.js src/pages/insta/components/StatusBadge.jsx src/pages/insta/components/TaskStatusBox.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): instaUtils(fmtDate)+usePollTask 훅+StatusBadge/TaskStatusBox 추출"
```
---
### Task 2: Cards 탭 좌측 — TriggerPanel + KeywordsPanel
**Files:**
- Create: `src/pages/insta/components/TriggerPanel.jsx`, `src/pages/insta/components/KeywordsPanel.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `usePollTask`(Task1), `TaskStatusBox`(Task1).
- Produces: `TriggerPanel()`, `KeywordsPanel({ onCreateSlate })` (default export).
- [ ] **Step 1: components/TriggerPanel.jsx 작성** — 현 `TriggerPanel`(471521) 함수 본문 verbatim 이동. imports:
```jsx
import React from 'react';
import usePollTask from '../hooks/usePollTask';
import TaskStatusBox from './TaskStatusBox';
import { instaCollectNews, instaExtractKeywords } from '../../../api';
```
내부 `usePollTask()`×2, `TaskStatusBox`, `instaCollectNews`/`instaExtractKeywords` 참조 그대로. `export default TriggerPanel;`.
- [ ] **Step 2: components/KeywordsPanel.jsx 작성** — 현 `KeywordsPanel`(527637) 함수 본문 verbatim 이동 + **상수 `CATEGORIES`(524)·`KEYWORDS_PER_PAGE`(525)를 이 파일 상단에 co-locate**. imports:
```jsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { getInstaKeywords } from '../../../api';
```
(실제 사용 훅/헬퍼만 — 원본이 useState/useEffect/useCallback/useMemo와 getInstaKeywords, onCreateSlate prop 사용.) 상수:
```jsx
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
const KEYWORDS_PER_PAGE = 10;
```
`export default KeywordsPanel;`.
- [ ] **Step 3: InstaCards.jsx 수정**`TriggerPanel`/`KeywordsPanel` 인라인 정의 + `CATEGORIES`/`KEYWORDS_PER_PAGE` 상수 제거. 추가:
```jsx
import TriggerPanel from './components/TriggerPanel';
import KeywordsPanel from './components/KeywordsPanel';
```
이제 shell에서 안 쓰는 api import 제거: `instaCollectNews`/`instaExtractKeywords`(TriggerPanel로 이동), `getInstaKeywords`(KeywordsPanel로 이동)가 shell 다른 곳에서 안 쓰이면 shell import에서 삭제. `TaskStatusBox` shell import는 아직 인라인 ExternalTrendsPanel(245)이 사용 → **유지**. `usePollTask` shell import는 이제 shell 인라인에서 미사용(TriggerPanel 이동) → **제거**.
- [ ] **Step 4: 검증**`npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
- [ ] **Step 5: Commit**
```bash
git add src/pages/insta/components/TriggerPanel.jsx src/pages/insta/components/KeywordsPanel.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): TriggerPanel/KeywordsPanel 추출 (Cards 탭 좌측)"
```
---
### Task 3: Cards 탭 우측 (중첩 트리) — SlatesPanel + SlateDetail + PagesStrip
**Files:**
- Create: `src/pages/insta/components/SlatesPanel.jsx`, `src/pages/insta/components/SlateDetail.jsx`, `src/pages/insta/components/PagesStrip.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `StatusBadge`(Task1), `fmtDate`(Task1).
- Produces: `SlatesPanel({ selectedId, onSelect })`, `SlateDetail({ slate, onDelete, onRender })`, `PagesStrip({ slateId, pageCount })` (default export).
- [ ] **Step 1: components/PagesStrip.jsx 작성** — 현 `PagesStrip`(733817) verbatim 이동. imports: `import React, { useState, useCallback, useEffect, useRef } from 'react';`(실제 사용) + `import { getInstaAssetUrl } from '../../../api';`. `export default PagesStrip;`.
- [ ] **Step 2: components/SlateDetail.jsx 작성** — 현 `SlateDetail`(818940) verbatim 이동. imports:
```jsx
import React from 'react';
import StatusBadge from './StatusBadge';
import PagesStrip from './PagesStrip';
import { getInstaAssetUrl, instaPackageUrl } from '../../../api';
```
(실제 사용 심볼만 — 원본 본문이 참조하는 것으로 확정; React 훅 사용 시 추가.) 내부 `<StatusBadge>`(832)·`<PagesStrip>`(845) 참조 그대로. `export default SlateDetail;`.
- [ ] **Step 3: components/SlatesPanel.jsx 작성** — 현 `SlatesPanel`(638732) verbatim 이동. imports:
```jsx
import React, { useState, useEffect, useCallback } from 'react';
import StatusBadge from './StatusBadge';
import SlateDetail from './SlateDetail';
import { fmtDate } from '../instaUtils';
import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api';
```
(실제 사용만 — 원본이 `<StatusBadge>`(711)·`<SlateDetail>`(722)·`fmtDate`(710)·slate 목록/상세/렌더/삭제 api 사용. onDelete/onRender 핸들러가 renderInstaSlate/deleteInstaSlate를 어디서 호출하는지 원본 따라 배치.) `export default SlatesPanel;`.
- [ ] **Step 4: InstaCards.jsx 수정**`SlatesPanel`/`SlateDetail`/`PagesStrip` 인라인 정의 제거. 추가: `import SlatesPanel from './components/SlatesPanel';`. (SlateDetail/PagesStrip은 shell이 직접 렌더 안 함 → shell import 아님.) 이제 shell에서 미사용: `StatusBadge` import(SlatesPanel/SlateDetail 이동으로 shell 인라인 미사용) → **제거**; 관련 api import(getInstaSlates/getInstaSlate/getInstaAssetUrl/renderInstaSlate/deleteInstaSlate/instaPackageUrl 등)가 shell 인라인에서 미사용이면 **제거**.
- [ ] **Step 5: 검증**`npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
- [ ] **Step 6: Commit**
```bash
git add src/pages/insta/components/SlatesPanel.jsx src/pages/insta/components/SlateDetail.jsx src/pages/insta/components/PagesStrip.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): SlatesPanel/SlateDetail/PagesStrip 추출 (Cards 탭 우측)"
```
---
### Task 4: Trends 탭 3패널 + PromptTemplatesEditor + shell 확정
**Files:**
- Create: `src/pages/insta/components/AccountFocusPanel.jsx`, `src/pages/insta/components/ExternalTrendsPanel.jsx`, `src/pages/insta/components/PreferenceImpactPanel.jsx`, `src/pages/insta/components/PromptTemplatesEditor.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `TaskStatusBox`(Task1), `fmtDate`(Task1).
- Produces: `AccountFocusPanel()`, `ExternalTrendsPanel({ onCreateSlate })`, `PreferenceImpactPanel()`, `PromptTemplatesEditor()` (default export).
- [ ] **Step 1: components/AccountFocusPanel.jsx 작성** — 현 `AccountFocusPanel`(101169) verbatim 이동. imports: `import React, { useState, useCallback, useEffect } from 'react';` + `import { getInstaPreferences, putInstaPreferences } from '../../../api';`(실제 사용). `export default AccountFocusPanel;`.
- [ ] **Step 2: components/ExternalTrendsPanel.jsx 작성** — 현 `ExternalTrendsPanel`(175267) verbatim 이동 + **상수 `CATEGORY_COLORS`(170)를 이 파일 상단에 co-locate**. imports:
```jsx
import React, { useState, useCallback, useEffect } from 'react';
import TaskStatusBox from './TaskStatusBox';
import { fmtDate } from '../instaUtils';
import { getInstaTrends, instaCollectTrends, getInstaTask } from '../../../api';
```
(실제 사용만 — 원본이 `<TaskStatusBox>`(245)·`fmtDate`(238)·`CATEGORY_COLORS`(221,252)·trends api·인라인 폴링 getInstaTask 사용.) 상수 `const CATEGORY_COLORS = { ... };`(원본 170 블록 verbatim). `export default ExternalTrendsPanel;`.
- [ ] **Step 3: components/PreferenceImpactPanel.jsx 작성** — 현 `PreferenceImpactPanel`(268300) verbatim 이동. imports: `import React, { useState, useEffect } from 'react';` + `import { getInstaPreferences } from '../../../api';`(실제 사용). `export default PreferenceImpactPanel;`.
- [ ] **Step 4: components/PromptTemplatesEditor.jsx 작성** — 현 `PromptTemplatesEditor`(9431013) verbatim 이동 + **상수 `PROMPT_NAMES`(941)를 이 파일 상단에 co-locate**. imports:
```jsx
import React, { useState, useEffect } from 'react';
import { fmtDate } from '../instaUtils';
import { getInstaPrompt, putInstaPrompt } from '../../../api';
```
(실제 사용만 — 원본이 `fmtDate`(984)·prompt api·`PROMPT_NAMES` 사용.) 상수 `const PROMPT_NAMES = ['slate_writer', 'category_seeds'];`. `export default PromptTemplatesEditor;`.
- [ ] **Step 5: InstaCards.jsx 최종 확정(shell)** — 4개 인라인 정의 + `CATEGORY_COLORS`/`PROMPT_NAMES` 상수 제거. 추가:
```jsx
import AccountFocusPanel from './components/AccountFocusPanel';
import ExternalTrendsPanel from './components/ExternalTrendsPanel';
import PreferenceImpactPanel from './components/PreferenceImpactPanel';
import PromptTemplatesEditor from './components/PromptTemplatesEditor';
```
**shell import 최종 정리** — shell이 실제 쓰는 것만 남긴다:
- React: `import React, { useState, useEffect, useCallback } from 'react';`(shell이 실제 사용하는 훅만; useRef/useMemo는 shell 미사용이면 제거)
- `import PullToRefresh from '../../components/PullToRefresh';`(유지)
- 7 컴포넌트 import(TriggerPanel/KeywordsPanel/SlatesPanel/PromptTemplatesEditor/AccountFocusPanel/ExternalTrendsPanel/PreferenceImpactPanel)
- api: shell이 직접 쓰는 것만 — `import { getInstaStatus, createInstaSlate, getInstaTask } from '../../../api';`(loadStatus=getInstaStatus, handleCreateSlate=createInstaSlate+getInstaTask). 나머지 api·`TaskStatusBox`·`StatusBadge`·`usePollTask`·`fmtDate` import 전부 제거(모두 컴포넌트로 이동).
- `import './InstaCards.css';`(유지)
결과 `InstaCards.jsx`는 shell(~140줄).
- [ ] **Step 6: 검증** — Run: `npm run test:run`(전체 green, 회귀 0), `npm run lint`(0 에러, 미사용 import 0), `npm run build`(`✓ built`), `wc -l src/pages/insta/InstaCards.jsx`(~140줄 확인).
- [ ] **Step 7: Commit**
```bash
git add src/pages/insta/components/AccountFocusPanel.jsx src/pages/insta/components/ExternalTrendsPanel.jsx src/pages/insta/components/PreferenceImpactPanel.jsx src/pages/insta/components/PromptTemplatesEditor.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): Trends 3패널+PromptTemplatesEditor 추출 → InstaCards shell 확정"
```
- [ ] **Step 8: Manual verification (dev server)**`npm run dev``http://localhost:3007/insta`. 확인: 2탭(Cards/Trends) 전환 + URL `?tab=trends` 동기화 / 트리거(뉴스 수집·키워드 추출 버튼 + 스피너 + TaskStatusBox) / 키워드 목록·카테고리·페이지네이션 / 슬레이트 생성(키워드 클릭 → progress 배너 → 완료 시 Cards 탭 자동 전환 + 상세) / 슬레이트 목록·상세(SlateDetail)·페이지 스트립(PagesStrip 스크롤·키보드)·삭제·렌더 / 프롬프트 편집(저장) / Trends 3패널(AccountFocus·ExternalTrends 수집·PreferenceImpact)이 리팩토링 전과 **동일 동작**.
---
## Self-Review 결과
**Spec coverage** (설계 §1§7):
- §3 file map(instaUtils/usePollTask/11 컴포넌트/shell) → Task 1(utils+hook+2리프) / Task 2(2) / Task 3(3) / Task 4(4+shell) ✅ (11 = 2+2+3+4)
- §4 shell 책임(탭·progress·handleCreateSlate 유지, 7 컴포넌트 조합) → Task 4 Step 5 ✅
- §5 테스트(instaUtils fmtDate 유닛 + build/lint/전체/수동) → Task 1 Step 14 + 각 task 검증 + Task 4 Step 8 ✅
- §6 완료기준 → 각 task 검증 + Task 4 line count ✅
- §3 상수 배치(CATEGORIES/KEYWORDS_PER_PAGE→KeywordsPanel, PROMPT_NAMES→PromptTemplatesEditor, CATEGORY_COLORS→ExternalTrendsPanel co-locate, fmtDate→instaUtils) → 각 Task 명시 ✅
**Placeholder scan:** fmtDate 테스트/구현 완전 명시. 리프트 컴포넌트는 "현 정의(이름·line 힌트) verbatim + 명시 import/export/상수 co-locate"로 지정(원본 코드 존재 → 재현 대신 위치+연결 지정, 서브프로젝트 1~3 동일 컨벤션). 일부 컴포넌트 import는 "원본이 참조하는 심볼만(build/lint 검출)"으로 위임 — 리프트 특성상 정확 목록은 원본 참조와 동일. ✅
**Type consistency:** usePollTask 반환 `{ taskId, task, start, clear }` ↔ TriggerPanel 사용 일치. 컴포넌트 props(status/task/onCreateSlate/selectedId/onSelect/slate/onDelete/onRender/slateId/pageCount) ↔ shell/부모 전달 일치. 경로(components→`../../../api`·`../instaUtils`·`./sibling`·`../hooks/usePollTask`) 일관. StatusBadge/TaskStatusBox는 Task1 추출 → 소비처(Task2/3/4)가 직접 import, shell은 소비 인라인 남아있는 동안만 브릿지 import 유지 후 제거. ✅
**참고:** Task 순서 의존(Task1 리프/훅/유틸 → Task2/3/4가 import). behavior-preserving라 컴포넌트 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. fmtDate만 명시적 TDD(RED→GREEN).