# 관심종목 탭 (Watchlist Tab) 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:** `/stock/trade` 거래 데스크에 관심종목 CRUD + 최근 매매 시그널 알림 이력을 보여주는 "관심종목" 탭을 추가한다. **Architecture:** 순수 헬퍼(`watchlistUtils.js`) → API 헬퍼(`api.js`) → 상태 훅(`useWatchlist.js`) → 표현 컴포넌트(`WatchlistTab.jsx`) → 탭 등재(`StockTrade.jsx`). 기존 `HoldingsIntelTab`/`usePortfolio` 패턴(훅을 `StockTrade`에서 인스턴스화해 탭에 props로 전달)을 그대로 따른다. **Tech Stack:** React 18 (함수형 + hooks), Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiDelete` 헬퍼. ## Global Constraints - **API는 항상 상대경로** (`/api/...`). 절대 URL 금지 (Mixed Content). - **모든 fetch는 `src/api.js`의 `apiGet/apiPost/apiDelete` 경유.** - 테스트: `import { describe, it, expect } from 'vitest'`. 실행 `npm run test:run`. 파일 컨벤션 `*.test.js(x)` 동일 디렉토리 배치. - 색상: 매수 `#22c55e`, 매도 `#ef4444` (기존 `ACTION_MAP` 팔레트 일치). - CSS 토큰 재사용: `--line`, `--surface`, `--radius-lg`, `--muted`, `--accent-stock`. 카드 관례: `background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px`. - 커밋은 `web-ui` 경로에서만. `.env`·무관 파일 커밋 금지 (변경 파일만 명시적 `git add`). - BE 계약 (소비 대상): - `GET /api/stock/watchlist` → `{ watchlist: [{ ticker, name, note, params, added_at }] }` - `POST /api/stock/watchlist` body `{ ticker, name?, note? }` → `{ ok: true }` - `DELETE /api/stock/watchlist/{ticker}` → 200/404 - `GET /api/stock/trade-alerts?days=N` → `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` - `kind`: `buy`|`sell`. `condition`: `buy_ma20_pullback`/`buy_breakout`/`buy_rsi_bounce`/`sell_stop_loss`/`sell_ma_break`/`sell_take_profit`/`sell_climax`/`sell_trailing_stop`. - 응답은 방어적 파싱: 배열 직접 반환 / 래핑(`watchlist`·`alerts`) 둘 다 허용. --- ### Task 1: 순수 헬퍼 `watchlistUtils.js` (라벨/색/시간 매핑) **Files:** - Create: `src/pages/stock/watchlistUtils.js` - Test: `src/pages/stock/watchlistUtils.test.js` **Interfaces:** - Produces: - `KIND_META: { buy: {label,color,bg}, sell: {label,color,bg} }` - `kindMeta(kind: string) => { label, color, bg }` (미정의 → 회색 폴백 + 원문 label) - `CONDITION_LABEL: Record` - `conditionLabel(cond: string) => string` (미정의 → 원문 폴백) - `normalizeTicker(str) => string` (trim만) - `relativeTime(iso: string, now?: number) => string` - [ ] **Step 1: Write the failing test** Create `src/pages/stock/watchlistUtils.test.js`: ```js import { describe, it, expect } from 'vitest'; import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js'; describe('kindMeta', () => { it('buy/sell 라벨과 색을 반환', () => { expect(kindMeta('buy').label).toBe('매수'); expect(kindMeta('buy').color).toBe('#22c55e'); expect(kindMeta('sell').label).toBe('매도'); expect(kindMeta('sell').color).toBe('#ef4444'); }); it('미정의 kind는 회색 폴백 + 원문 label', () => { const m = kindMeta('weird'); expect(m.label).toBe('weird'); expect(m.color).toBe('#94a3b8'); }); }); describe('conditionLabel', () => { it('정의된 8종을 한글로 매핑', () => { expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등'); expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파'); expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등'); expect(conditionLabel('sell_stop_loss')).toBe('손절 라인'); expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈'); expect(conditionLabel('sell_take_profit')).toBe('목표가 도달'); expect(conditionLabel('sell_climax')).toBe('과열 소진'); expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱'); }); it('미정의 condition은 원문 폴백', () => { expect(conditionLabel('buy_unknown')).toBe('buy_unknown'); expect(conditionLabel(undefined)).toBe(''); }); }); describe('normalizeTicker', () => { it('공백 trim', () => { expect(normalizeTicker(' 005930 ')).toBe('005930'); expect(normalizeTicker(undefined)).toBe(''); }); }); describe('relativeTime', () => { const now = new Date('2026-07-03T12:00:00Z').getTime(); it('60초 미만은 방금', () => { expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금'); }); it('분/시간/어제/일 경계', () => { expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전'); expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전'); expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제'); expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전'); }); it('잘못된/빈 값은 빈 문자열', () => { expect(relativeTime('', now)).toBe(''); expect(relativeTime('not-a-date', now)).toBe(''); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js` Expected: FAIL — `Failed to resolve import "./watchlistUtils.js"` (파일 없음). - [ ] **Step 3: Write the implementation** Create `src/pages/stock/watchlistUtils.js`: ```js /* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */ export const KIND_META = { buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, }; const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' }; export const kindMeta = (kind) => { const meta = KIND_META[kind]; if (meta) return meta; return { ...FALLBACK_KIND, label: kind ?? '' }; }; export const CONDITION_LABEL = { buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파', buy_rsi_bounce: 'RSI 과매도 반등', sell_stop_loss: '손절 라인', sell_ma_break: '이평선 이탈', sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱', }; export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? ''; export const normalizeTicker = (str) => String(str ?? '').trim(); export const relativeTime = (iso, now = Date.now()) => { if (!iso) return ''; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return ''; const diffMs = now - then; if (diffMs < 0) return '방금'; const sec = Math.floor(diffMs / 1000); if (sec < 60) return '방금'; const min = Math.floor(sec / 60); if (min < 60) return `${min}분 전`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}시간 전`; const day = Math.floor(hr / 24); if (day === 1) return '어제'; if (day < 7) return `${day}일 전`; return new Date(iso).toLocaleDateString('ko-KR'); }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js` Expected: PASS (4 describe 블록 전부 통과). - [ ] **Step 5: Commit** ```bash git add src/pages/stock/watchlistUtils.js src/pages/stock/watchlistUtils.test.js git commit -m "feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트" ``` --- ### Task 2: API 헬퍼 + `useWatchlist` 훅 **Files:** - Modify: `src/api.js` (파일 끝에 추가) - Create: `src/pages/stock/hooks/useWatchlist.js` - Test: `src/pages/stock/hooks/useWatchlist.test.js` **Interfaces:** - Consumes (Task 1): `normalizeTicker` - Produces: - `api.js`: `getWatchlist()`, `addWatchlist(body)`, `removeWatchlist(ticker)`, `getTradeAlerts(days=7)` - `useWatchlist() => { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload }` - `add({ ticker, name?, note? })` — 낙관적 추가 후 `reload`, 실패 시 롤백 - `remove(ticker)` — 낙관적 제거, 실패 시 롤백 - [ ] **Step 1: Add API helpers** `src/api.js` 파일 맨 끝(마지막 `compatDeleteReading` 함수 뒤)에 추가: ```js // ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ── // GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] } // POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true } // DELETE /api/stock/watchlist/{ticker} → 200/404 // GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } export const getWatchlist = () => apiGet('/api/stock/watchlist'); export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`); export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`); ``` - [ ] **Step 2: Write the failing hook test** Create `src/pages/stock/hooks/useWatchlist.test.js`: ```js import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; vi.mock('../../../api', () => ({ getWatchlist: vi.fn(), addWatchlist: vi.fn(), removeWatchlist: vi.fn(), getTradeAlerts: vi.fn(), })); import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; import useWatchlist from './useWatchlist'; beforeEach(() => { vi.clearAllMocks(); getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] }); getTradeAlerts.mockResolvedValue({ alerts: [] }); addWatchlist.mockResolvedValue({ ok: true }); removeWatchlist.mockResolvedValue({ ok: true }); }); describe('useWatchlist', () => { it('마운트 시 watchlist를 로드', async () => { const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.items).toHaveLength(1)); expect(result.current.items[0].ticker).toBe('005930'); }); it('배열 직접 반환도 방어적으로 파싱', async () => { getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]); const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.items).toHaveLength(1)); expect(result.current.items[0].ticker).toBe('000660'); }); it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => { getWatchlist .mockResolvedValueOnce({ watchlist: [] }) .mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] }); const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); }); expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined }); await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true)); }); it('add 실패 시 롤백 + error', async () => { getWatchlist.mockResolvedValue({ watchlist: [] }); addWatchlist.mockRejectedValue(new Error('HTTP 500 err')); const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.add({ ticker: '000660' }); }); await waitFor(() => expect(result.current.error).toContain('HTTP 500')); expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false); }); it('중복 ticker는 add 차단', async () => { const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.items).toHaveLength(1)); await act(async () => { await result.current.add({ ticker: '005930' }); }); expect(addWatchlist).not.toHaveBeenCalled(); expect(result.current.error).toContain('이미'); }); it('remove: 낙관적 제거 + DELETE 호출', async () => { const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.items).toHaveLength(1)); await act(async () => { await result.current.remove('005930'); }); expect(removeWatchlist).toHaveBeenCalledWith('005930'); expect(result.current.items).toHaveLength(0); }); it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => { getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing')); const { result } = renderHook(() => useWatchlist()); await waitFor(() => expect(result.current.items).toHaveLength(1)); await waitFor(() => expect(result.current.alertError).toContain('HTTP 404')); expect(result.current.alerts).toHaveLength(0); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js` Expected: FAIL — `Failed to resolve import "./useWatchlist"` (파일 없음). - [ ] **Step 4: Write the hook** Create `src/pages/stock/hooks/useWatchlist.js`: ```js import { useCallback, useEffect, useState } from 'react'; import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; import { normalizeTicker } from '../watchlistUtils'; const asArray = (data, key) => { if (Array.isArray(data)) return data; if (data && Array.isArray(data[key])) return data[key]; return []; }; const byFiredAtDesc = (a, b) => new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime(); export default function useWatchlist() { const [items, setItems] = useState([]); const [alerts, setAlerts] = useState([]); const [alertDays, setAlertDays] = useState(7); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [alertError, setAlertError] = useState(''); const [adding, setAdding] = useState(false); const loadWatchlist = useCallback(async () => { setLoading(true); setError(''); try { const data = await getWatchlist(); setItems(asArray(data, 'watchlist')); } catch (e) { setError(e?.message ?? String(e)); } finally { setLoading(false); } }, []); const loadAlerts = useCallback(async (days) => { setAlertError(''); try { const data = await getTradeAlerts(days); setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc)); } catch (e) { setAlertError(e?.message ?? String(e)); setAlerts([]); } }, []); useEffect(() => { loadWatchlist(); }, [loadWatchlist]); useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]); const add = useCallback(async ({ ticker, name, note }) => { const t = normalizeTicker(ticker); if (!t) return; if (items.some((it) => it.ticker === t)) { setError(`이미 관심종목에 있습니다: ${t}`); return; } setAdding(true); setError(''); const cleanName = (name ?? '').trim(); const cleanNote = (note ?? '').trim(); const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() }; setItems((prev) => [optimistic, ...prev]); try { await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined }); await loadWatchlist(); } catch (e) { setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백 setError(e?.message ?? String(e)); } finally { setAdding(false); } }, [items, loadWatchlist]); const remove = useCallback(async (ticker) => { const prev = items; setItems((cur) => cur.filter((it) => it.ticker !== ticker)); setError(''); try { await removeWatchlist(ticker); } catch (e) { setItems(prev); // 롤백 setError(e?.message ?? String(e)); } }, [items]); return { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload: loadWatchlist, }; } ``` - [ ] **Step 5: Run test to verify it passes** Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js` Expected: PASS (7 케이스 통과). - [ ] **Step 6: Commit** ```bash git add src/api.js src/pages/stock/hooks/useWatchlist.js src/pages/stock/hooks/useWatchlist.test.js git commit -m "feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트" ``` --- ### Task 3: `WatchlistTab.jsx` 컴포넌트 + 스타일 **Files:** - Create: `src/pages/stock/components/WatchlistTab.jsx` - Modify: `src/pages/stock/Stock.css` (파일 끝에 `wl-*` 섹션 추가) - Test: `src/pages/stock/components/WatchlistTab.test.jsx` **Interfaces:** - Consumes (Task 1): `kindMeta`, `conditionLabel`, `relativeTime`; (stockUtils) `formatNumber`; (Task 2) `useWatchlist` 반환 형태 — 단, 컴포넌트는 훅 결과를 `wl` **prop**으로 받는다(테스트/뱃지 용이). - Produces: `WatchlistTab({ wl })` 기본 export (React 컴포넌트). - [ ] **Step 1: Write the failing smoke test** Create `src/pages/stock/components/WatchlistTab.test.jsx`: ```jsx import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import WatchlistTab from './WatchlistTab.jsx'; const baseWl = { items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(), loading: false, error: '', alertError: '', adding: false, add: vi.fn(), remove: vi.fn(), reload: vi.fn(), }; describe('WatchlistTab', () => { it('빈 상태: 헤딩과 빈 안내 노출', () => { render(); expect(screen.getByText('관심종목 관리')).toBeInTheDocument(); expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument(); expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument(); }); it('종목·알림이 있으면 렌더', () => { const wl = { ...baseWl, items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }], alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }], }; render(); expect(screen.getByText('삼성전자')).toBeInTheDocument(); expect(screen.getByText('매수')).toBeInTheDocument(); expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument(); }); }); ``` > 참고: `toBeInTheDocument` 매처는 `@testing-library/jest-dom`(devDependency)에서 제공된다. 기존 테스트 셋업에서 전역 등록이 안 되어 있으면 테스트 파일 상단에 `import '@testing-library/jest-dom';` 한 줄을 추가한다. - [ ] **Step 2: Run test to verify it fails** Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx` Expected: FAIL — `Failed to resolve import "./WatchlistTab.jsx"` (파일 없음). - [ ] **Step 3: Write the component** Create `src/pages/stock/components/WatchlistTab.jsx`: ```jsx import React, { useState } from 'react'; import Loading from '../../../components/Loading'; import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils'; import { formatNumber } from '../stockUtils'; const DAYS_OPTIONS = [ { value: 1, label: '1D' }, { value: 7, label: '7D' }, { value: 30, label: '30D' }, ]; const AlertCard = ({ a }) => { const meta = kindMeta(a.kind); return (
{meta.label} {a.name || a.ticker} {a.ticker} {relativeTime(a.fired_at)}
{conditionLabel(a.condition)} {a.price != null && {formatNumber(a.price)}원}
{a.detail &&
{a.detail}
}
); }; const WatchlistTab = ({ wl }) => { const [form, setForm] = useState({ ticker: '', name: '', note: '' }); const submit = async (e) => { e.preventDefault(); if (!form.ticker.trim()) return; await wl.add(form); setForm({ ticker: '', name: '', note: '' }); }; return ( <> {/* 관심종목 관리 */}

관심종목

관심종목 관리

등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.

{wl.loading && }
setForm((f) => ({ ...f, ticker: e.target.value }))} /> setForm((f) => ({ ...f, name: e.target.value }))} /> setForm((f) => ({ ...f, note: e.target.value }))} />
{wl.error &&

{wl.error}

} {wl.items.length === 0 ? (

아직 관심종목이 없습니다. 종목코드를 추가해 보세요.

) : (
    {wl.items.map((it) => (
  • {it.name || it.ticker} {it.ticker} {it.note && {it.note}}
  • ))}
)}
{/* 최근 시그널 알림 */}

