merge: 관심종목 탭 (watchlist CRUD + 매매 시그널 알림) FE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
2026-07-03 10:04:55 +09:00
13 changed files with 1710 additions and 5 deletions

View File

@@ -16,7 +16,7 @@
| `/blog` | `Blog` | 마크다운 기반 블로그 | | `/blog` | `Blog` | 마크다운 기반 블로그 |
| `/lotto` | `Lotto` | 로또 추천/통계 | | `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 | | `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 | | `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) | | `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 | | `/realestate/property` | `RealEstate` | 관심 단지 정보 |
@@ -102,6 +102,10 @@ proxy: {
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` | | 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` | | 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
| 스크리너 | GET | `/api/stock/screener/runs/:id` | | 스크리너 | GET | `/api/stock/screener/runs/:id` |
| 관심종목 | 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 }] } |
| 포트폴리오 | GET/POST | `/api/portfolio` | | 포트폴리오 | GET/POST | `/api/portfolio` |
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` | | 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` | | 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |

View File

@@ -0,0 +1,906 @@
# 관심종목 탭 (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<string,string>`
- `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(<WatchlistTab wl={baseWl} />);
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(<WatchlistTab wl={wl} />);
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 (
<div className="wl-alert">
<div className="wl-alert__head">
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
<span className="wl-alert__ticker">{a.ticker}</span>
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
</div>
<div className="wl-alert__body">
<span className="wl-cond">{conditionLabel(a.condition)}</span>
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}</span>}
</div>
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
</div>
);
};
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 (
<>
{/* 관심종목 관리 */}
<section className="stock-panel stock-panel--wide wl-panel">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">관심종목</p>
<h3>관심종목 관리</h3>
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
</div>
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
</div>
<form className="wl-form" onSubmit={submit}>
<input
className="wl-form__input"
placeholder="종목코드 (예: 005930)"
value={form.ticker}
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
/>
<input
className="wl-form__input"
placeholder="종목명 (선택)"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
className="wl-form__input"
placeholder="메모 (선택)"
value={form.note}
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
/>
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
{wl.adding ? '추가 중…' : '추가'}
</button>
</form>
{wl.error && <p className="stock-error">{wl.error}</p>}
{wl.items.length === 0 ? (
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
) : (
<ul className="wl-list">
{wl.items.map((it) => (
<li key={it.ticker} className="wl-row">
<div className="wl-row__meta">
<strong className="wl-row__name">{it.name || it.ticker}</strong>
<span className="wl-row__ticker">{it.ticker}</span>
{it.note && <span className="wl-row__note">{it.note}</span>}
</div>
<button
className="wl-del"
type="button"
aria-label={`${it.ticker} 삭제`}
onClick={() => wl.remove(it.ticker)}
>
</button>
</li>
))}
</ul>
)}
</section>
{/* 최근 시그널 알림 */}
<section className="stock-panel stock-panel--wide wl-panel">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">시그널</p>
<h3>최근 매매 알림</h3>
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
</div>
<div className="wl-period-toggle">
{DAYS_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
onClick={() => wl.setAlertDays(o.value)}
>
{o.label}
</button>
))}
</div>
</div>
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
{wl.alerts.length === 0 ? (
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
) : (
<div className="wl-alerts">
{wl.alerts.map((a) => (
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
))}
</div>
)}
<p className="hi-disclaimer"> 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
</section>
</>
);
};
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 추가. 기존 `: <HoldingsIntelTab />,` 를 다음으로 교체:
```js
content: tabId === TAB_PORTFOLIO
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
: tabId === TAB_REPORT
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
: tabId === TAB_ADVISOR
? <AdvisorTab pf={pf} advisor={advisor} />
: tabId === TAB_HOLDINGS_INTEL
? <HoldingsIntelTab />
: <WatchlistTab wl={wl} />,
```
(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 && <HoldingsIntelTab />}` 뒤에 추가:
```js
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
```
- [ ] **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 36 ✅
- §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 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.

View File

