Files
web-page/docs/superpowers/plans/2026-07-10-fe-module-refactor-portfoliotab.md
2026-07-10 10:08:39 +09:00

18 KiB
Raw Blame History

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로 그대로 전달). formatPriceTimeportfolioUtils.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 filesportfolioUtils.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.jsxformatPriceTime(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.mapHoldingRow 호출로 교체. 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 devhttp://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에서 부여.