시그널

최근 매매 알림

감시 종목에서 발생한 매수·매도 시그널 이력입니다.

{DAYS_OPTIONS.map((o) => ( ))}
{wl.alertError &&

{wl.alertError}

} {wl.alerts.length === 0 ? (

해당 기간에 발생한 알림이 없습니다.

) : (
{wl.alerts.map((a) => ( ))}
)}

※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.

); }; export default WatchlistTab; ``` - [ ] **Step 4: Append styles to `Stock.css`** `src/pages/stock/Stock.css` 파일 맨 끝에 추가: ```css /* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */ .wl-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .wl-form__input { flex: 1 1 140px; min-width: 120px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255, 255, 255, 0.03); color: inherit; font-size: 13px; } .wl-form__input:focus { outline: none; border-color: var(--accent-stock); } .wl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .wl-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(148, 163, 184, 0.12); border-radius: 10px; padding: 10px 14px; } .wl-row__meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; min-width: 0; } .wl-row__name { font-size: 14px; } .wl-row__ticker { font-size: 12px; color: var(--muted); } .wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; } .wl-del { flex: none; border: none; background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; line-height: 1; padding: 4px 6px; border-radius: 6px; } .wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } .wl-period-toggle { display: flex; gap: 4px; } .wl-period { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer; } .wl-period.is-active { color: var(--accent-stock); border-color: var(--accent-stock); background: rgba(148, 163, 184, 0.08); } .wl-alerts { display: flex; flex-direction: column; gap: 10px; } .wl-alert { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(148, 163, 184, 0.12); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; } .wl-alert__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .wl-kind-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; } .wl-alert__name { font-size: 14px; } .wl-alert__ticker { font-size: 12px; color: var(--muted); } .wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; } .wl-alert__body { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } .wl-cond { font-size: 13px; font-weight: 600; } .wl-alert__price { font-size: 13px; color: var(--muted); } .wl-alert__detail { font-size: 12px; color: var(--muted); } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx` Expected: PASS (2 케이스 통과). - [ ] **Step 6: Commit** ```bash git add src/pages/stock/components/WatchlistTab.jsx src/pages/stock/components/WatchlistTab.test.jsx src/pages/stock/Stock.css git commit -m "feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트" ``` --- ### Task 4: `StockTrade`에 탭 등재 + 문서 갱신 **Files:** - Modify: `src/pages/stock/stockUtils.js:152` (TAB 상수 추가) - Modify: `src/pages/stock/StockTrade.jsx` (import·훅·탭 배열·렌더) - Modify: `CLAUDE.md` (API 엔드포인트 테이블 — web-ui 루트가 아닌 `web-ui/CLAUDE.md`) **Interfaces:** - Consumes (Task 2·3): `useWatchlist`, `WatchlistTab` - Produces: 없음 (통합 지점, 최종 배선) - [ ] **Step 1: Add TAB constant** `src/pages/stock/stockUtils.js` 맨 끝(`export const TAB_HOLDINGS_INTEL = 'holdings_intel';` 뒤)에 추가: ```js export const TAB_WATCHLIST = 'watchlist'; ``` - [ ] **Step 2: Wire into StockTrade.jsx** `src/pages/stock/StockTrade.jsx` 수정 — 4곳: (a) stockUtils import에 `TAB_WATCHLIST` 추가 (기존 import 블록 line 6-10): ```js import { formatNumber, formatPercent, toNumeric, profitColorClass, TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST, } from './stockUtils'; ``` (b) 탭 컴포넌트 import 추가 (기존 `import HoldingsIntelTab ...` 뒤, line 25 근처): ```js import HoldingsIntelTab from './components/HoldingsIntelTab'; import WatchlistTab from './components/WatchlistTab'; ``` (c) 훅 인스턴스화 + `TAB_ORDER`/`tabLabels` 확장. `const [activeTab, ...]` 아래(line 31 근처)와 hooks 블록에 추가: ```js const wl = useWatchlist(); const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST]; const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목']; ``` 그리고 파일 상단 hooks import 목록에 훅 import 추가 (line 19 `import useAdvisor ...` 뒤): ```js import useAdvisor from './hooks/useAdvisor'; import useWatchlist from './hooks/useWatchlist'; ``` `const wl = useWatchlist();` 는 다른 훅들(`const advisor = useAdvisor({...});`) 뒤에 배치. (d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 `: ,` 를 다음으로 교체: ```js content: tabId === TAB_PORTFOLIO ? : tabId === TAB_REPORT ? : tabId === TAB_ADVISOR ? : tabId === TAB_HOLDINGS_INTEL ? : , ``` (e) 데스크탑 탭 버튼 배열에 항목 추가. 기존 `{ id: TAB_HOLDINGS_INTEL, ... }` 항목 뒤에 추가: ```js { id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' }, { id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null }, ``` (f) 데스크탑 조건부 렌더 추가. 기존 `{activeTab === TAB_HOLDINGS_INTEL && }` 뒤에 추가: ```js {activeTab === TAB_HOLDINGS_INTEL && } {activeTab === TAB_WATCHLIST && } ``` - [ ] **Step 3: Run the full test suite** Run: `npm run test:run` Expected: PASS — 신규 3개 테스트 파일 포함 전체 통과 (기존 테스트 회귀 없음). - [ ] **Step 4: Lint + build** Run: `npm run lint` Expected: 신규 파일 관련 에러 0. (기존 코드의 사전 경고는 무시하되, 신규 파일이 새 에러를 만들지 않을 것.) Run: `npm run build` Expected: 빌드 성공 (`dist/` 생성, 에러 없음). - [ ] **Step 5: Manual verification (dev server)** ```bash npm run dev ``` 브라우저에서 `http://localhost:3007/stock/trade` 접속 → "관심종목" 탭이 데스크탑 탭바(⭐)와 모바일 스와이프에 노출되는지 확인. 종목코드 입력 후 추가 → 목록 반영, 삭제 버튼 동작, 기간 토글(1D/7D/30D) 확인. (BE 미배포 시 알림 패널은 에러/빈 상태로 표시되고 CRUD는 독립 동작해야 함.) - [ ] **Step 6: Update `web-ui/CLAUDE.md` API 테이블** `CLAUDE.md` (web-ui 프로젝트 루트) 의 "API 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처): ```markdown | 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } | | 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } | | 관심종목 | DELETE | `/api/stock/watchlist/:ticker` | | 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } | ``` 그리고 페이지 구조 표의 `/stock/trade` 행 설명에 "(포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭)" 취지를 반영. - [ ] **Step 7: Commit** ```bash git add src/pages/stock/stockUtils.js src/pages/stock/StockTrade.jsx CLAUDE.md git commit -m "feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신" ``` --- ## Self-Review 결과 **Spec coverage** (설계 §1–§10 대비): - §2 계약 4종 → Task 2 (api 헬퍼) ✅ - §3 탭 등재 → Task 4 ✅ - §4 컴포넌트 구조(훅+자립형 탭+utils) → Task 1/2/3 ✅ - §5 API 레이어 → Task 2 ✅ - §6 UX(낙관적 갱신·중복 차단·기간 토글·정렬) → Task 2(훅)·Task 3(뷰) ✅ - §7 스타일 `wl-*` → Task 3 ✅ - §8 테스트 → Task 1(utils)·Task 2(훅)·Task 3(컴포넌트) ✅ - §9 완료 기준 → Task 4 Step 3–6 ✅ - §10 리스크(방어적 파싱·알림 독립) → `asArray` + `alertError` 분리 ✅ **Placeholder scan:** 모든 코드/명령/기대출력 구체값 명시. TBD/TODO 없음. ✅ **Type consistency:** `kindMeta`/`conditionLabel`/`relativeTime`/`normalizeTicker` (Task1) ↔ 훅/컴포넌트 사용처 일치. `useWatchlist` 반환 키(`items/alerts/alertDays/setAlertDays/loading/error/alertError/adding/add/remove/reload`) ↔ `WatchlistTab` prop 사용처 일치. `getWatchlist/addWatchlist/removeWatchlist/getTradeAlerts` (api) ↔ 훅 import 일치. ✅ **참고 — StockTrade 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.