@@ -0,0 +1,174 @@
# 관심종목 탭 (Watchlist Tab) — FE 설계
- **작성일**: 2026-07-03
- **역할/저장소**: FE (`web-ui`)
- **상위 스펙(BE)**: `web-page-backend/docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §2·§5.3
- **상위 플랜(BE)**: `web-page-backend/docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md`
- **범위**: FE(web-ui)만. BE 계약(§5.3)을 소비하는 "관심종목" 탭 구현. 워커(web-ai)·BE는 별도 세션.
---
## 1. 배경 & 목표
실시간 매매 알림 시스템의 매수 유니버스는 **"watchlist(사용자 관리) 당일 스크리너 후보"** 로 정의된다(BE 스펙 §2). 관심종목 관리 수단은 **"텔레그램 봇 명령 + web-ui 탭 둘 다"** 로 결정되었다. 본 문서는 그중 **web-ui 탭**을 정의한다.
목표:
1. 사용자가 관심종목을 웹에서 추가/조회/삭제(CRUD)할 수 있다.
2. 최근 발생한 매수·매도 시그널 알림 이력을 웹에서 확인할 수 있다.
비목표(YAGNI, v1 제외):
- 종목별 조건 오버라이드(`params_json`: trailing_pct, stop_pct 등) 편집 — BE POST/PUT params 계약 미확정.
- 실시간 WebSocket 알림 스트림 — 폴링/수동 새로고침으로 충분.
- 텔레그램 설정 UI.
---
## 2. 소비할 BE 계약 (§5.3)
| 메서드 | 경로 | 요청 | 응답 |
|--------|------|------|------|
| GET | `/api/stock/watchlist` | — | `{ watchlist: [{ ticker, name, note, params, added_at }] }` |
| POST | `/api/stock/watchlist` | `{ ticker, name?, note? }` | 201 `{ 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 }] }` |
**알림 필드 enum (BE 스펙 §5.3):**
- `kind`: `buy` | `sell`
- `condition` (buy): `buy_ma20_pullback` · `buy_breakout` · `buy_rsi_bounce`
- `condition` (sell): `sell_stop_loss` · `sell_ma_break` · `sell_take_profit` · `sell_climax` · `sell_trailing_stop`
> 응답 래핑 키(`watchlist`/`alerts`)와 `params` 필드는 BE 스펙 문구 기준. FE는 방어적으로 파싱한다(배열 직접 반환 / 래핑 둘 다 허용, `params` 미사용이면 무시).
---
## 3. 배치 & 탭 등재
`/stock/trade` (거래 데스크)에 5번째 메인 탭 **"관심종목"** 추가. 기존 탭 등재 패턴을 그대로 확장한다.
- `src/pages/stock/stockUtils.js`: `export const TAB_WATCHLIST = 'watchlist';`
- `src/pages/stock/StockTrade.jsx`:
- `TAB_ORDER` 배열에 `TAB_WATCHLIST` 추가
- `tabLabels``'관심종목'` 추가
- 데스크탑 탭 버튼 배열에 `{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: <count> }` 추가
- 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가
- 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && <WatchlistTab />}` 추가
- 탭 뱃지 = 관심종목 개수(훅에서 노출).
---
## 4. 컴포넌트 구조 (접근안 A: 훅 + 자립형 탭)
기존 `HoldingsIntelTab` 패턴(자립형 탭 컴포넌트 + api 헬퍼)에 상태 로직을 훅으로 분리한 형태.
```
src/pages/stock/
├── hooks/
│ └── useWatchlist.js # CRUD + 알림 이력 상태·액션
├── components/
│ └── WatchlistTab.jsx # 표현 (내부 소형 컴포넌트: WatchlistForm/Row, AlertCard)
├── watchlistUtils.js # 순수 헬퍼 (라벨/색/시간 매핑)
└── watchlistUtils.test.js # 헬퍼 유닛 테스트
```
### 4.1 `useWatchlist.js` (훅)
상태:
- `items: []` — 관심종목 목록
- `alerts: []` — 알림 이력
- `alertDays: 7` — 알림 기간 필터(1/7/30)
- `loading`, `error`, `adding` (폼 제출 중)
액션:
- `load()``getWatchlist()` + `getTradeAlerts(alertDays)` 병렬 로드
- `add({ ticker, name, note })` — 낙관적 추가 → 성공 시 `load()` 재조회, 실패 시 롤백 + 에러
- `remove(ticker)` — 낙관적 제거 → 실패 시 롤백
- `setAlertDays(days)` — 변경 시 알림만 재조회
노출: `{ items, alerts, alertDays, setAlertDays, loading, error, adding, add, remove, load }`
### 4.2 `WatchlistTab.jsx` (표현)
- 마운트 시 `load()`.
- **상단 패널 — 관심종목 관리**: 인라인 추가 폼(ticker 필수, name·note 선택) + 목록. 각 행: 종목명/코드/메모/등록일 + 삭제 버튼. 빈 상태 안내.
- **하단 패널 — 최근 시그널**: 기간 토글(1D/7D/30D) + 알림 카드. 카드: `kind` 뱃지, `condition` 한글 라벨, `ticker`/`name`, `price`, `detail`, `fired_at` 상대시간.
- 로딩/에러/빈 상태: `stock-panel` · `stock-error` · `stock-empty` 등 기존 클래스 재사용.
- 하단 면책 문구(`hi-disclaimer` 유사): "※ 어드바이저리 알림이며 자동매매가 아닙니다."
### 4.3 `watchlistUtils.js` (순수 헬퍼 — 테스트 대상)
```js
KIND_META = { buy: { label: '매수', color, bg }, sell: { label: '매도', color, bg } }
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: '트레일링 스톱' }
kindMeta(kind) // 미정의 → 회색 폴백 + 원문 label
conditionLabel(cond) // 미정의 → 원문 그대로 반환
normalizeTicker(str) // trim만 수행(한국 종목코드=6자리 숫자, 대문자화 불필요)
relativeTime(iso) // '3분 전' / '2시간 전' / '어제' 등, 잘못된 값 → '' 폴백
```
---
## 5. API 레이어 (`src/api.js` 추가)
```js
// ── Stock Watchlist / Trade Alerts ──
export const getWatchlist = () => apiGet('/api/stock/watchlist');
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); // { ticker, name?, note? }
export const removeWatchlist= (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
export const getTradeAlerts = (days = 7)=> apiGet(`/api/stock/trade-alerts?days=${days}`);
```
전부 상대경로, 기존 `apiGet/apiPost/apiDelete` 재사용. `getWatchlist`/`getTradeAlerts` 응답은 훅에서 `data.watchlist ?? data ?? []`, `data.alerts ?? data ?? []` 로 방어적 파싱.
---
## 6. UX / 상호작용 세부
- **추가 폼**: ticker 미입력 시 제출 비활성. 제출 중 `adding` → 버튼 로딩. 성공 시 폼 초기화.
- **낙관적 갱신**: add/remove 즉시 UI 반영, 실패 시 이전 상태 롤백 + `stock-error` 메시지.
- **중복 방지**: 이미 목록에 있는 ticker면 폼에서 안내(추가 차단).
- **알림 카드 정렬**: `fired_at` 내림차순(최신 우선).
- **빈 상태**: 관심종목 0개 / 알림 0개 각각 안내 문구.
- **반응형**: 데스크탑 2열/모바일 1열은 기존 `stock-panel` 그리드 관례 따름.
---
## 7. 스타일
`src/pages/stock/Stock.css` 하단에 `wl-*` 프리픽스 섹션 추가 (기존 `hi-*` 패턴과 동일 구성):
- `.wl-form`, `.wl-list`, `.wl-row`, `.wl-row__meta`, `.wl-del`
- `.wl-alerts`, `.wl-alert`, `.wl-kind-badge`, `.wl-cond`, `.wl-period-toggle`
- 색상: 매수 초록 `#22c55e`, 매도 빨강 `#ef4444` (기존 `ACTION_MAP` 팔레트와 일치).
---
## 8. 테스트 (TDD)
`watchlistUtils.test.js` — 순수 헬퍼 검증:
1. `conditionLabel`: 정의된 8종 매핑 정확, 미정의 값은 원문 폴백.
2. `kindMeta`: buy/sell 라벨·색, 미정의 kind 회색 폴백.
3. `relativeTime`: 방금/분/시간/일 경계, 잘못된 입력 `''` 폴백.
4. `normalizeTicker`: 공백 trim.
컴포넌트/훅은 수동 검증(개발 서버 3007 + BE 계약) + 빌드/lint 통과로 확인. (기존 스크리너 훅 테스트처럼 필요 시 훅 테스트 추가 가능하나 v1 필수 아님.)
---
## 9. 완료 기준 (Acceptance)
- [ ] 거래 데스크에 "관심종목" 탭 노출(데스크탑·모바일), 뱃지에 개수 표시.
- [ ] 종목 추가/삭제가 BE 계약대로 동작(낙관적 갱신 + 실패 롤백).
- [ ] 최근 알림 이력이 기간 토글별로 조회되고, kind/condition 한글 라벨·색으로 표시.
- [ ] `watchlistUtils.test.js` 통과.
- [ ] `npm run lint` · `npm run build` 통과.
---
## 10. 리스크 / 오픈 이슈
- **응답 래핑 형태 미확정**: BE가 `{ watchlist: [...] }` 인지 배열 직접인지 문구 기준 불확실 → 방어적 파싱으로 흡수.
- **알림 엔드포인트 미배포 가능성**: BE 세션 미완 시 GET `/api/stock/trade-alerts` 404/네트워크 오류 → 알림 패널은 에러 상태를 조용히 표시하고 관심종목 CRUD는 독립 동작하도록 분리.
- **params 편집**: v1 제외. 추후 BE POST/PUT params 계약 확정 후 별도 스펙으로 확장.

