Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
18 KiB
PortfolioTab.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: PortfolioTab.jsx(671줄)를 stock/components/portfolio/ 하위 표현 컴포넌트 7개 + portfolioUtils로 동작 보존 분해한다.
Architecture: 인라인 JSX 블록을 컴포넌트로 리프트(가드는 컴포넌트 내부 early-return으로 이동, pf/asset/핸들러를 props로 그대로 전달). formatPriceTime→portfolioUtils.js. PortfolioTab.jsx는 조합 shell(~70줄)로 축소. 각 Task는 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
Tech Stack: React 18 + Vite, recharts, Vitest + @testing-library/react.
Global Constraints
- Behavior-preserving 순수 추출: 리프트하는 JSX 본문은 verbatim(참조하는
pf.*/asset.*은 이제 props). 래핑 가드(pf.addFormOpen &&,pf.portfolioHoldings.length > 0 &&등)는 컴포넌트 내부if (!cond) return null;로 이동.pf(훅 객체 전체)를 각 하위에 그대로 전달 → prop shape·동작 불변. 스타일/클래스명 불변. - 경로:
portfolio/*.jsx→ stockUtils'../../stockUtils', Loading'../../../../components/Loading', portfolioUtils'./portfolioUtils';HoldingRow→ PriceSessionBadge'./PriceSessionBadge';PortfolioTab.jsx→ 하위'./portfolio/*'(기존'../stockUtils'·'../../../components/Loading'는 shell이 쓰는 것만 유지). - 각 파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 빌드가 누락 검출.
- 각 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)동일 디렉토리. - 커밋은 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: portfolioUtils.js + PriceSessionBadge 추출 + 테스트
Files:
- Create:
src/pages/stock/components/portfolio/portfolioUtils.js - Create:
src/pages/stock/components/portfolio/portfolioUtils.test.js - Create:
src/pages/stock/components/portfolio/PriceSessionBadge.jsx - Create:
src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx - Modify:
src/pages/stock/components/PortfolioTab.jsx
Interfaces:
-
Produces:
formatPriceTime(iso)(portfolioUtils);PriceSessionBadge({ session, asOf })(default export). -
Step 1: Write failing tests — Create
portfolioUtils.test.js:
import { describe, it, expect } from 'vitest';
import { formatPriceTime } from './portfolioUtils.js';
describe('formatPriceTime', () => {
it('유효 ISO → HH:MM (제로패드)', () => {
expect(formatPriceTime('2026-07-10T09:05:00')).toBe('09:05');
expect(formatPriceTime('2026-07-10T18:30:00')).toBe('18:30');
});
it('falsy/invalid → 빈 문자열', () => {
expect(formatPriceTime('')).toBe('');
expect(formatPriceTime(null)).toBe('');
expect(formatPriceTime('not-a-date')).toBe('');
});
});
Create PriceSessionBadge.test.jsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import PriceSessionBadge from './PriceSessionBadge.jsx';
describe('PriceSessionBadge', () => {
it('NXT_AFTER → "NXT", NXT_PRE → "NXT 프리"', () => {
const { unmount } = render(<PriceSessionBadge session="NXT_AFTER" />);
expect(screen.getByText('NXT')).toBeInTheDocument();
unmount();
render(<PriceSessionBadge session="NXT_PRE" />);
expect(screen.getByText('NXT 프리')).toBeInTheDocument();
});
it('그 외 세션 → 렌더 없음', () => {
const { container } = render(<PriceSessionBadge session="REGULAR" />);
expect(container).toBeEmptyDOMElement();
});
});
- Step 2: Run to verify fail
Run: npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx
Expected: FAIL — unresolved imports.
- Step 3: Create files —
portfolioUtils.js(formatPriceTime을 현 PortfolioTab.jsx:9-14에서 verbatim):
export const formatPriceTime = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
PriceSessionBadge.jsx (현 PortfolioTab.jsx:16-27 본문 verbatim + import/export):
import React from 'react';
import { formatPriceTime } from './portfolioUtils';
const PriceSessionBadge = ({ session, asOf }) => {
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
const isPre = session === 'NXT_PRE';
const label = isPre ? 'NXT 프리' : 'NXT';
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
const time = formatPriceTime(asOf);
return (
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
{label}
</span>
);
};
export default PriceSessionBadge;
- Step 4: Wire PortfolioTab.jsx —
formatPriceTime(9-14)와PriceSessionBadge(16-27) 정의 삭제, import 추가:
import PriceSessionBadge from './portfolio/PriceSessionBadge';
(PortfolioTab 내부 보유행에서 <PriceSessionBadge .../> 사용은 그대로 — import한 것을 씀.)
- Step 5: Run tests + build
Run: npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx → PASS.
Run: npm run test:run → 전체 green. npm run build → ✓ built. npm run lint → 신규/수정 0 에러.
- Step 6: Commit
git add src/pages/stock/components/portfolio/portfolioUtils.js src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.jsx src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): formatPriceTime util + PriceSessionBadge 추출 + 테스트"
Task 2: 관리 section 3 컴포넌트 추출 (AddHoldingForm / PortfolioSummary / AssetHistoryChart)
Files:
- Create:
src/pages/stock/components/portfolio/AddHoldingForm.jsx - Create:
src/pages/stock/components/portfolio/PortfolioSummary.jsx - Create:
src/pages/stock/components/portfolio/AssetHistoryChart.jsx - Modify:
src/pages/stock/components/PortfolioTab.jsx
Interfaces:
-
Produces:
AddHoldingForm({ pf }),PortfolioSummary({ pf }),AssetHistoryChart({ asset, pf, handleSaveSnapshot })(default exports). -
Step 1: Create the three components (JSX 본문 verbatim, 가드는 내부 early-return)
AddHoldingForm.jsx:
import React from 'react';
컴포넌트: const AddHoldingForm = ({ pf }) => { if (!pf.addFormOpen) return null; return ( <form className="pf-add-form" ... > ... </form> ); }; — return 내부의 <form>...</form>은 현 PortfolioTab.jsx:67-152의 <form> JSX를 verbatim(내부의 pf.addForm/pf.setAddForm/pf.handleAddSubmit/pf.addLoading/pf.addError 참조 그대로). export default AddHoldingForm;.
PortfolioSummary.jsx:
import React from 'react';
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../../stockUtils';
컴포넌트: const PortfolioSummary = ({ pf }) => { if (!(pf.portfolioHoldings.length > 0)) return null; return ( <div className="pf-total-summary"> ... </div> ); }; — 내부는 현 PortfolioTab.jsx:157-195의 <div className="pf-total-summary">...</div> JSX verbatim(pf.portfolioSummary/pf.totalCash/pf.totalAssets 참조 그대로). export default PortfolioSummary;.
AssetHistoryChart.jsx:
import React from 'react';
import Loading from '../../../../components/Loading';
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as ChartTooltip } from 'recharts';
컴포넌트: const AssetHistoryChart = ({ asset, pf, handleSaveSnapshot }) => ( <div className="pf-asset-history"> ... </div> ); — 내부는 현 PortfolioTab.jsx:199-281의 <div className="pf-asset-history">...</div> JSX verbatim(asset.assetHistory*/asset.setAssetHistoryDays/asset.snapshotSaving/handleSaveSnapshot/pf.totalAssets 참조 그대로). export default AssetHistoryChart;.
각 파일은 위 import만(실제 사용 심볼). AddHoldingForm은 stockUtils/Loading 불필요(폼만).
- Step 2: Wire PortfolioTab.jsx — 관리
<section>내부에서 위 3개 인라인 블록을 컴포넌트 호출로 교체: {pf.addFormOpen && (<form ...>...</form>)}→<AddHoldingForm pf={pf} />{pf.portfolioHoldings.length > 0 && (<div className="pf-total-summary">...</div>)}→<PortfolioSummary pf={pf} /><div className="pf-asset-history">...</div>→<AssetHistoryChart asset={asset} pf={pf} handleSaveSnapshot={handleSaveSnapshot} />import 추가:
import AddHoldingForm from './portfolio/AddHoldingForm';
import PortfolioSummary from './portfolio/PortfolioSummary';
import AssetHistoryChart from './portfolio/AssetHistoryChart';
PortfolioTab.jsx 상단의 이제-미사용 import 정리: recharts(ResponsiveContainer/AreaChart/Area/XAxis/YAxis/Tooltip)가 AssetHistoryChart로 이동했으니 shell에서 제거. Loading은 shell 헤더가 계속 사용(pf.portfolioLoading) → 유지. stockUtils 심볼 중 shell(브로커 섹션 등 아직 인라인)이 계속 쓰는 것은 유지. (미사용 import 0 되도록 — 빌드/lint 확인.)
- Step 3: Run tests + build + lint
Run: npm run test:run → 전체 green. npm run build → ✓ built. npm run lint → 신규 3파일 + PortfolioTab.jsx 0 에러.
- Step 4: Commit
git add src/pages/stock/components/portfolio/AddHoldingForm.jsx src/pages/stock/components/portfolio/PortfolioSummary.jsx src/pages/stock/components/portfolio/AssetHistoryChart.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 추가폼/총자산요약/자산추이차트 컴포넌트 추출"
Task 3: CashPanel 추출 (예수금 패널)
Files:
- Create:
src/pages/stock/components/portfolio/CashPanel.jsx - Modify:
src/pages/stock/components/PortfolioTab.jsx
Interfaces:
-
Produces:
CashPanel({ pf })(default export). -
Step 1: Create CashPanel.jsx
import React from 'react';
import { formatNumber } from '../../stockUtils';
컴포넌트: const CashPanel = ({ pf }) => ( <section className="stock-panel stock-panel--wide"> ... </section> ); — 내부는 현 PortfolioTab.jsx:285-409의 예수금 <section>...</section> JSX verbatim(pf.cashList/pf.cashEditingBroker/pf.cashEditingValue/pf.setCashEditingValue/pf.handleCashInlineSave/pf.handleCashInlineCancel/pf.handleCashInlineEdit/pf.handleCashDelete/pf.cashEditSaving/pf.cashForm/pf.setCashForm/pf.handleCashSave/pf.cashSaving/pf.cashError 참조 그대로). export default CashPanel;.
- Step 2: Wire PortfolioTab.jsx — 예수금
<section>...</section>(285-409) 인라인 블록을<CashPanel pf={pf} />로 교체. import 추가:
import CashPanel from './portfolio/CashPanel';
(shell이 formatNumber를 예수금 외 다른 곳에서 안 쓰게 되면 정리 — 단 브로커 섹션이 아직 인라인이면 formatNumber 유지. 빌드/lint로 확인.)
- Step 3: Run tests + build + lint
Run: npm run test:run → 전체 green. npm run build → ✓ built. npm run lint → 신규 파일 + PortfolioTab.jsx 0 에러.
- Step 4: Commit
git add src/pages/stock/components/portfolio/CashPanel.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 예수금 패널 CashPanel 컴포넌트 추출"
Task 4: HoldingRow + BrokerSection 추출 → PortfolioTab shell 축소 + 검증
Files:
- Create:
src/pages/stock/components/portfolio/HoldingRow.jsx - Create:
src/pages/stock/components/portfolio/BrokerSection.jsx - Modify:
src/pages/stock/components/PortfolioTab.jsx
Interfaces:
-
Consumes:
PriceSessionBadge(Task 1). -
Produces:
HoldingRow({ item, pf, handleSell }),BrokerSection({ broker, items, pf, handleSell })(default exports). -
Step 1: Create HoldingRow.jsx
import React from 'react';
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../../stockUtils';
import PriceSessionBadge from './PriceSessionBadge';
컴포넌트: const HoldingRow = ({ item, pf, handleSell }) => { ...상수 계산... return ( <div key={item.id} className="stock-holdings__item pf-item"> ... </div> ); }; — 내부는 현 PortfolioTab.jsx:447-654의 items.map((item) => { ... return (<div ...>...</div>); }) 콜백 본문 verbatim(상수 profitAmt/profitRate/profitAmtN/profitRateN/isEditing/isDeleting/isSelling/sellPrice/saleAmount 계산 + <div> JSX; pf.editingId/pf.editForm/pf.setEditForm/pf.handleEditSave/pf.editLoading/pf.setEditingId/pf.handleEditStart/pf.sellConfirmId/pf.setSellConfirmId/pf.sellLoading/pf.deleteConfirmId/pf.setDeleteConfirmId/pf.handleDelete/handleSell 참조 그대로). map 콜백의 return을 컴포넌트 return으로. key는 BrokerSection의 map에서 부여하므로 HoldingRow 내부 최상위 <div>의 key={item.id}는 제거 가능(무해하나 React는 map 자식에만 key 필요) — 동작 보존 위해 그대로 두거나 제거 무관; BrokerSection map에서 <HoldingRow key={item.id} .../>로 key 부여. export default HoldingRow;.
- Step 2: Create BrokerSection.jsx
import React from 'react';
import { formatNumber, formatPercent, profitColorClass } from '../../stockUtils';
import HoldingRow from './HoldingRow';
컴포넌트: const BrokerSection = ({ broker, items, pf, handleSell }) => { const bSummary = pf.getBrokerSummary(items); const color = pf.brokerColors[broker]; return ( <section key={broker} className="stock-panel stock-panel--wide pf-broker-section" style={...}> ...헤더... <div className="stock-holdings"> {items.map((item) => <HoldingRow key={item.id} item={item} pf={pf} handleSell={handleSell} />)} </div> </section> ); }; — 섹션 헤더(현 PortfolioTab.jsx:421-445) verbatim, <div className="stock-holdings"> 내부의 items.map을 HoldingRow 호출로 교체. key={broker}는 PortfolioTab의 map에서 부여하므로 내부 유지/제거 무관. export default BrokerSection;.
- Step 3: Reduce PortfolioTab.jsx to shell — 브로커 섹션 인라인 블록(412-659)
{pf.brokerGroups.map(([broker, items]) => { ... })}을 다음으로 교체:
{pf.brokerGroups.map(([broker, items]) => (
<BrokerSection key={broker} broker={broker} items={items} pf={pf} handleSell={handleSell} />
))}
import 추가: import BrokerSection from './portfolio/BrokerSection';. (HoldingRow는 BrokerSection이 import.) PortfolioTab.jsx 상단 import 최종 정리: shell이 실제 쓰는 것만 — React, Loading(관리 section 헤더 spinner), 6개 하위 컴포넌트(PriceSessionBadge는 이제 HoldingRow가 쓰므로 shell import 제거), stockUtils 심볼 중 shell이 직접 쓰는 것(브로커 섹션 이동 후 shell엔 거의 없음 → 미사용 제거). PriceSessionBadge import를 shell에서 제거(HoldingRow로 이동). 미사용 import 0 되도록 build/lint 확인. 결과 PortfolioTab.jsx ~70줄 shell.
- Step 4: Run full suite + lint + build
Run: npm run test:run → 전체 green(회귀 0). npm run lint → 0 새 에러. npm run build → ✓ built. wc -l src/pages/stock/components/PortfolioTab.jsx ~70줄 확인.
- Step 5: Commit
git add src/pages/stock/components/portfolio/HoldingRow.jsx src/pages/stock/components/portfolio/BrokerSection.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 보유행/브로커섹션 추출 → PortfolioTab shell 축소"
- Step 6: Manual verification (dev server)
npm run dev → http://localhost:3007/stock/trade 포트폴리오 탭. 종목 추가 폼, 총자산 요약, 자산 추이 차트(기간·스냅샷), 예수금(인라인 편집·삭제·추가), 브로커 섹션(요약·예수금 뱃지), 보유행(현재가·NXT 뱃지·편집·매도확인·삭제확인)이 리팩토링 전과 동일 동작인지 확인.
Self-Review 결과
Spec coverage (설계 §1–§7):
- §3 file map(portfolioUtils/7컴포넌트/shell) → Task 1/2/3/4 ✅
- §4 컴포넌트별 책임 → 각 Task의 props·JSX 범위 매핑 ✅
- §5 테스트(portfolioUtils + PriceSessionBadge 스모크 + build/lint/수동) → Task 1 + 각 Task 검증 + Task 4 수동 ✅
- §6 완료기준 → 각 Task 검증 ✅
Placeholder scan: 신규 코드(portfolioUtils/PriceSessionBadge/테스트) 전체 명시. 리프트 컴포넌트는 "현 인라인 JSX(line 범위) verbatim + 가드 early-return + 명시된 import/props"로 지정(플레이스홀더 아님 — 원본 JSX 존재, 참조 심볼·props 명시). ✅
Type consistency: portfolioUtils/PriceSessionBadge(Task1) ↔ HoldingRow import 일치. 각 컴포넌트 props(pf/asset/handleSell/handleSaveSnapshot/item/broker/items) ↔ shell/BrokerSection 전달 일치. 경로(portfolio/* → ../../stockUtils·../../../../components/Loading) 일치. ✅
참고: Task 순서 의존(PriceSessionBadge→…→HoldingRow/BrokerSection). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. 리프트 시 가드→early-return, key는 부모 map에서 부여.