View File

@@ -852,3 +852,13 @@ export function compatPatchReading(id, body) {
export function compatDeleteReading(id) { export function compatDeleteReading(id) {
return apiDelete(`/api/saju/compat/readings/${id}`); return apiDelete(`/api/saju/compat/readings/${id}`);
} }
// ── 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}`);

View File

@@ -3232,3 +3232,121 @@
display: none; display: none;
} }
} }
/* ── 관심종목 탭 (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); }

View File

@@ -6,7 +6,7 @@ import SwipeableView from '../../components/SwipeableView';
import { import {
formatNumber, formatPercent, formatNumber, formatPercent,
toNumeric, profitColorClass, toNumeric, profitColorClass,
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
} from './stockUtils'; } from './stockUtils';
/* ── hooks ──────────────────────────────────────────────────────── */ /* ── hooks ──────────────────────────────────────────────────────── */
@@ -17,12 +17,14 @@ import useAssetHistory from './hooks/useAssetHistory';
import useMarketContext from './hooks/useMarketContext'; import useMarketContext from './hooks/useMarketContext';
import useReportData from './hooks/useReportData'; import useReportData from './hooks/useReportData';
import useAdvisor from './hooks/useAdvisor'; import useAdvisor from './hooks/useAdvisor';
import useWatchlist from './hooks/useWatchlist';
/* ── tab components ─────────────────────────────────────────────── */ /* ── tab components ─────────────────────────────────────────────── */
import PortfolioTab from './components/PortfolioTab'; import PortfolioTab from './components/PortfolioTab';
import ReportTab from './components/ReportTab'; import ReportTab from './components/ReportTab';
import AdvisorTab from './components/AdvisorTab'; import AdvisorTab from './components/AdvisorTab';
import HoldingsIntelTab from './components/HoldingsIntelTab'; import HoldingsIntelTab from './components/HoldingsIntelTab';
import WatchlistTab from './components/WatchlistTab';
import SellHistoryDrawer from './components/SellHistoryDrawer'; import SellHistoryDrawer from './components/SellHistoryDrawer';
/* ── component ───────────────────────────────────────────────────── */ /* ── component ───────────────────────────────────────────────────── */
@@ -31,8 +33,8 @@ const StockTrade = () => {
const [activeTab, setActiveTab] = React.useState(TAB_REPORT); const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL]; const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔']; const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
const tabIndex = TAB_ORDER.indexOf(activeTab); const tabIndex = TAB_ORDER.indexOf(activeTab);
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -62,6 +64,7 @@ const StockTrade = () => {
totalAssets: pf.totalAssets, totalAssets: pf.totalAssets,
marketCtx, marketCtx,
}); });
const wl = useWatchlist();
/* ── sell history filter derived ─────────────────────────────── */ /* ── sell history filter derived ─────────────────────────────── */
const sellHistoryBrokers = useMemo(() => { const sellHistoryBrokers = useMemo(() => {
@@ -169,7 +172,9 @@ const StockTrade = () => {
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} /> ? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
: tabId === TAB_ADVISOR : tabId === TAB_ADVISOR
? <AdvisorTab pf={pf} advisor={advisor} /> ? <AdvisorTab pf={pf} advisor={advisor} />
: <HoldingsIntelTab />, : tabId === TAB_HOLDINGS_INTEL
? <HoldingsIntelTab />
: <WatchlistTab wl={wl} />,
}))} }))}
activeIndex={tabIndex} activeIndex={tabIndex}
onTabChange={handleTabChange} onTabChange={handleTabChange}
@@ -182,6 +187,7 @@ const StockTrade = () => {
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' }, { id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' }, { id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' }, { id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
].map(({ id, icon, label, sub, badge, className: cls }) => ( ].map(({ id, icon, label, sub, badge, className: cls }) => (
<button <button
key={id} key={id}
@@ -203,6 +209,7 @@ const StockTrade = () => {
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />} {activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />} {activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />} {activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
</> </>
)} )}

View File

@@ -0,0 +1,145 @@
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 (
<div className="wl-alert">
<div className="wl-alert__head">
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
<span className="wl-alert__ticker">{a.ticker}</span>
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
</div>
<div className="wl-alert__body">
<span className="wl-cond">{conditionLabel(a.condition)}</span>
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}</span>}
</div>
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
</div>
);
};
const WatchlistTab = ({ wl }) => {
const [form, setForm] = useState({ ticker: '', name: '', note: '' });
const submit = async (e) => {
e.preventDefault();
if (!form.ticker.trim()) return;
const ok = await wl.add(form);
if (ok) setForm({ ticker: '', name: '', note: '' });
};
return (
<>
{/* 관심종목 관리 */}
<section className="stock-panel stock-panel--wide wl-panel">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">관심종목</p>
<h3>관심종목 관리</h3>
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
</div>
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
</div>
<form className="wl-form" onSubmit={submit}>
<input
className="wl-form__input"
placeholder="종목코드 (예: 005930)"
value={form.ticker}
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
/>
<input
className="wl-form__input"
placeholder="종목명 (선택)"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
className="wl-form__input"
placeholder="메모 (선택)"
value={form.note}
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
/>
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
{wl.adding ? '추가 중…' : '추가'}
</button>
</form>
{wl.error && <p className="stock-error">{wl.error}</p>}
{wl.items.length === 0 ? (
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
) : (
<ul className="wl-list">
{wl.items.map((it) => (
<li key={it.ticker} className="wl-row">
<div className="wl-row__meta">
<strong className="wl-row__name">{it.name || it.ticker}</strong>
<span className="wl-row__ticker">{it.ticker}</span>
{it.note && <span className="wl-row__note">{it.note}</span>}
</div>
<button
className="wl-del"
type="button"
aria-label={`${it.ticker} 삭제`}
onClick={() => wl.remove(it.ticker)}
>
</button>
</li>
))}
</ul>
)}
</section>
{/* 최근 시그널 알림 */}
<section className="stock-panel stock-panel--wide wl-panel">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">시그널</p>
<h3>최근 매매 알림</h3>
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
</div>
<div className="wl-period-toggle">
{DAYS_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
onClick={() => wl.setAlertDays(o.value)}
>
{o.label}
</button>
))}
</div>
</div>
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
{wl.alerts.length === 0 ? (
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
) : (
<div className="wl-alerts">
{wl.alerts.map((a) => (
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
))}
</div>
)}
<p className="hi-disclaimer"> 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
</section>
</>
);
};
export default WatchlistTab;

View File

@@ -0,0 +1,31 @@
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(<WatchlistTab wl={baseWl} />);
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(<WatchlistTab wl={wl} />);
// '삼성전자'는 관심종목 목록 행과 알림 카드 양쪽에 렌더되므로 getAllByText로 확인
expect(screen.getAllByText('삼성전자')).toHaveLength(2);
expect(screen.getByText('매수')).toBeInTheDocument();
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,94 @@
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 }) => {
if (adding) return false;
const t = normalizeTicker(ticker);
if (!t) return false;
if (items.some((it) => it.ticker === t)) {
setError(`이미 관심종목에 있습니다: ${t}`);
return false;
}
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();
return true;
} catch (e) {
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
setError(e?.message ?? String(e));
return false;
} finally {
setAdding(false);
}
}, [items, loadWatchlist, adding]);
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,
};
}

View File

@@ -0,0 +1,97 @@
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));
let ok;
await act(async () => { ok = await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
expect(ok).toBe(true);
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));
let ok;
await act(async () => { ok = await result.current.add({ ticker: '000660' }); });
expect(ok).toBe(false);
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));
let ok;
await act(async () => { ok = await result.current.add({ ticker: '005930' }); });
expect(addWatchlist).not.toHaveBeenCalled();
expect(result.current.error).toContain('이미');
expect(ok).toBe(false);
});
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);
});
it('alerts를 fired_at 내림차순으로 정렬', async () => {
getTradeAlerts.mockResolvedValue({ alerts: [
{ id: 1, ticker: 'A', fired_at: '2026-07-01T00:00:00Z' },
{ id: 2, ticker: 'B', fired_at: '2026-07-03T00:00:00Z' },
{ id: 3, ticker: 'C', fired_at: '2026-07-02T00:00:00Z' },
] });
const { result } = renderHook(() => useWatchlist());
await waitFor(() => expect(result.current.alerts).toHaveLength(3));
expect(result.current.alerts.map((a) => a.id)).toEqual([2, 3, 1]);
});
});

View File

@@ -150,3 +150,4 @@ export const TAB_PORTFOLIO = 'portfolio';
export const TAB_REPORT = 'report'; export const TAB_REPORT = 'report';
export const TAB_ADVISOR = 'advisor'; export const TAB_ADVISOR = 'advisor';
export const TAB_HOLDINGS_INTEL = 'holdings_intel'; export const TAB_HOLDINGS_INTEL = 'holdings_intel';
export const TAB_WATCHLIST = 'watchlist';

View File

@@ -0,0 +1,47 @@
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
export const KIND_META = Object.freeze({
buy: Object.freeze({ label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }),
sell: Object.freeze({ 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) => {
if (Object.hasOwn(KIND_META, kind)) return KIND_META[kind];
return { ...FALLBACK_KIND, label: kind ?? '' };
};
export const CONDITION_LABEL = Object.freeze({
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) =>
Object.hasOwn(CONDITION_LABEL, 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');
};

View File

@@ -0,0 +1,71 @@
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('프로토타입 키 방어 (render-safe)', () => {
it('conditionLabel은 상속 키에도 문자열을 반환', () => {
expect(conditionLabel('toString')).toBe('toString');
expect(typeof conditionLabel('toString')).toBe('string');
expect(typeof conditionLabel('constructor')).toBe('string');
});
it('kindMeta는 상속 키에도 문자열 label + 회색 폴백', () => {
const m = kindMeta('constructor');
expect(typeof m.label).toBe('string');
expect(m.label).toBe('constructor');
expect(m.color).toBe('#94a3b8');
});
});
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('');
});
});