27 Commits

Author SHA1 Message Date
d57f9b9b65 fix(stock): 매매알람 detail 객체 렌더 크래시(React #31) 방지
/api/stock/trade-alerts의 alerts[].detail은 condition별 가변 객체
({ma50,ma200,severity} 등)인데 JSX 자식으로 직접 렌더 → React #31로
페이지 크래시(워커가 실제 알람 발화 시작하며 발현, BE msg #17).

formatDetail 헬퍼 추가: 객체를 안전 문자열로 변환(알려진 키 한글 라벨,
*_pct 퍼센트, null 값 스킵, 미정의 키도 원문 폴백 — 스키마 가정 없음).
AlertCard가 detail 대신 formatDetail(detail) 렌더.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 22:06:11 +09:00
970c8164e0 docs: README에 관심종목 탭(실시간 매매 알림 연동) 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 11:08:18 +09:00
cb15ae1d24 merge: 관심종목 탭 (watchlist CRUD + 매매 시그널 알림) FE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 10:04:55 +09:00
6bf36f34f0 fix(stock): watchlist 렌더 크래시 가드·성공 시 폼 리셋·정렬 테스트
- watchlistUtils: Object.hasOwn 가드 + Object.freeze (프로토타입 키 → 함수 반환 방지)
- useWatchlist.add: boolean 반환 + 재진입 가드; 성공 시에만 폼 리셋
- byFiredAtDesc 멀티 알림 정렬 테스트 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 02:13:59 +09:00
3656ee9a59 feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 02:03:46 +09:00
e8091a0391 feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 01:58:24 +09:00
a52fd0db8f feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 01:49:44 +09:00
ae33aa4def feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 01:44:34 +09:00
3e73077b29 docs(stock): 관심종목 탭 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-03 01:43:03 +09:00
6e415b3e45 feat(infra): NAS↔Windows 워커 파이프라인 관측 페이지 /infra (Three.js)
분산 워커 관측 Part C — useNodeStatus 3초 폴링 훅 + statusVisual 색/라벨 매핑
+ 2D 워커 카드 그리드 + raw three.js 파이프라인 시각화(정상=시안 파티클 흐름 /
busy=가속 / paused=앰버 정지 / degraded=주황 / down=빨강 끊김, Redis 끊김=버스 빨강).
GET /api/agent-office/nodes(Part B) 소비. r3f 대신 기설치 three 직접 사용.
WebGL 미지원 시 카드 폴백 + 3D/그리드 토글. vitest 10 passed, build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LV86jBozkNhSFXJA412fq
2026-06-30 10:39:08 +09:00
696c2ade15 merge: co-gahusb FE 클라이언트 배선 (.mcp.json + 역할 블록)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:51:34 +09:00
c024087c94 feat(co-gahusb): FE 클라이언트 배선 (.mcp.json + 역할 블록)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:34:31 +09:00
d0bf5fdd50 merge: 에이전트 횡단 오버사이트 타임라인 (agent-office 우측 기본 패널)
- agentActivity API 헬퍼 + useActivityFeed 훅 (필터/페이지네이션/WS refresh/stale 가드)
- ActivityItem/ActivityFilters/ActivityTimeline 컴포넌트
- AgentOffice 우측 기본 패널을 횡단 타임라인으로 교체
- 15 테스트 추가 (총 97 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:18:47 +09:00
f6b8badd12 style(agent-office): designer 마감 — 타임라인 스파인·신호등 도트·level 색·펄스 강조
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:17:32 +09:00
833b590afb fix(agent-office): useActivityFeed stale 응답 무시 (필터 변경 중 in-flight 요청)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:14:03 +09:00
ce980b6eff style(agent-office): 횡단 타임라인 baseline 스타일 2026-06-11 09:09:53 +09:00
4dc70a6fc6 feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체 2026-06-11 09:09:50 +09:00
57dfb3a3aa feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤) 2026-06-11 09:08:39 +09:00
1dc5bc3391 feat(agent-office): ActivityFilters (agent/type/status/days) 2026-06-11 09:07:17 +09:00
76e6fa5e69 feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지) 2026-06-11 09:06:44 +09:00
ae6454ed37 feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh) 2026-06-11 09:05:01 +09:00
2afcf487a1 feat(agent-office): agentActivity API 헬퍼 추가 2026-06-11 09:04:19 +09:00
0bc2ef3b98 docs: 에이전트 횡단 오버사이트 타임라인 구현 플랜
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:48:57 +09:00
726ed77b31 docs: 에이전트 횡단 오버사이트 타임라인 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:44:44 +09:00
2a89d52634 merge: 인스타 슬레이트 패키지 다운로드 버튼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:39:49 +09:00
6958714021 fix: 다운로드 버튼 hover 색상 일관성 (a.ic-btn color inherit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:06:52 +09:00
52677c606a feat: 인스타 슬레이트 패키지 다운로드 버튼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:03:43 +09:00
38 changed files with 4422 additions and 11 deletions

9
.mcp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"co-gahusb": {
"type": "http",
"url": "https://gahusb.synology.me/api/co/mcp",
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
}
}
}

142
CLAUDE.md
View File

@@ -16,7 +16,7 @@
| `/blog` | `Blog` | 마크다운 기반 블로그 |
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
@@ -27,8 +27,18 @@
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
| `/todo` | `Todo` | 태스크 보드 |
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
| `/saju/result?rid=N` | `SajuResult` | 사주 풀이 결과 (4탭: Basic/Chart/Flow/Traits) |
| `/saju/today?rid=N` | `Today` | 오늘의 운세 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings) |
| `/saju/compatibility` | `Compatibility` | 궁합 입력 (두 사람 폼) |
| `/saju/compatibility/result?cid=N` | `CompatibilityResult` | 궁합 점수 + 요약 + strengths/challenges |
| `/saju/me` | `SajuMe` | 마이페이지 placeholder ("곧 만나요" + 4 비활성 카드) |
| `/tarot` | `Tarot` | 타로 메인 (agent-office에서 분리, tarot-lab API) |
| `/tarot/today` | `TarotTodayCard` | 오늘의 카드 (one_card spread) |
| `/tarot/reading` | `TarotReading` | 멀티 카드 스프레드 리딩 (three_card 등) |
| `/tarot/history` | `TarotHistory` | 리딩 이력 조회 |
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
@@ -92,6 +102,10 @@ proxy: {
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
| 스크리너 | 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` |
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
@@ -128,6 +142,23 @@ proxy: {
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
| 사주 | POST | `/api/saju/interpret` — body: `{ year, month, day, hour, gender, calendar_type, is_leap_month? }` → reading_id + saju_data + analysis + fortune_scores + lucky + monthly_flow |
| 사주 | GET | `/api/saju/readings/:id` — 저장된 사주 조회 (`useSajuReading` hook 사용) |
| 사주 | GET | `/api/saju/current-fortune?reading_id=N` — 현재 연도 세운 |
| 사주 | PATCH/DELETE | `/api/saju/readings/:id` — 즐겨찾기·메모 / 삭제 |
| 사주 | GET | `/api/saju/readings?page=1&size=20&favorite=bool` — 목록 |
| 궁합 | POST | `/api/saju/compat/interpret` — body: `{ person_a, person_b }` → compat_id + score + interpretation |
| 궁합 | GET | `/api/saju/compat/readings/:id` — 궁합 결과 |
| 궁합 | PATCH/DELETE | `/api/saju/compat/readings/:id` |
| 타로 | POST | `/api/tarot/interpret` — body: `{ spread_type, category, question, cards }` → interpretation_json (DB 저장 X) |
| 타로 | POST | `/api/tarot/readings` — 확정 후 저장 |
| 타로 | GET | `/api/tarot/readings?page=1&spread_type=X&category=Y` — 목록 |
| 타로 | GET/PATCH/DELETE | `/api/tarot/readings/:id` |
| 영상 생성 | POST | `/api/video/generate` — body: `{ provider, prompt, params }` → task_id (sora/veo/kling/seedance) |
| 영상 생성 | GET | `/api/video/tasks/:id`, `/api/video/providers` |
| 이미지 생성 | POST | `/api/image/generate` — body: `{ provider, prompt, params }` → task_id (gpt_image/nano_banana/flux) |
| 이미지 생성 | GET | `/api/image/tasks/:id`, `/api/image/providers` |
| 에이전트 로그 | GET | `/api/agent-office/agents/:id/logs?limit=50` — DB agent_logs + 컨테이너 `/logs/recent` 병합 |
---
@@ -332,6 +363,102 @@ handleGenerate()
---
## 호령 사주 v2 — `/saju` 라우트 트리
2026-05-27 풀 리디자인 (Phase 1-6, 30 commits). v1 `components/` + `Saju.css` 일괄 삭제 후 신규 디자인 시스템 도입.
### 디자인 컨셉
한국 전통 명리학 미학 + 호령 캐릭터. Inter/Roboto 같은 generic AI sans 회피.
- **타이포**: Nanum Myeongjo (display, weight 800) + Nanum Gothic (body) + Gowun Batang (fallback serif). `index.html` head에서 preconnect + link 일괄 로드 (기존 Noto Serif KR도 v1 호환 유지)
- **컬러**: navy `#1F2A44` dominant + gold `#D4AF37` accent + ivory `#F7F2E8` paper. 화면별 단일 accent (홈=navy, 오늘=gold, 궁합=green, 사주풀이=purple, 마이=gray)
- **차별화 요소**: `OrnateFrame` (4 코너 꺽쇠 + double border), `MascotBubble` (paw-bob 2.4s 애니메이션), `OrnamentBloom` (꽃봉오리 SVG), `mt-wash` (산수화 SVG 데스크탑 배경)
### 디렉토리 구조
```
src/pages/saju/
├── _shell/ # 디자인 시스템 + 네비
│ ├── tokens.css # CSS 변수 (.saju-v2 scope)
│ ├── shell.css # paper-bg/night-bg/mt-wash/screenIn/paw-bob
│ ├── useViewportMode.js # 1024px breakpoint hook
│ ├── BottomNav.jsx # 모바일 5항목
│ ├── DesktopHeader.jsx # 데스크탑 헤더 nav
│ ├── Mascot.jsx # 7 variant 매핑 (full/head/upper/greeting/thinking/pointing/happy)
│ ├── MascotBubble.jsx # 4 tone (ivory/navy/green/purple)
│ ├── OrnateFrame.jsx
│ ├── OrnamentBloom.jsx
│ ├── TopRibbon.jsx
│ ├── TitleBlock.jsx
│ ├── PrimaryButton.jsx
│ ├── GhostButton.jsx
│ ├── InputRow.jsx
│ ├── Icons.jsx # 5 nav + IconPaw/Chevron/Sparkle
│ └── helpers/
│ ├── hexA.js # hex + alpha → rgba
│ ├── daeunLabel.js # 나이 → 8 인생 단계 label
│ ├── deriveTraits.js # element_scores → 6 성향
│ └── colorMap.js # 오행 한자 → CSS var + 한글/한자
├── views/ # mobile/desktop 컴포넌트 분리
│ ├── home.{mobile,desktop}.jsx
│ ├── saju.{mobile,desktop}.jsx # 4탭 (Basic/Chart/Flow/Traits)
│ ├── today.{mobile,desktop}.jsx
│ └── match.{mobile,desktop}.jsx
├── hooks/
│ ├── useSajuForm.js # 폼 상태 (year/month/day/hour/gender/calendar_type, handleChange(field,value) 콜백)
│ └── useSajuReading.js # rid 기반 { data, loading, error }
├── Saju.jsx # /saju 진입 router
├── SajuResult.jsx # /saju/result 진입 (Empty/Loading/Error state)
├── Today.jsx
├── Compatibility.jsx
├── CompatibilityResult.jsx
└── Me.jsx # placeholder
```
### 응답 schema 매핑 (saju-lab → view)
`useSajuReading(rid).data` 구조:
- `saju_data.{year,month,day,hour}``{stem, stem_kr, branch, branch_kr, ten_god, fortune}` (4기둥)
- `analysis_data.element_scores` (한자 키 `木/火/土/金/水`) — view에서 `wood/fire/earth/metal/water`로 매핑 (`HANJA_TO_ID`)
- `analysis_data.day_master_strength.{result, score, reasons}` (신강신약)
- `daeun_data` (8개): `{age, start_year, end_year, stem, branch, stem_kr, branch_kr}` — 현재 판정 `start_year ≤ currentYear ≤ end_year`
- `interpretation_json.{summary, items: [{key,title,content,evidence}], advice}`
- `fortune_scores.{wealth, romance, social, career, overall}` (0-100)
- `lucky.{color: string[], number, direction, good_signs: string[], warnings: string[]}`
### 반응형 전략
1024px breakpoint로 모바일/데스크탑 컴포넌트 트리 완전 분리:
- 모바일 (< 1024): `night-bg` 또는 `paper-bg`, BottomNav 하단 fixed + safe-area
- 데스크탑 (≥ 1024): `mt-wash` 산수화 배경, DesktopHeader sticky top, content max-width 1200px
### 호령 자산
`public/images/saju/horyung/` 7 PNG (horyung-main/bust/front/greeting/thinking/pointing/happy). Mascot variant API가 매핑:
- `full` → horyung-main, `head` → horyung-bust, `upper` → horyung-front, 나머지는 1:1
---
## 타로 — `/tarot` 라우트 트리
agent-office에서 독립 라우트로 분리 (백엔드는 `tarot-lab` 컨테이너).
| 경로 | 컴포넌트 | 백엔드 |
|------|----------|--------|
| `/tarot` | `Tarot` | tarot-lab `/api/tarot/interpret` |
| `/tarot/today` | `TarotTodayCard` | one_card spread |
| `/tarot/reading` | `TarotReading` | three_card spread + 멀티 |
| `/tarot/history` | `TarotHistory` | `/api/tarot/readings` 목록 |
해석 흐름 (interpret ↔ save 분리):
1. 사용자가 카드 배치 → `POST /api/tarot/interpret` → Claude 응답 (DB 저장 X)
2. 사용자 확정 또는 reroll 결정
3. 확정 후 `POST /api/tarot/readings` → DB 저장 + reading_id 반환
`useTarotReading(id)` + `useTarotShuffle()` hook (`src/pages/tarot/hooks/`).
---
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
@@ -372,3 +499,14 @@ web-ui → POST /api/music/generate (NAS music-lab)
```
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
---
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **FE**
이 세션은 프론트엔드(FE) 역할이다. co-gahusb MCP 툴로 다른 세션(BE/AI/Producer)과 협업한다.
- **소유권**: 이 세션은 `web-ui` repo만 쓴다(BE=web-backend, AI=web-ai).
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "FE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
- **모든 툴 호출에 `role="FE"`** (또는 `from_role`/`created_by`에 FE).
- **수신**: `/loop`로 주기적으로 `read_inbox("FE", after_id=<last>)` + `list_tasks(assignee_role="FE")` 확인.
-`CO_BUS_KEY`는 환경변수로 주입(커밋 금지).

View File

@@ -64,14 +64,15 @@
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
### Stock Trade (`/stock/trade`) — 7 컴포넌트
### Stock Trade (`/stock/trade`) — 8 컴포넌트
포트폴리오 관리 및 트레이딩 데스크.
포트폴리오 관리 및 트레이딩 데스크 (5탭).
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이 + AI 코치
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
- **보유종목 인텔 탭**: 스크리너 엔진 기반 기술분석·매도룰 신호 (어드바이저리)
- **관심종목 탭**: 관심종목 CRUD + 실시간 매매 시그널 알림 이력 (매수/매도 시그널, 1D/7D/30D 필터) — 실시간 매매 알림 파이프라인(BE 엔드포인트 + web-ai `trade-monitor` 워커) 연동
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
- 매도 히스토리 드로어 (실현손익 추적)

View File

@@ -0,0 +1,765 @@
# 에이전트 횡단 오버사이트 타임라인 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:** AgentOffice 우측 패널(에이전트 미선택 시)에 전 에이전트 활동을 시간순으로 보여주는 횡단 오버사이트 타임라인을 추가한다.
**Architecture:** 백엔드 `GET /api/agent-office/activity`(필터 지원)를 소비. `useActivityFeed` 훅이 페이지네이션·필터·WS refreshTrigger 재조회를 담당하고, `ActivityTimeline``ActivityFilters` + `ActivityItem` 리스트 + IntersectionObserver 무한스크롤을 조립한다. AgentOffice는 `selectedAgent===null`일 때 기존 `EmptyDetailPanel``ActivityTimeline`으로 교체한다.
**Tech Stack:** React 18, vitest + @testing-library/react(v16, `renderHook` 사용), 기존 `ao-*` CSS 컨벤션, `AGENT_META` 색상/표시명 재사용.
---
## 파일 구조
| 파일 | 책임 |
|------|------|
| `src/api.js` (수정) | `agentActivity({agent_id,type,status,days,limit,offset})` 헬퍼 추가 |
| `src/pages/agent-office/hooks/useActivityFeed.js` (생성) | items/total/loading/error/hasMore 상태, 필터·refreshTrigger 재조회, loadMore append |
| `src/pages/agent-office/components/ActivityItem.jsx` (생성) | 한 행: agent 색·표시명 + 메시지 + 상태/level 뱃지 + 시간/duration, 클릭 → onSelectAgent |
| `src/pages/agent-office/components/ActivityFilters.jsx` (생성) | agent/type/status/days select 4종, type=log 시 status 비활성 |
| `src/pages/agent-office/components/ActivityTimeline.jsx` (생성) | 컨테이너: 헤더 + 필터 + 리스트 + sentinel + 상태 |
| `src/pages/agent-office/AgentOffice.jsx` (수정) | null 분기를 ActivityTimeline으로 교체 |
| `src/pages/agent-office/AgentOffice.css` (수정) | 타임라인 baseline 스타일 (Task 7) → designer 마감 (Task 8) |
| 각 `*.test.{js,jsx}` | hook/Item/Filters 단위 테스트 |
---
## Task 1: `agentActivity` API 헬퍼
**Files:**
- Modify: `src/api.js` (기존 `getActivityFeed` 줄 근처, 596라인 부근)
- [ ] **Step 1: 헬퍼 추가**
`src/api.js`에서 기존 줄
```js
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
```
바로 아래에 추가:
```js
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
const p = new URLSearchParams();
if (agent_id) p.set('agent_id', agent_id);
if (type) p.set('type', type);
if (status) p.set('status', status);
if (days) p.set('days', String(days));
p.set('limit', String(limit));
p.set('offset', String(offset));
return apiGet(`/api/agent-office/activity?${p.toString()}`);
};
```
- [ ] **Step 2: lint 통과 확인**
Run: `npm run lint`
Expected: 에러 없음 (no-unused-vars 등)
- [ ] **Step 3: Commit**
```bash
git add src/api.js
git commit -m "feat(agent-office): agentActivity API 헬퍼 추가"
```
---
## Task 2: `useActivityFeed` 훅 (TDD)
**Files:**
- Create: `src/pages/agent-office/hooks/useActivityFeed.js`
- Test: `src/pages/agent-office/hooks/useActivityFeed.test.js`
- [ ] **Step 1: 실패하는 테스트 작성**
`src/pages/agent-office/hooks/useActivityFeed.test.js`:
```js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useActivityFeed } from './useActivityFeed.js';
const mockAgentActivity = vi.fn();
vi.mock('../../../api', () => ({
agentActivity: (...args) => mockAgentActivity(...args),
}));
beforeEach(() => mockAgentActivity.mockReset());
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
describe('useActivityFeed', () => {
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
expect(result.current.total).toBe(1);
});
it('loadMore는 다음 offset으로 append한다', async () => {
mockAgentActivity
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
await act(async () => { result.current.loadMore(); });
await waitFor(() => expect(result.current.items).toHaveLength(2));
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
});
it('필터 변경 시 offset 리셋 + items 교체', async () => {
mockAgentActivity
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
rerender({ f: { days: 7, agent_id: 'insta' } });
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
expect(result.current.items).toHaveLength(1);
});
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
rerender({ rt: 1 });
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
});
it('hasMore는 items.length < total', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
expect(result.current.hasMore).toBe(true);
});
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
Expected: FAIL — "Failed to resolve import './useActivityFeed.js'" 또는 useActivityFeed undefined
- [ ] **Step 3: 훅 구현**
`src/pages/agent-office/hooks/useActivityFeed.js`:
```js
// src/pages/agent-office/hooks/useActivityFeed.js
import { useState, useEffect, useCallback, useRef } from 'react';
import { agentActivity } from '../../../api';
const PAGE_SIZE = 30;
export function useActivityFeed(filters, refreshTrigger = 0) {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const offsetRef = useRef(0);
const loadingRef = useRef(false);
const filtersRef = useRef(filters);
filtersRef.current = filters;
const filterKey = JSON.stringify(filters);
const fetchPage = useCallback(async (offset, replace) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
const newItems = Array.isArray(data?.items) ? data.items : [];
setTotal(data?.total || 0);
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
offsetRef.current = offset + newItems.length;
} catch (e) {
setError(e.message || '불러오기 실패');
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
useEffect(() => {
offsetRef.current = 0;
fetchPage(0, true);
}, [filterKey, refreshTrigger, fetchPage]);
const loadMore = useCallback(() => {
if (loadingRef.current) return;
if (offsetRef.current >= total) return;
fetchPage(offsetRef.current, false);
}, [fetchPage, total]);
const retry = useCallback(() => {
offsetRef.current = 0;
fetchPage(0, true);
}, [fetchPage]);
const hasMore = items.length < total;
return { items, total, loading, error, hasMore, loadMore, retry };
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
Expected: PASS (5 tests)
- [ ] **Step 5: Commit**
```bash
git add src/pages/agent-office/hooks/useActivityFeed.js src/pages/agent-office/hooks/useActivityFeed.test.js
git commit -m "feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)"
```
---
## Task 3: `ActivityItem` 컴포넌트 (TDD)
**Files:**
- Create: `src/pages/agent-office/components/ActivityItem.jsx`
- Test: `src/pages/agent-office/components/ActivityItem.test.jsx`
- [ ] **Step 1: 실패하는 테스트 작성**
`src/pages/agent-office/components/ActivityItem.test.jsx`:
```jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ActivityItem from './ActivityItem.jsx';
describe('ActivityItem', () => {
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
expect(screen.getByText(/완료/)).toBeInTheDocument();
expect(screen.getByText('2s')).toBeInTheDocument();
});
it('log 항목은 level 아이콘을 렌더한다', () => {
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
expect(screen.getByText('signal_check')).toBeInTheDocument();
expect(screen.getByText('❌')).toBeInTheDocument();
});
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
const onSelect = vi.fn();
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
expect(onSelect).toHaveBeenCalledWith('insta');
});
it('미지정 agent_id는 id를 그대로 표시한다', () => {
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
expect(screen.getByText('unknown')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
Expected: FAIL — import 해결 실패
- [ ] **Step 3: 컴포넌트 구현**
`src/pages/agent-office/components/ActivityItem.jsx`:
```jsx
// src/pages/agent-office/components/ActivityItem.jsx
import { AGENT_META } from '../constants.js';
const STATUS_STYLE = {
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
};
const LEVEL_STYLE = {
error: { icon: '❌', cls: 'level-error' },
warning: { icon: '⚠️', cls: 'level-warning' },
info: { icon: '·', cls: 'level-info' },
};
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
}
export default function ActivityItem({ item, onSelectAgent }) {
const meta = AGENT_META[item.agent_id];
const color = meta?.color || '#6b7280';
const name = meta?.displayName || item.agent_id;
const isTask = item.type === 'task';
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
return (
<div
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
onClick={() => onSelectAgent(item.agent_id)}
role="button"
tabIndex={0}
>
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
<div className="ao-activity-body">
<div className="ao-activity-line">
<span className="ao-activity-agent" style={{ color }}>{name}</span>
{isTask
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
</div>
<div className="ao-activity-msg">{item.message}</div>
</div>
<div className="ao-activity-meta">
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
{isTask && item.duration_seconds != null && (
<span className="ao-activity-dur">{item.duration_seconds}s</span>
)}
</div>
</div>
);
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
Expected: PASS (4 tests)
- [ ] **Step 5: Commit**
```bash
git add src/pages/agent-office/components/ActivityItem.jsx src/pages/agent-office/components/ActivityItem.test.jsx
git commit -m "feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지)"
```
---
## Task 4: `ActivityFilters` 컴포넌트 (TDD)
**Files:**
- Create: `src/pages/agent-office/components/ActivityFilters.jsx`
- Test: `src/pages/agent-office/components/ActivityFilters.test.jsx`
- [ ] **Step 1: 실패하는 테스트 작성**
`src/pages/agent-office/components/ActivityFilters.test.jsx`:
```jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ActivityFilters from './ActivityFilters.jsx';
const base = { agent_id: '', type: '', status: '', days: 7 };
describe('ActivityFilters', () => {
it('type=log이면 상태 필터가 비활성화된다', () => {
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
});
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
const onChange = vi.fn();
render(<ActivityFilters filters={base} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
});
it('type을 log로 바꾸면 status를 비운다', () => {
const onChange = vi.fn();
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
});
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
Expected: FAIL — import 해결 실패
- [ ] **Step 3: 컴포넌트 구현**
`src/pages/agent-office/components/ActivityFilters.jsx`:
```jsx
// src/pages/agent-office/components/ActivityFilters.jsx
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
const TYPE_OPTIONS = [
{ value: '', label: '전체' },
{ value: 'task', label: 'Task' },
{ value: 'log', label: 'Log' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체' },
{ value: 'succeeded', label: '완료' },
{ value: 'failed', label: '실패' },
{ value: 'pending', label: '대기' },
];
const DAYS_OPTIONS = [
{ value: 1, label: '1일' },
{ value: 7, label: '7일' },
{ value: 30, label: '30일' },
];
export default function ActivityFilters({ filters, onChange }) {
const set = (patch) => onChange({ ...filters, ...patch });
const statusDisabled = filters.type === 'log';
return (
<div className="ao-activity-filters">
<select
className="ao-activity-select"
aria-label="에이전트 필터"
value={filters.agent_id || ''}
onChange={e => set({ agent_id: e.target.value })}
>
<option value="">모든 에이전트</option>
{ACTIVE_AGENT_IDS.map(id => (
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
))}
</select>
<select
className="ao-activity-select"
aria-label="타입 필터"
value={filters.type || ''}
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select
className="ao-activity-select"
aria-label="상태 필터"
value={filters.status || ''}
disabled={statusDisabled}
onChange={e => set({ status: e.target.value })}
>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select
className="ao-activity-select"
aria-label="기간 필터"
value={filters.days}
onChange={e => set({ days: Number(e.target.value) })}
>
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
);
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add src/pages/agent-office/components/ActivityFilters.jsx src/pages/agent-office/components/ActivityFilters.test.jsx
git commit -m "feat(agent-office): ActivityFilters (agent/type/status/days)"
```
---
## Task 5: `ActivityTimeline` 컨테이너 (TDD)
**Files:**
- Create: `src/pages/agent-office/components/ActivityTimeline.jsx`
- Test: `src/pages/agent-office/components/ActivityTimeline.test.jsx`
> 참고: jsdom에는 IntersectionObserver가 없으므로 테스트 setup에서 stub이 필요하다. Step 1에서 테스트 파일 상단에 직접 stub을 둔다(전역 test-setup 수정 없이 국소 처리).
- [ ] **Step 1: 실패하는 테스트 작성**
`src/pages/agent-office/components/ActivityTimeline.test.jsx`:
```jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import ActivityTimeline from './ActivityTimeline.jsx';
// jsdom IntersectionObserver stub
beforeEach(() => {
global.IntersectionObserver = class {
observe() {} unobserve() {} disconnect() {}
};
});
const mockAgentActivity = vi.fn();
vi.mock('../../../api', () => ({
agentActivity: (...args) => mockAgentActivity(...args),
}));
describe('ActivityTimeline', () => {
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
});
it('빈 결과면 안내 문구를 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
});
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
const onSelect = vi.fn();
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
const row = await screen.findByText('signal_check');
fireEvent.click(row.closest('.ao-activity-item'));
expect(onSelect).toHaveBeenCalledWith('lotto');
});
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
Expected: FAIL — import 해결 실패
- [ ] **Step 3: 컴포넌트 구현**
`src/pages/agent-office/components/ActivityTimeline.jsx`:
```jsx
// src/pages/agent-office/components/ActivityTimeline.jsx
import { useState, useRef, useEffect } from 'react';
import { useActivityFeed } from '../hooks/useActivityFeed.js';
import ActivityFilters from './ActivityFilters.jsx';
import ActivityItem from './ActivityItem.jsx';
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
const sentinelRef = useRef(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '120px' });
io.observe(el);
return () => io.disconnect();
}, [loadMore, items.length]);
return (
<div className="ao-sidepanel ao-activity">
<div className="ao-sidepanel-header ao-activity-header">
<div className="ao-sidepanel-name"> 활동 ({total})</div>
</div>
<ActivityFilters filters={filters} onChange={setFilters} />
<div className="ao-sidepanel-content ao-activity-content">
{error && (
<div className="ao-activity-error">
불러오기 실패: {error}
<button type="button" onClick={retry}>재시도</button>
</div>
)}
{!error && items.length === 0 && !loading && (
<div className="ao-empty">최근 {filters.days} 활동 없음</div>
)}
{items.map((item, i) => (
<ActivityItem
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
item={item}
onSelectAgent={onSelectAgent}
/>
))}
{loading && <div className="ao-activity-loading">불러오는 </div>}
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
{!hasMore && items.length > 0 && <div className="ao-activity-end"> 이상 활동 없음</div>}
</div>
</div>
);
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add src/pages/agent-office/components/ActivityTimeline.jsx src/pages/agent-office/components/ActivityTimeline.test.jsx
git commit -m "feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)"
```
---
## Task 6: AgentOffice 우측 패널 배선
**Files:**
- Modify: `src/pages/agent-office/AgentOffice.jsx`
- [ ] **Step 1: import 추가**
`src/pages/agent-office/AgentOffice.jsx`에서
```js
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
```
바로 아래에 추가:
```js
import ActivityTimeline from './components/ActivityTimeline.jsx';
```
- [ ] **Step 2: null 분기 교체**
같은 파일에서
```js
if (selectedAgent === null) {
rightPanel = <EmptyDetailPanel variant="initial" />;
} else if (selectedAgent.startsWith('placeholder-')) {
```
를 아래로 변경:
```js
if (selectedAgent === null) {
rightPanel = (
<ActivityTimeline
refreshTrigger={refreshTrigger}
onSelectAgent={handleSelectAgent}
/>
);
} else if (selectedAgent.startsWith('placeholder-')) {
```
- [ ] **Step 3: 전체 테스트 통과 확인 (회귀 없음)**
Run: `npm run test:run`
Expected: PASS — 신규 테스트 포함 전부 통과, 기존 테스트 회귀 없음
- [ ] **Step 4: Commit**
```bash
git add src/pages/agent-office/AgentOffice.jsx
git commit -m "feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체"
```
---
## Task 7: baseline CSS
**Files:**
- Modify: `src/pages/agent-office/AgentOffice.css` (파일 끝에 append)
- [ ] **Step 1: 스타일 추가**
`src/pages/agent-office/AgentOffice.css` 맨 끝에 추가:
```css
/* ── 횡단 오버사이트 타임라인 ── */
.ao-activity { display: flex; flex-direction: column; min-height: 0; }
.ao-activity-header { display: flex; align-items: center; }
.ao-activity-filters {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 8px 12px; border-bottom: 1px solid #1f2937;
}
.ao-activity-select {
background: #111827; color: #e5e7eb;
border: 1px solid #374151; border-radius: 6px;
padding: 4px 8px; font-size: 12px;
}
.ao-activity-select:disabled { opacity: .4; cursor: not-allowed; }
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; }
.ao-activity-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 12px; border-bottom: 1px solid #161b25;
cursor: pointer; transition: background .12s;
}
.ao-activity-item:hover { background: #161b25; }
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, .08); }
.ao-activity-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
.ao-activity-body { flex: 1; min-width: 0; }
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
.ao-activity-agent { font-size: 12px; font-weight: 600; }
.ao-activity-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
.ao-activity-level { font-size: 12px; }
.ao-activity-msg {
font-size: 13px; color: #cbd5e1; margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.ao-activity-time { font-size: 11px; color: #6b7280; }
.ao-activity-dur { font-size: 10px; color: #475569; }
.ao-activity-loading,
.ao-activity-end { text-align: center; padding: 12px; font-size: 12px; color: #6b7280; }
.ao-activity-sentinel { height: 1px; }
.ao-activity-error { padding: 12px; font-size: 13px; color: #fca5a5; }
.ao-activity-error button {
margin-left: 8px; background: #374151; color: #e5e7eb;
border: none; border-radius: 6px; padding: 2px 10px; cursor: pointer;
}
```
- [ ] **Step 2: 개발 서버에서 시각 확인**
Run: `npm run dev` 후 브라우저에서 `http://localhost:3007/agent-office` 접속 → 우측 패널에 타임라인/필터/항목이 보이는지 확인 (에이전트 미선택 상태).
Expected: 필터 4종 + 활동 항목 리스트 표시, 항목 클릭 시 SidePanel 전환
- [ ] **Step 3: Commit**
```bash
git add src/pages/agent-office/AgentOffice.css
git commit -m "style(agent-office): 횡단 타임라인 baseline 스타일"
```
---
## Task 8: designer 스킬 비주얼 마감 + 최종 검증
**Files:**
- Modify: `src/pages/agent-office/AgentOffice.css` (+ 필요 시 컴포넌트 className 미세 조정)
- [ ] **Step 1: designer 스킬 적용**
`designer` 스킬을 invoke하여 AgentOffice 다크 미감과 일관된 타임라인 비주얼로 마감 (에이전트 색 강조, 상태 뱃지 가독성, 펄스 애니메이션, 밀도/여백). 기능/마크업 구조는 유지하고 스타일만 개선.
- [ ] **Step 2: lint + 전체 테스트 + 빌드 검증**
```bash
npm run lint
npm run test:run
npm run build
```
Expected: lint 0 error, 전체 테스트 PASS, build 성공
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "style(agent-office): designer 마감 — 횡단 오버사이트 타임라인"
```
---
## Self-Review 체크리스트 (작성자 검증 완료)
- **Spec coverage:** agentActivity 헬퍼(T1) ✓ / useActivityFeed 필터·페이지네이션·refreshTrigger(T2) ✓ / 상태·level 뱃지 + agent 색 + 클릭(T3) ✓ / 필터 4종 + log시 status 비활성(T4) ✓ / 무한스크롤·empty·error·end(T5) ✓ / AgentOffice 배선(T6) ✓ / 비주얼(T7·T8) ✓ — spec 전 항목 커버.
- **Placeholder scan:** 모든 step에 실제 코드/명령/기대출력 포함, TBD 없음.
- **Type consistency:** `useActivityFeed(filters, refreshTrigger)` 반환 `{items,total,loading,error,hasMore,loadMore,retry}` — T5에서 동일 사용. `onSelectAgent(agent_id)` 시그니처 T3/T5/T6 일치. `AGENT_META`/`ACTIVE_AGENT_IDS` import 경로 `../constants.js` 일치. `agentActivity({...})` 객체 인자 T1 정의 ↔ T2 호출 일치.
- **Known caveat:** jsdom IntersectionObserver 없음 → T5 테스트 상단 stub으로 처리(전역 setup 미수정).

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,107 @@
# 에이전트 횡단 오버사이트 타임라인 — 설계
작성일: 2026-06-11
대상 repo: `web-ui` (프론트엔드)
연관 백엔드: ✅ 완료 (`GET /api/agent-office/activity` 필터 지원, main `2c2828c`)
## 배경 / 목적
3개 자율 에이전트(stock 보유종목·insta 발급·lotto 진화)가 모두 도는 상태에서
"팀이 무엇을·언제·왜 했나"를 **한 화면에서** 보는 에이전트 횡단 오버사이트(CEO 가시화) 기능.
현재 web-ui에는 `/lotto/evolver` 탭의 lotto 전용 `LottoActivityTimeline`만 존재.
통합 `/activity`(전 에이전트 대상)를 소비하는 횡단 뷰가 없다.
## 백엔드 응답 shape (라이브 검증 완료)
```
GET /api/agent-office/activity?agent_id=&type=task|log&status=&days=&limit=&offset=
→ { items: [...], total: N }
```
- **task item**: `{ type:'task', agent_id, task_id, message, created_at, task_type, status, completed_at, duration_seconds }`
- **log item**: `{ type:'log', agent_id, task_id, message, created_at, level }`
- `status`는 task 전용(`type=log`에 주면 무시). injection 안전(? 바인딩 + 브랜치 선택).
검증 메모:
- 무필터 `total`이 65,599건 → **기본 `days=7` 필터 필수**(task 기준 110건으로 감소).
- `requires_approval` 필드는 **존재하지 않음**`status:'pending'`을 진행/대기 강조로 처리.
- `agent_id` 값이 `AGENT_META` 키(stock/music/insta/realestate/lotto)와 일치 → 색상/이미지 재사용.
## 아키텍처
AgentOffice는 단일 화면(TopBar + 3×3 AgentGrid + 우측 패널) 구조.
우측 패널은 `selectedAgent` 상태로 분기:
- `null` → (기존) `EmptyDetailPanel variant="initial"`**`ActivityTimeline`으로 교체**
- `placeholder-N``EmptyDetailPanel variant="placeholder"` (유지)
- active agent id → `SidePanel` (유지)
즉 **에이전트 미선택 시 기본 우측 패널이 횡단 타임라인**이 되고, 그리드와 항상 동시 노출.
항목/그리드 클릭으로 해당 에이전트 SidePanel로 전환.
## 신규/변경 파일
| 파일 | 역할 |
|------|------|
| `src/api.js` | `agentActivity({agent_id,type,status,days,limit,offset})` 추가 — 빈 값 제외 쿼리스트링 빌드 + GET `/api/agent-office/activity` |
| `src/pages/agent-office/AgentOffice.jsx` | `selectedAgent===null` 분기를 `EmptyDetailPanel``ActivityTimeline`(props: `refreshTrigger`, `onSelectAgent`)로 교체 |
| `src/pages/agent-office/hooks/useActivityFeed.js` | items/offset/total/hasMore/loading/error/filters 상태 관리 |
| `src/pages/agent-office/components/ActivityTimeline.jsx` | 컨테이너: 헤더 + `ActivityFilters` + 리스트 + 무한스크롤 sentinel + 상태(loading/empty/error/end) |
| `src/pages/agent-office/components/ActivityFilters.jsx` | 필터 4종(agent 색칩 / type / status / days). `type==='log'`일 때 status 비활성 |
| `src/pages/agent-office/components/ActivityItem.jsx` | 한 행: agent 색·이미지 + message + 상태/level 뱃지 + 상대시간 + duration. 클릭 → `onSelectAgent(agent_id)` |
| `src/pages/agent-office/AgentOffice.css` | 타임라인/필터/항목 스타일 (designer 스킬로 마감) |
## 데이터 흐름
```
AgentOffice (selectedAgent===null)
└─ <ActivityTimeline refreshTrigger={refreshTrigger} onSelectAgent={handleSelectAgent} />
└─ useActivityFeed(filters)
• mount / 필터 변경 → offset=0 fetch → items 교체
• loadMore (sentinel 교차) → offset += limit → items append
• refreshTrigger 변경 → offset=0 재조회 → items 교체 (WS 실시간 연동)
└─ ActivityItem onClick → onSelectAgent(agent_id) → SidePanel로 전환
```
`handleSelectAgent`는 기존 콜백 재사용(선택 + `clearNotifications`).
## 필터 기본값
`days=7`, `type=all`, `status=all`, `agent=all`, `limit=30`(페이지당).
## 상태 / 비주얼 매핑
- task `status`: `succeeded` → 초록 ✓ / `failed` → 빨강 ✗ / `pending`·`working` → 앰버 펄스 ⏳(강조)
- log `level`: `error` → ❌ / `warning` → ⚠️ / `info` → ·
- agent 색상: `AGENT_META[agent_id].color`, 미지정 agent → 회색 `#6b7280`
- `offset >= total` → "더 이상 활동 없음" / 무한스크롤은 IntersectionObserver
## 상태 처리(엣지)
- 첫 페이지 로딩 → 스피너/스켈레톤
- 빈 결과 → "최근 N일 활동 없음"
- fetch 실패 → 인라인 에러 + 재시도 버튼
- 리스트 끝 → end-of-list 표시, sentinel 관찰 중단
## 테스트 (TDD, vitest + RTL — 기존 패턴 따름)
- `useActivityFeed`: 필터 변경 시 offset 리셋 + items 교체 / loadMore append / refreshTrigger 재조회 / `hasMore = items.length < total` 계산 (api mock)
- `ActivityItem`: task vs log 렌더 분기, status/level 뱃지 클래스, 클릭 시 `onSelectAgent(agent_id)` 호출
- `ActivityFilters`: `type==='log'`일 때 status select 비활성, 필터 변경 시 onChange 호출
## 비범위 (YAGNI)
- 별도 라우트(`/agent-office/activity`) 미생성 — 기본 우측 패널 통합으로 충분
- 기존 `getActivityFeed(limit, offset)` 헬퍼는 lotto evolver 등에서 사용 여부 확인 후 유지(신규 `agentActivity`와 공존, 무리한 통합 안 함)
- `LottoActivityTimeline`(`kind/ts/payload` shape)은 다른 엔드포인트 소비 → 건드리지 않음
- CSV/export, 검색어 필터 등 부가기능 제외
## 구현 순서
1. `agentActivity` api 헬퍼 추가
2. `useActivityFeed` 훅 (TDD)
3. `ActivityItem` / `ActivityFilters` (TDD)
4. `ActivityTimeline` 컨테이너 조립
5. `AgentOffice.jsx` 분기 교체
6. designer 스킬로 CSS 마감
7. lint + 테스트 + 빌드 검증

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

@@ -14,6 +14,11 @@ export async function apiGet(path) {
return res.json();
}
// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드)
export async function getNodeStatus() {
return apiGet("/api/agent-office/nodes");
}
export async function apiDelete(path) {
const res = await fetch(toApiUrl(path), { method: "DELETE" });
if (!res.ok) {
@@ -548,6 +553,8 @@ export function getInstaAssetUrl(slateId, page) {
return `/api/insta/slates/${slateId}/assets/${page}`;
}
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
export function getInstaTask(taskId) {
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
}
@@ -592,6 +599,17 @@ export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/age
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
export const getAgentStates = () => apiGet('/api/agent-office/states');
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
const p = new URLSearchParams();
if (agent_id) p.set('agent_id', agent_id);
if (type) p.set('type', type);
if (status) p.set('status', status);
if (days) p.set('days', String(days));
p.set('limit', String(limit));
p.set('offset', String(offset));
return apiGet(`/api/agent-office/activity?${p.toString()}`);
};
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
// --- Lotto Briefing ---
@@ -834,3 +852,13 @@ export function compatPatchReading(id, body) {
export function compatDeleteReading(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

@@ -447,3 +447,102 @@
padding-bottom: env(safe-area-inset-bottom, 16px);
}
}
/* ── 횡단 오버사이트 타임라인 (mission-control activity log) ── */
.ao-activity { display: flex; flex-direction: column; min-height: 0; height: 100%; }
/* 헤더 — 섹션 타이틀 톤 (퍼플 액센트 + 트래킹) */
.ao-activity-header { align-items: center; }
.ao-activity-header .ao-sidepanel-name {
color: #8b5cf6; letter-spacing: 0.6px; text-transform: uppercase; font-size: 13px;
}
/* 필터 바 — 다크 슬레이트 셀렉트 */
.ao-activity-filters {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 8px 12px; border-bottom: 1px solid #333;
background: rgba(15, 23, 42, 0.6);
}
.ao-activity-select {
background: #1e293b; color: #e2e8f0;
border: 1px solid #334155; border-radius: 4px;
padding: 4px 8px; font-family: inherit; font-size: 11px; cursor: pointer;
transition: border-color .12s, box-shadow .12s;
}
.ao-activity-select:hover { border-color: #475569; }
.ao-activity-select:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); }
.ao-activity-select:disabled { opacity: .35; cursor: not-allowed; }
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; padding: 0; }
/* 활동 행 — 타임라인 스파인(수직 레일) + 신호등 도트 */
.ao-activity-item {
position: relative;
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 12px; border-bottom: 1px solid #1a2233;
cursor: pointer; transition: background .12s;
animation: ao-activity-in .18s ease-out both;
}
.ao-activity-item::before {
content: ''; position: absolute; left: 16px; top: 0; bottom: 0;
width: 1px; background: #1e293b; z-index: 0;
}
.ao-activity-item:hover { background: #161b2e; }
.ao-activity-item:focus-visible { outline: none; background: #161b2e; box-shadow: inset 2px 0 0 #8b5cf6; }
/* 진행/대기 강조 — 앰버 인셋 + 도트 펄스 */
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, 0.06); box-shadow: inset 2px 0 0 #f59e0b; }
.ao-activity-item.is-highlight .ao-activity-dot { animation: ao-pulse 1.6s ease-in-out infinite; }
/* 에이전트 색 = 신호등. 링(#111)으로 뒤 레일을 끊어 점처럼 떠 보이게 */
.ao-activity-dot {
position: relative; z-index: 1; flex: 0 0 auto;
width: 9px; height: 9px; border-radius: 50%; margin-top: 4px;
box-shadow: 0 0 0 3px #111;
}
.ao-activity-body { flex: 1; min-width: 0; }
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
.ao-activity-agent { font-size: 11px; font-weight: bold; letter-spacing: 0.3px; }
/* 상태 뱃지 — 터미널 톤(각진 모서리, 모노) */
.ao-activity-badge {
font-size: 10px; font-weight: bold; letter-spacing: 0.3px;
padding: 1px 7px; border-radius: 4px; white-space: nowrap;
}
/* 로그 레벨 표식 */
.ao-activity-level { font-size: 12px; line-height: 1; }
.ao-activity-level.level-info { color: #475569; font-size: 15px; font-weight: bold; }
.ao-activity-level.level-warning,
.ao-activity-level.level-error { font-size: 12px; }
.ao-activity-msg {
font-size: 12.5px; color: #cbd5e1; margin-top: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ao-activity-item.is-log .ao-activity-msg { color: #94a3b8; }
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.ao-activity-time { font-size: 10px; color: #64748b; }
.ao-activity-dur { font-size: 10px; color: #475569; }
.ao-activity-loading,
.ao-activity-end {
text-align: center; padding: 12px; font-size: 10px;
color: #475569; letter-spacing: 0.6px; text-transform: uppercase;
}
.ao-activity-sentinel { height: 1px; }
.ao-activity-error { padding: 12px; font-size: 12px; color: #fca5a5; }
.ao-activity-error button {
margin-left: 8px; background: #2a2a4e; color: #8b5cf6;
border: 1px solid #4c1d95; border-radius: 4px;
padding: 3px 10px; font-family: inherit; font-size: 11px; cursor: pointer;
}
.ao-activity-error button:hover { background: #3a3a5e; }
@keyframes ao-activity-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: none; }
}

View File

@@ -6,6 +6,7 @@ import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import ActivityTimeline from './components/ActivityTimeline.jsx';
import './AgentOffice.css';
export default function AgentOffice() {
@@ -36,7 +37,12 @@ export default function AgentOffice() {
let rightPanel;
if (selectedAgent === null) {
rightPanel = <EmptyDetailPanel variant="initial" />;
rightPanel = (
<ActivityTimeline
refreshTrigger={refreshTrigger}
onSelectAgent={handleSelectAgent}
/>
);
} else if (selectedAgent.startsWith('placeholder-')) {
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
} else {

View File

@@ -0,0 +1,64 @@
// src/pages/agent-office/components/ActivityFilters.jsx
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
const TYPE_OPTIONS = [
{ value: '', label: '전체' },
{ value: 'task', label: 'Task' },
{ value: 'log', label: 'Log' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체' },
{ value: 'succeeded', label: '완료' },
{ value: 'failed', label: '실패' },
{ value: 'pending', label: '대기' },
];
const DAYS_OPTIONS = [
{ value: 1, label: '1일' },
{ value: 7, label: '7일' },
{ value: 30, label: '30일' },
];
export default function ActivityFilters({ filters, onChange }) {
const set = (patch) => onChange({ ...filters, ...patch });
const statusDisabled = filters.type === 'log';
return (
<div className="ao-activity-filters">
<select
className="ao-activity-select"
aria-label="에이전트 필터"
value={filters.agent_id || ''}
onChange={e => set({ agent_id: e.target.value })}
>
<option value="">모든 에이전트</option>
{ACTIVE_AGENT_IDS.map(id => (
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
))}
</select>
<select
className="ao-activity-select"
aria-label="타입 필터"
value={filters.type || ''}
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select
className="ao-activity-select"
aria-label="상태 필터"
value={filters.status || ''}
disabled={statusDisabled}
onChange={e => set({ status: e.target.value })}
>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select
className="ao-activity-select"
aria-label="기간 필터"
value={filters.days}
onChange={e => set({ days: Number(e.target.value) })}
>
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ActivityFilters from './ActivityFilters.jsx';
const base = { agent_id: '', type: '', status: '', days: 7 };
describe('ActivityFilters', () => {
it('type=log이면 상태 필터가 비활성화된다', () => {
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
});
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
const onChange = vi.fn();
render(<ActivityFilters filters={base} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
});
it('type을 log로 바꾸면 status를 비운다', () => {
const onChange = vi.fn();
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
});
});

View File

@@ -0,0 +1,60 @@
// src/pages/agent-office/components/ActivityItem.jsx
import { AGENT_META } from '../constants.js';
const STATUS_STYLE = {
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
};
const LEVEL_STYLE = {
error: { icon: '❌', cls: 'level-error' },
warning: { icon: '⚠️', cls: 'level-warning' },
info: { icon: '·', cls: 'level-info' },
};
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
}
export default function ActivityItem({ item, onSelectAgent }) {
const meta = AGENT_META[item.agent_id];
const color = meta?.color || '#6b7280';
const name = meta?.displayName || item.agent_id;
const isTask = item.type === 'task';
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
return (
<div
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
onClick={() => onSelectAgent(item.agent_id)}
role="button"
tabIndex={0}
>
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
<div className="ao-activity-body">
<div className="ao-activity-line">
<span className="ao-activity-agent" style={{ color }}>{name}</span>
{isTask
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
</div>
<div className="ao-activity-msg">{item.message}</div>
</div>
<div className="ao-activity-meta">
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
{isTask && item.duration_seconds != null && (
<span className="ao-activity-dur">{item.duration_seconds}s</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ActivityItem from './ActivityItem.jsx';
describe('ActivityItem', () => {
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
expect(screen.getByText(/완료/)).toBeInTheDocument();
expect(screen.getByText('2s')).toBeInTheDocument();
});
it('log 항목은 level 아이콘을 렌더한다', () => {
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
expect(screen.getByText('signal_check')).toBeInTheDocument();
expect(screen.getByText('❌')).toBeInTheDocument();
});
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
const onSelect = vi.fn();
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
expect(onSelect).toHaveBeenCalledWith('insta');
});
it('미지정 agent_id는 id를 그대로 표시한다', () => {
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
expect(screen.getByText('unknown')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
// src/pages/agent-office/components/ActivityTimeline.jsx
import { useState, useRef, useEffect } from 'react';
import { useActivityFeed } from '../hooks/useActivityFeed.js';
import ActivityFilters from './ActivityFilters.jsx';
import ActivityItem from './ActivityItem.jsx';
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
const sentinelRef = useRef(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '120px' });
io.observe(el);
return () => io.disconnect();
}, [loadMore, items.length]);
return (
<div className="ao-sidepanel ao-activity">
<div className="ao-sidepanel-header ao-activity-header">
<div className="ao-sidepanel-name"> 활동 ({total})</div>
</div>
<ActivityFilters filters={filters} onChange={setFilters} />
<div className="ao-sidepanel-content ao-activity-content">
{error && (
<div className="ao-activity-error">
불러오기 실패: {error}
<button type="button" onClick={retry}>재시도</button>
</div>
)}
{!error && items.length === 0 && !loading && (
<div className="ao-empty">최근 {filters.days} 활동 없음</div>
)}
{items.map((item, i) => (
<ActivityItem
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
item={item}
onSelectAgent={onSelectAgent}
/>
))}
{loading && <div className="ao-activity-loading">불러오는 </div>}
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
{!hasMore && items.length > 0 && <div className="ao-activity-end"> 이상 활동 없음</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import ActivityTimeline from './ActivityTimeline.jsx';
// jsdom IntersectionObserver stub
beforeEach(() => {
global.IntersectionObserver = class {
observe() {} unobserve() {} disconnect() {}
};
});
const mockAgentActivity = vi.fn();
vi.mock('../../../api', () => ({
agentActivity: (...args) => mockAgentActivity(...args),
}));
describe('ActivityTimeline', () => {
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
});
it('빈 결과면 안내 문구를 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
});
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
const onSelect = vi.fn();
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
const row = await screen.findByText('signal_check');
fireEvent.click(row.closest('.ao-activity-item'));
expect(onSelect).toHaveBeenCalledWith('lotto');
});
});

View File

@@ -0,0 +1,64 @@
// src/pages/agent-office/hooks/useActivityFeed.js
import { useState, useEffect, useCallback, useRef } from 'react';
import { agentActivity } from '../../../api';
const PAGE_SIZE = 30;
export function useActivityFeed(filters, refreshTrigger = 0) {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const offsetRef = useRef(0);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const filtersRef = useRef(filters);
filtersRef.current = filters;
const filterKey = JSON.stringify(filters);
const fetchPage = useCallback(async (offset, replace) => {
// append(loadMore)만 중복 방지. replace(필터/refresh 재조회)는 항상 우선 진행.
if (!replace && loadingRef.current) return;
const reqId = ++requestIdRef.current;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
if (reqId !== requestIdRef.current) return; // 더 새로운 요청이 시작됨 → stale 응답 무시
const newItems = Array.isArray(data?.items) ? data.items : [];
setTotal(data?.total || 0);
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
offsetRef.current = offset + newItems.length;
} catch (e) {
if (reqId !== requestIdRef.current) return;
setError(e.message || '불러오기 실패');
} finally {
if (reqId === requestIdRef.current) {
loadingRef.current = false;
setLoading(false);
}
}
}, []);
useEffect(() => {
offsetRef.current = 0;
fetchPage(0, true);
}, [filterKey, refreshTrigger, fetchPage]);
const loadMore = useCallback(() => {
if (loadingRef.current) return;
if (offsetRef.current >= total) return;
fetchPage(offsetRef.current, false);
}, [fetchPage, total]);
const retry = useCallback(() => {
offsetRef.current = 0;
fetchPage(0, true);
}, [fetchPage]);
const hasMore = items.length < total;
return { items, total, loading, error, hasMore, loadMore, retry };
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useActivityFeed } from './useActivityFeed.js';
const mockAgentActivity = vi.fn();
vi.mock('../../../api', () => ({
agentActivity: (...args) => mockAgentActivity(...args),
}));
beforeEach(() => mockAgentActivity.mockReset());
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
describe('useActivityFeed', () => {
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
expect(result.current.total).toBe(1);
});
it('loadMore는 다음 offset으로 append한다', async () => {
mockAgentActivity
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
await act(async () => { result.current.loadMore(); });
await waitFor(() => expect(result.current.items).toHaveLength(2));
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
});
it('필터 변경 시 offset 리셋 + items 교체', async () => {
mockAgentActivity
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
rerender({ f: { days: 7, agent_id: 'insta' } });
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
expect(result.current.items).toHaveLength(1);
});
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
rerender({ rt: 1 });
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
});
it('hasMore는 items.length < total', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
await waitFor(() => expect(result.current.items).toHaveLength(1));
expect(result.current.hasMore).toBe(true);
});
it('필터 변경 중이던 이전(stale) 요청 응답은 무시된다', async () => {
let resolveFirst;
const firstPromise = new Promise(r => { resolveFirst = r; });
mockAgentActivity
.mockReturnValueOnce(firstPromise) // 초기 요청 — 느리게 resolve
.mockResolvedValueOnce({ items: [item({ task_id: 'fresh', agent_id: 'insta' })], total: 1 });
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
rerender({ f: { days: 7, agent_id: 'insta' } }); // 첫 요청 resolve 전에 필터 변경
await waitFor(() => expect(result.current.items[0]?.task_id).toBe('fresh'));
await act(async () => { resolveFirst({ items: [item({ task_id: 'stale' })], total: 99 }); });
expect(result.current.items[0].task_id).toBe('fresh'); // stale이 덮어쓰지 않음
expect(result.current.total).toBe(1);
});
});

View File

@@ -0,0 +1,359 @@
/* ═══════════════════════════════════════════════════════════════════
InfraMonitor — NAS↔Windows 워커 파이프라인 관측 콘솔
다크 미션컨트롤 / 텔레메트리 미학 (index.css 토큰 재사용)
═══════════════════════════════════════════════════════════════════ */
.infra {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── 상태 바 ───────────────────────────────────────────────────────── */
.infra-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.infra-bar__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.infra-chip {
font-family: var(--font-body);
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--text-dim);
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
white-space: nowrap;
}
.infra-chip b {
color: var(--text-bright);
font-weight: 700;
}
.infra-chip.is-ok {
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.35);
box-shadow: 0 0 16px rgba(0, 212, 255, 0.12) inset;
}
.infra-chip.is-warn {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.35);
}
.infra-chip.is-danger {
color: #fb923c;
border-color: rgba(251, 146, 60, 0.4);
}
.infra-chip.is-down {
color: #f43f5e;
border-color: rgba(244, 63, 94, 0.4);
box-shadow: 0 0 16px rgba(244, 63, 94, 0.1) inset;
}
.infra-bar__actions {
display: flex;
align-items: center;
gap: 10px;
}
.infra-updated {
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.infra-toggle {
display: inline-flex;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
}
.infra-toggle button {
background: transparent;
border: 0;
color: var(--text-dim);
font-size: 12px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.infra-toggle button.is-active {
background: var(--neon-cyan-muted);
color: var(--neon-cyan);
}
.infra-refresh {
background: var(--surface-card);
border: 1px solid var(--line);
color: var(--text-dim);
border-radius: var(--radius-sm);
width: 34px;
height: 32px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.infra-refresh:hover {
color: var(--neon-cyan);
border-color: var(--line-bright);
transform: rotate(90deg);
}
/* ── 에러 / 경고 / 로딩 ────────────────────────────────────────────── */
.infra-error {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: rgba(244, 63, 94, 0.08);
border: 1px solid rgba(244, 63, 94, 0.3);
border-radius: var(--radius-md);
color: var(--text);
}
.infra-error b {
color: #f43f5e;
}
.infra-error span {
color: var(--text-dim);
font-size: 13px;
flex: 1;
}
.infra-error button {
background: rgba(244, 63, 94, 0.18);
border: 1px solid rgba(244, 63, 94, 0.4);
color: #ffd2da;
border-radius: var(--radius-sm);
padding: 6px 14px;
cursor: pointer;
}
.infra-warn-banner {
padding: 12px 18px;
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.28);
border-radius: var(--radius-md);
color: #ffb3bf;
font-size: 13.5px;
}
.infra-loading {
padding: 40px;
text-align: center;
color: var(--text-dim);
font-family: var(--font-display);
letter-spacing: 0.04em;
}
/* ── 3D 스테이지 ───────────────────────────────────────────────────── */
.infra-stage {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
background:
radial-gradient(ellipse 90% 60% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(139, 92, 246, 0.07) 0%, transparent 60%),
linear-gradient(180deg, #060a16 0%, #04060f 100%);
box-shadow: var(--shadow-card);
}
.infra-stage::before {
/* 미세 그리드 텍스처 */
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(0, 212, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.04) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse 100% 80% at 50% 50%, #000 40%, transparent 90%);
pointer-events: none;
}
.pipeline-canvas {
position: relative;
width: 100%;
height: 58vh;
min-height: 440px;
}
.pipeline-labels {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.pipeline-label {
position: absolute;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
background: rgba(6, 10, 22, 0.78);
border: 1px solid color-mix(in srgb, var(--pl-color, #00d4ff) 45%, transparent);
border-radius: 999px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
white-space: nowrap;
will-change: transform;
transition: opacity 0.3s;
}
.pipeline-label .pl-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--pl-color, #00d4ff);
box-shadow: 0 0 8px var(--pl-color, #00d4ff);
}
.pipeline-label .pl-name {
font-family: var(--font-display);
font-size: 11.5px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.01em;
}
.pipeline-label .pl-state {
font-size: 10.5px;
color: var(--pl-color, #8892b0);
font-variant-numeric: tabular-nums;
}
.pipeline-label--anchor .pl-name {
color: var(--pl-color, #e8f0fe);
}
.infra-legend {
position: absolute;
bottom: 12px;
left: 14px;
display: flex;
gap: 14px;
flex-wrap: wrap;
padding: 6px 12px;
background: rgba(6, 10, 22, 0.6);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
color: var(--text-dim);
}
.infra-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.infra-legend i {
width: 9px;
height: 9px;
border-radius: 2px;
display: inline-block;
}
/* ── 워커 카드 그리드 ──────────────────────────────────────────────── */
.infra-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
.infra-grid--compact {
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
}
.infra-card {
position: relative;
background: var(--surface-card);
border: 1px solid var(--line);
border-left: 3px solid var(--c, #4a5572);
border-radius: var(--radius-md);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s var(--ease-out), border-color 0.2s;
}
.infra-card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--c) 40%, var(--line));
}
.infra-card--down {
opacity: 0.72;
}
.infra-card__head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 12px;
}
.infra-card__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--c);
box-shadow: 0 0 10px var(--c);
flex-shrink: 0;
}
.infra-card__id {
flex: 1;
min-width: 0;
}
.infra-card__title {
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
}
.infra-card__kind {
font-size: 10.5px;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.infra-card__state {
font-size: 11px;
font-weight: 600;
color: var(--c);
padding: 3px 9px;
border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
border-radius: 999px;
white-space: nowrap;
}
.infra-card__metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.infra-metric {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 7px 4px;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-xs);
}
.infra-metric__v {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--text-bright);
font-variant-numeric: tabular-nums;
}
.infra-metric__l {
font-size: 10px;
color: var(--text-muted);
}
.infra-metric--warn .infra-metric__v {
color: #fbbf24;
}
.infra-metric--danger .infra-metric__v {
color: #f43f5e;
}
.infra-card__foot {
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
@media (max-width: 768px) {
.pipeline-canvas {
height: 46vh;
min-height: 340px;
}
.infra-bar {
gap: 10px;
}
}

View File

@@ -0,0 +1,141 @@
// src/pages/infra/InfraMonitor.jsx
// /infra — NAS↔Windows 분산 워커 파이프라인 실시간 관측.
// 3D 파이프라인(Three.js) + 2D 워커 카드. WebGL 미지원 시 카드만.
import { useMemo, useState } from 'react';
import { useNodeStatus } from './useNodeStatus';
import PipelineScene from './PipelineScene';
import { workerStateLabel, workerColor, workerTitle, kindLabel } from './statusVisual';
import './InfraMonitor.css';
function hasWebGL() {
try {
const c = document.createElement('canvas');
return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl')));
} catch {
return false;
}
}
function Metric({ label, value, tone }) {
return (
<div className={`infra-metric${tone ? ` infra-metric--${tone}` : ''}`}>
<span className="infra-metric__v">{value ?? 0}</span>
<span className="infra-metric__l">{label}</span>
</div>
);
}
function WorkerCard({ w }) {
const color = workerColor(w);
return (
<div className={`infra-card${w.alive ? '' : ' infra-card--down'}`} style={{ '--c': color }}>
<div className="infra-card__head">
<span className="infra-card__dot" />
<div className="infra-card__id">
<div className="infra-card__title">{workerTitle(w.name)}</div>
<div className="infra-card__kind">{kindLabel(w.kind)}</div>
</div>
<span className="infra-card__state">{workerStateLabel(w)}</span>
</div>
<div className="infra-card__metrics">
<Metric label="큐" value={w.queue_depth} tone={w.queue_depth > 0 ? 'warn' : null} />
<Metric label="실패" value={w.dead_letter} tone={w.dead_letter > 0 ? 'danger' : null} />
<Metric label="처리중" value={w.processing} />
<Metric label="완료" value={w.jobs_done} />
</div>
<div className="infra-card__foot">
{w.alive
? `last beat ${w.last_beat_age_s ?? '?'}s 전`
: '비콘 없음 (오프라인)'}
{w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''}
</div>
</div>
);
}
export default function InfraMonitor() {
const { data, error, loading, updatedAt, refresh } = useNodeStatus(3000);
const webgl = useMemo(() => hasWebGL(), []);
const [view, setView] = useState(webgl ? '3d' : 'grid');
const workers = data?.workers || [];
const online = workers.filter((w) => w.alive).length;
const total = workers.length;
const deadLetters = workers.reduce((a, w) => a + (w.dead_letter || 0), 0);
const redisOk = data ? data.redis_ok : null;
return (
<div className="infra">
<div className="infra-bar">
<div className="infra-bar__stats">
<span className={`infra-chip ${online === total && total > 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}>
<b>{online}</b>/{total || ''} 온라인
</span>
<span className={`infra-chip ${redisOk === false ? 'is-down' : redisOk ? 'is-ok' : ''}`}>
Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'}
</span>
{data?.paused && (
<span className="infra-chip is-warn">
일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''}
</span>
)}
{deadLetters > 0 && <span className="infra-chip is-danger"> 실패 {deadLetters}</span>}
</div>
<div className="infra-bar__actions">
{updatedAt && (
<span className="infra-updated">
{new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신
</span>
)}
{webgl && (
<div className="infra-toggle">
<button className={view === '3d' ? 'is-active' : ''} onClick={() => setView('3d')}>
3D
</button>
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>
그리드
</button>
</div>
)}
<button className="infra-refresh" onClick={refresh} title="새로고침">
</button>
</div>
</div>
{error && !data && (
<div className="infra-error">
<b>집계 서버 연결 끊김</b>
<span>{String(error.message || error)}</span>
<button onClick={refresh}>다시 시도</button>
</div>
)}
{redisOk === false && (
<div className="infra-warn-banner">
Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 없습니다. 파이프라인이 전면 중단 상태입니다.
</div>
)}
{loading && !data && <div className="infra-loading">노드 상태 수집 </div>}
{view === '3d' && webgl && (
<div className="infra-stage">
<PipelineScene status={data} />
<div className="infra-legend">
<span><i style={{ background: '#00d4ff' }} /> 정상·흐름</span>
<span><i style={{ background: '#fbbf24' }} /> 일시정지</span>
<span><i style={{ background: '#fb923c' }} /> 실패누적</span>
<span><i style={{ background: '#f43f5e' }} /> 다운·끊김</span>
</div>
</div>
)}
<div className={`infra-grid${view === '3d' ? ' infra-grid--compact' : ''}`}>
{workers.map((w) => (
<WorkerCard key={w.name} w={w} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getNodeStatus } from '../../api';
import InfraMonitor from './InfraMonitor';
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
const sample = {
redis_ok: true,
paused: false,
paused_reason: null,
workers: [
{ name: 'image-render', kind: 'render', alive: true, state: 'idle', queue_depth: 0, dead_letter: 0, processing: 0, jobs_done: 5, jobs_failed: 0, last_beat_age_s: 3 },
{ name: 'insta-render', kind: 'render', alive: false, state: null, queue_depth: 3, dead_letter: 0, processing: 0, jobs_done: 0, jobs_failed: 0, last_beat_age_s: null },
],
links: [],
};
describe('InfraMonitor', () => {
beforeEach(() => vi.clearAllMocks());
it('renders worker cards from /nodes (grid mode in jsdom — no WebGL)', async () => {
getNodeStatus.mockResolvedValue(sample);
render(<InfraMonitor />);
await waitFor(() => expect(screen.getByText('Image Render')).toBeInTheDocument());
expect(screen.getByText('Insta Render')).toBeInTheDocument();
// alive 워커(image-render, idle)는 '대기' 상태 라벨
expect(screen.getByText('대기')).toBeInTheDocument();
// 오프라인 워커(insta-render)는 '오프라인' 라벨
expect(screen.getByText('오프라인')).toBeInTheDocument();
});
it('shows error state when /nodes fails', async () => {
getNodeStatus.mockRejectedValue(new Error('down'));
render(<InfraMonitor />);
await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,340 @@
// src/pages/infra/PipelineScene.jsx
// NAS ↔ Redis 큐 버스 ↔ Windows 워커 6종을 raw three.js로 그린 실시간 파이프라인.
// 정상: 시안 파티클이 흐름 / busy: 빠르게 / paused: 앰버 정지 / degraded: 주황 흐름 / down: 빨강·흐름 멈춤.
// status(/nodes)는 statusRef로 RAF 루프에 최신값 주입. 라벨은 3D→화면 투영 HTML 오버레이.
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { linkColor, workerStatus, workerStateLabel, workerTitle } from './statusVisual';
const NODES = [
{ name: 'music-render', kind: 'render' },
{ name: 'video-render', kind: 'render' },
{ name: 'image-render', kind: 'render' },
{ name: 'insta-render', kind: 'render' },
{ name: 'task-watcher', kind: 'watcher' },
{ name: 'ai_trade', kind: 'trader' },
];
const hexToColor = (hex) => new THREE.Color(hex);
function workerByName(status, name) {
if (!status || !Array.isArray(status.workers)) return null;
return status.workers.find((w) => w.name === name) || null;
}
// 링크의 현재 상태 문자열 → 'healthy'|'paused'|'degraded'|'down'|null
function linkStatusOf(status, link) {
if (!status) return null;
if (link.kind === 'trunk') return status.redis_ok ? 'healthy' : 'down';
const w = workerByName(status, link.worker);
if (link.kind === 'branch' && !status.redis_ok) return 'down';
if (!w) return 'down';
return workerStatus(w);
}
export default function PipelineScene({ status }) {
const mountRef = useRef(null);
const statusRef = useRef(status);
statusRef.current = status;
useEffect(() => {
const mount = mountRef.current;
if (!mount) return undefined;
let width = mount.clientWidth || 900;
let height = mount.clientHeight || 520;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(52, width / height, 0.1, 200);
camera.position.set(0, 1.4, 20.5);
camera.lookAt(0, -0.3, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height);
renderer.domElement.style.display = 'block';
mount.appendChild(renderer.domElement);
// ── lights ──
scene.add(new THREE.AmbientLight(0x5577aa, 0.65));
const l1 = new THREE.PointLight(0x00d4ff, 1.3, 80);
l1.position.set(-10, 7, 14);
scene.add(l1);
const l2 = new THREE.PointLight(0x8b5cf6, 1.1, 80);
l2.position.set(10, -7, 12);
scene.add(l2);
// ── positions ──
const nasPos = new THREE.Vector3(-9, 0, 0);
const redisPos = new THREE.Vector3(-1.5, 0, 0);
const colX = 8;
const ys = [6.25, 3.75, 1.25, -1.25, -3.75, -6.25];
const nodePositions = NODES.map((n, i) => new THREE.Vector3(colX, ys[i], 0));
const disposables = [];
const track = (obj) => {
if (obj.geometry) disposables.push(obj.geometry);
if (obj.material) disposables.push(obj.material);
return obj;
};
// ── NAS node (left monolith) ──
const nasMesh = track(
new THREE.Mesh(
new THREE.BoxGeometry(2.2, 3.2, 1.4),
new THREE.MeshStandardMaterial({
color: 0x0d1530,
emissive: 0x0a2a44,
emissiveIntensity: 0.9,
metalness: 0.5,
roughness: 0.35,
})
)
);
nasMesh.position.copy(nasPos);
scene.add(nasMesh);
// ── Redis bus (vertical glowing spine) ──
const busMesh = track(
new THREE.Mesh(
new THREE.CylinderGeometry(0.55, 0.55, 13.2, 24, 1, true),
new THREE.MeshBasicMaterial({
color: 0x00d4ff,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
})
)
);
busMesh.position.copy(redisPos);
scene.add(busMesh);
const busCore = track(
new THREE.Mesh(
new THREE.CylinderGeometry(0.18, 0.18, 13.2, 16),
new THREE.MeshBasicMaterial({ color: 0xe8f0fe, transparent: true, opacity: 0.9 })
)
);
busCore.position.copy(redisPos);
scene.add(busCore);
// ── worker nodes ──
const nodeMeshes = NODES.map((n, i) => {
const geo =
n.kind === 'trader'
? new THREE.IcosahedronGeometry(0.95, 0)
: n.kind === 'watcher'
? new THREE.OctahedronGeometry(1.0, 0)
: new THREE.BoxGeometry(1.7, 1.4, 1.4);
const mat = new THREE.MeshStandardMaterial({
color: 0x0d1530,
emissive: 0x111a3a,
emissiveIntensity: 1.0,
metalness: 0.45,
roughness: 0.4,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(nodePositions[i]);
scene.add(mesh);
disposables.push(geo, mat);
return mesh;
});
// ── links (curves) ──
const particleGeo = new THREE.SphereGeometry(0.13, 8, 8);
disposables.push(particleGeo);
const PARTICLES_PER_LINK = 6;
function makeLink(curve, kind, worker) {
const pts = curve.getPoints(60);
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
const lineMat = new THREE.LineBasicMaterial({
color: 0x2a3a66,
transparent: true,
opacity: 0.55,
});
const line = new THREE.Line(lineGeo, lineMat);
scene.add(line);
disposables.push(lineGeo, lineMat);
const pMat = new THREE.MeshBasicMaterial({
color: 0x00d4ff,
transparent: true,
opacity: 0.95,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
disposables.push(pMat);
const particles = [];
for (let k = 0; k < PARTICLES_PER_LINK; k += 1) {
const pm = new THREE.Mesh(particleGeo, pMat);
scene.add(pm);
particles.push({ mesh: pm, t: k / PARTICLES_PER_LINK });
}
return { curve, kind, worker, line, lineMat, pMat, particles };
}
const links = [];
// trunk: NAS → Redis
links.push(
makeLink(
new THREE.QuadraticBezierCurve3(
nasPos.clone().add(new THREE.Vector3(1.2, 0, 0)),
new THREE.Vector3((nasPos.x + redisPos.x) / 2, 0.6, 1.2),
redisPos.clone()
),
'trunk'
)
);
// branches: Redis → render/watcher (indices 0..4)
for (let i = 0; i < 5; i += 1) {
const start = new THREE.Vector3(redisPos.x, ys[i] * 0.45, 0);
const end = nodePositions[i].clone().add(new THREE.Vector3(-1.0, 0, 0));
const ctrl = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, 1.6);
links.push(makeLink(new THREE.QuadraticBezierCurve3(start, ctrl, end), 'branch', NODES[i].name));
}
// ai_trade: node → NAS directly (http-pull, bypasses Redis bus)
links.push(
makeLink(
new THREE.QuadraticBezierCurve3(
nodePositions[5].clone().add(new THREE.Vector3(-0.9, -0.2, 0)),
new THREE.Vector3(0, -9.5, 4.5),
nasPos.clone().add(new THREE.Vector3(0.4, -1.4, 0))
),
'pull',
'ai_trade'
)
);
// ── HTML label overlay ──
const overlay = document.createElement('div');
overlay.className = 'pipeline-labels';
mount.appendChild(overlay);
const makeLabel = (title, sub) => {
const el = document.createElement('div');
el.className = 'pipeline-label';
el.innerHTML = `<span class="pl-dot"></span><span class="pl-name">${title}</span><span class="pl-state">${sub}</span>`;
overlay.appendChild(el);
return el;
};
const nasLabel = makeLabel('NAS', '게이트웨이');
nasLabel.classList.add('pipeline-label--anchor');
const busLabel = makeLabel('Redis Bus', '큐');
busLabel.classList.add('pipeline-label--anchor');
const nodeLabels = NODES.map((n) => makeLabel(workerTitle(n.name), '—'));
const projectTo = (pos, el, dx = 0, dy = 0) => {
const v = pos.clone().project(camera);
const x = (v.x * 0.5 + 0.5) * width + dx;
const y = (-v.y * 0.5 + 0.5) * height + dy;
el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px)`;
el.style.opacity = v.z < 1 ? '1' : '0';
};
// ── animation ──
let raf = 0;
let last = performance.now();
const clock = { t: 0 };
const speedFor = (st) => {
if (st === 'down' || st === 'paused' || st == null) return 0;
return 0.16; // healthy/degraded base
};
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
clock.t += dt;
const status = statusRef.current;
// Redis bus color/pulse
const redisOk = !status || status.redis_ok;
const busColor = redisOk ? 0x00d4ff : 0xf43f5e;
const pulse = 0.7 + Math.sin(clock.t * 2.2) * 0.18;
busMesh.material.color.setHex(busColor);
busMesh.material.opacity = 0.45 + pulse * 0.3;
busCore.material.opacity = redisOk ? 0.55 + pulse * 0.35 : 0.5;
// per-link
links.forEach((lk) => {
const st = linkStatusOf(status, lk);
const col = hexToColor(st ? linkColor(st) : '#2a3a66');
lk.lineMat.color.copy(col);
lk.lineMat.opacity = st === 'down' ? 0.5 : 0.55;
lk.pMat.color.copy(col);
let speed = speedFor(st);
// busy 워커는 빠르게
if (lk.worker && status) {
const w = workerByName(status, lk.worker);
if (w && w.state === 'busy') speed = 0.42;
}
const showParticles = st !== 'down';
lk.pMat.opacity = showParticles ? 0.95 : 0.0;
lk.particles.forEach((p) => {
p.t = (p.t + speed * dt) % 1;
const pos = lk.curve.getPoint(p.t);
p.mesh.position.copy(pos);
p.mesh.visible = showParticles;
const s = st === 'paused' ? 0.8 : 1 + Math.sin((p.t + clock.t) * 6) * 0.25;
p.mesh.scale.setScalar(s);
});
});
// worker node color/pulse + labels
NODES.forEach((n, i) => {
const w = workerByName(status, n.name);
const stt = workerStatus(w);
const c = hexToColor(linkColor(stt));
const mesh = nodeMeshes[i];
mesh.material.emissive.copy(c);
const alive = w && w.alive;
const beat = alive ? 1.05 + Math.sin(clock.t * 3 + i) * 0.06 : 0.92;
mesh.material.emissiveIntensity = alive ? 0.9 + Math.sin(clock.t * 3 + i) * 0.25 : 0.35;
mesh.scale.setScalar(beat);
mesh.rotation.y += dt * (n.kind === 'render' ? 0.15 : 0.4);
// label
const el = nodeLabels[i];
el.style.setProperty('--pl-color', linkColor(stt));
const sub = el.querySelector('.pl-state');
if (sub) sub.textContent = workerStateLabel(w);
projectTo(nodePositions[i].clone().add(new THREE.Vector3(0, 1.5, 0)), el);
});
// NAS / bus labels
nasLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
projectTo(nasPos.clone().add(new THREE.Vector3(0, 2.2, 0)), nasLabel);
busLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
const busSub = busLabel.querySelector('.pl-state');
if (busSub) busSub.textContent = redisOk ? '정상' : '연결 끊김';
projectTo(redisPos.clone().add(new THREE.Vector3(0, 7.3, 0)), busLabel);
renderer.render(scene, camera);
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
// ── resize ──
const onResize = () => {
width = mount.clientWidth || width;
height = mount.clientHeight || height;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
const ro = new ResizeObserver(onResize);
ro.observe(mount);
// ── cleanup ──
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
disposables.forEach((d) => d.dispose && d.dispose());
renderer.dispose();
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
};
}, []);
return <div ref={mountRef} className="pipeline-canvas" />;
}

View File

@@ -0,0 +1,69 @@
// src/pages/infra/statusVisual.js
// 상태 → 색/라벨 매핑. 2D 패널과 Three.js 파이프라인이 공유하는 단일 진실원천.
// 색은 index.css 테마 팔레트와 일치(neon-cyan healthy, amber paused, orange degraded, red down).
export const LINK_COLORS = {
healthy: '#00d4ff', // neon-cyan — 통신이 흐름
paused: '#fbbf24', // amber — 작업중(트레이딩) 일시정지
degraded: '#fb923c', // orange — dead-letter 누적
down: '#f43f5e', // red — 워커 다운/링크 끊김
};
const NEUTRAL = '#4a5572';
export function linkColor(status) {
return LINK_COLORS[status] || NEUTRAL;
}
// 워커 객체 → 사람이 읽는 상태 라벨
export function workerStateLabel(w) {
if (!w || !w.alive) return '오프라인';
switch (w.state) {
case 'paused':
return '일시정지';
case 'busy':
return '처리 중';
case 'idle':
return '대기';
case 'market_open':
return '장중';
case 'market_closed':
return '휴장';
default:
return '온라인';
}
}
// 워커 객체 → 링크 status 도출(2D/3D 공통). collect_status의 link 산정과 동일 규칙.
export function workerStatus(w) {
if (!w || !w.alive) return 'down';
if (w.state === 'paused') return 'paused';
if ((w.dead_letter || 0) > 0) return 'degraded';
return 'healthy';
}
export function workerColor(w) {
return linkColor(workerStatus(w));
}
// 워커 내부명 → 표시 타이틀
export const WORKER_TITLES = {
'music-render': 'Music Render',
'video-render': 'Video Render',
'image-render': 'Image Render',
'insta-render': 'Insta Render',
'task-watcher': 'Task Watcher',
ai_trade: 'AI Trade',
};
export function workerTitle(name) {
return WORKER_TITLES[name] || name;
}
// kind → 한 줄 역할
export function kindLabel(kind) {
if (kind === 'render') return '렌더 워커';
if (kind === 'watcher') return '작업 감시';
if (kind === 'trader') return '트레이딩';
return kind || '';
}

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle } from './statusVisual';
describe('statusVisual', () => {
it('maps link status to theme colors', () => {
expect(linkColor('healthy')).toBe('#00d4ff');
expect(linkColor('paused')).toBe('#fbbf24');
expect(linkColor('degraded')).toBe('#fb923c');
expect(linkColor('down')).toBe('#f43f5e');
expect(linkColor('???')).toBe('#4a5572');
});
it('labels a dead worker offline', () => {
expect(workerStateLabel({ alive: false })).toBe('오프라인');
expect(workerStateLabel(null)).toBe('오프라인');
});
it('labels alive workers by state', () => {
expect(workerStateLabel({ alive: true, state: 'idle' })).toBe('대기');
expect(workerStateLabel({ alive: true, state: 'busy' })).toBe('처리 중');
expect(workerStateLabel({ alive: true, state: 'paused' })).toBe('일시정지');
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
});
it('derives worker status with dead-letter and paused precedence', () => {
expect(workerStatus({ alive: false })).toBe('down');
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 3 })).toBe('degraded');
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 0 })).toBe('healthy');
});
it('workerColor follows workerStatus', () => {
expect(workerColor({ alive: false })).toBe('#f43f5e');
expect(workerColor({ alive: true, state: 'idle' })).toBe('#00d4ff');
});
it('humanizes worker names', () => {
expect(workerTitle('insta-render')).toBe('Insta Render');
expect(workerTitle('ai_trade')).toBe('AI Trade');
expect(workerTitle('unknown-x')).toBe('unknown-x');
});
});

View File

@@ -0,0 +1,39 @@
// src/pages/infra/useNodeStatus.js
// /api/agent-office/nodes 를 주기 폴링하는 훅. 3초 권장(Three.js 흐름과 동기).
import { useEffect, useState, useRef, useCallback } from 'react';
import { getNodeStatus } from '../../api';
export function useNodeStatus(intervalMs = 4000) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [updatedAt, setUpdatedAt] = useState(null);
const aliveRef = useRef(true);
const tick = useCallback(async () => {
try {
const d = await getNodeStatus();
if (!aliveRef.current) return;
setData(d);
setError(null);
setUpdatedAt(Date.now());
} catch (e) {
if (!aliveRef.current) return;
setError(e);
} finally {
if (aliveRef.current) setLoading(false);
}
}, []);
useEffect(() => {
aliveRef.current = true;
tick();
const id = setInterval(tick, intervalMs);
return () => {
aliveRef.current = false;
clearInterval(id);
};
}, [tick, intervalMs]);
return { data, error, loading, updatedAt, refresh: tick };
}

View File

@@ -0,0 +1,26 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useNodeStatus } from './useNodeStatus';
import { getNodeStatus } from '../../api';
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
describe('useNodeStatus', () => {
beforeEach(() => vi.clearAllMocks());
it('fetches node status on mount', async () => {
getNodeStatus.mockResolvedValue({ redis_ok: true, workers: [], links: [] });
const { result } = renderHook(() => useNodeStatus(100000));
await waitFor(() => expect(result.current.data).toBeTruthy());
expect(result.current.data.redis_ok).toBe(true);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('captures fetch error', async () => {
getNodeStatus.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useNodeStatus(100000));
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.error.message).toBe('boom');
});
});

View File

@@ -23,6 +23,8 @@
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
a.ic-btn { color: inherit; }
a.ic-btn:hover { color: inherit; }
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
@keyframes ic-spin { to { transform: rotate(360deg); } }

View File

@@ -11,6 +11,7 @@ import {
renderInstaSlate,
deleteInstaSlate,
getInstaAssetUrl,
instaPackageUrl,
getInstaTask,
getInstaPrompt,
putInstaPrompt,
@@ -832,6 +833,9 @@ function SlateDetail({ slate, onDelete, onRender }) {
</div>
<div className="ic-detail__actions">
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
📦 패키지 다운로드 (10 + 캡션)
</a>
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
</div>
</div>

View File

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

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import Loading from '../../../components/Loading';
import { kindMeta, conditionLabel, relativeTime, formatDetail } 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);
const detailText = formatDetail(a.detail);
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>
{detailText && <div className="wl-alert__detail">{detailText}</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,47 @@
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();
});
it('detail이 객체인 알림도 크래시 없이 렌더 (React #31 회귀 방지)', () => {
const wl = {
...baseWl,
alerts: [{
id: 20, ticker: '381170', name: null, kind: 'sell', condition: 'sell_ma_break',
price: 32715, detail: { ma50: 33309.8, ma200: null, severity: 'normal' },
fired_at: '2026-07-03T07:02:59Z',
}],
};
render(<WatchlistTab wl={wl} />);
expect(screen.getByText('매도')).toBeInTheDocument();
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
// 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출)
expect(screen.getByText(/normal/)).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_ADVISOR = 'advisor';
export const TAB_HOLDINGS_INTEL = 'holdings_intel';
export const TAB_WATCHLIST = 'watchlist';

View File

@@ -0,0 +1,80 @@
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
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();
/* 알림 detail 은 condition 마다 스키마가 다른 객체(또는 문자열).
객체를 JSX 자식으로 직접 렌더하면 React #31 크래시 → 항상 문자열로 변환한다.
특정 필드 존재를 가정하지 않고(스키마 가변) 알려진 키만 한글 라벨, 나머지는 원문 키. */
const DETAIL_KEY_LABEL = Object.freeze({
avg_price: '평단', pnl_pct: '손익', stop_pct: '손절', take_pct: '목표',
holding_high: '보유고점', trailing_pct: '트레일링', drawdown_pct: '낙폭',
ma50: 'MA50', ma200: 'MA200', severity: '강도', vol: '거래량', rsi: 'RSI',
breakout_high: '돌파고점', pullback_pct: '눌림',
});
const formatDetailValue = (key, v) => {
if (v == null) return null;
if (typeof v === 'number') {
if (key.endsWith('_pct')) return `${(v * 100).toFixed(2)}%`;
return Number.isInteger(v) ? v.toLocaleString('ko-KR') : String(v);
}
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
};
export const formatDetail = (detail) => {
if (detail == null) return '';
if (typeof detail === 'string') return detail;
if (typeof detail !== 'object') return String(detail);
return Object.entries(detail)
.map(([k, v]) => {
const fv = formatDetailValue(k, v);
return fv == null ? null : `${DETAIL_KEY_LABEL[k] ?? k} ${fv}`;
})
.filter(Boolean)
.join(' · ');
};
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,99 @@
import { describe, it, expect } from 'vitest';
import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } 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('formatDetail (알림 detail 객체 → 안전 문자열, React #31 방지)', () => {
it('객체 detail을 읽기 좋은 문자열로 변환하고 절대 객체를 반환하지 않는다', () => {
const s = formatDetail({ avg_price: 75325, pnl_pct: -0.1012, stop_pct: 0.08 });
expect(typeof s).toBe('string');
expect(s).toContain('75,325'); // avg_price 천단위 포맷
expect(s).toContain('-10.12%'); // *_pct 는 퍼센트로
expect(s).toContain('8.00%');
});
it('값이 null인 필드는 건너뛴다', () => {
const s = formatDetail({ ma50: 33309.8, ma200: null, severity: 'normal' });
expect(typeof s).toBe('string');
expect(s).toContain('normal');
expect(s).not.toContain('null');
});
it('문자열 detail은 그대로 반환', () => {
expect(formatDetail('박스권 돌파')).toBe('박스권 돌파');
});
it('null/undefined 는 빈 문자열', () => {
expect(formatDetail(null)).toBe('');
expect(formatDetail(undefined)).toBe('');
});
it('미정의 키(스키마 가정 없음)도 크래시 없이 문자열로', () => {
const s = formatDetail({ some_new_field: 42 });
expect(typeof s).toBe('string');
expect(s).toContain('42');
});
});
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('');
});
});

View File

@@ -41,6 +41,7 @@ const SajuToday = lazy(() => import('./pages/saju/Today'));
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
const SajuMe = lazy(() => import('./pages/saju/Me'));
const InfraMonitor = lazy(() => import('./pages/infra/InfraMonitor'));
export const navLinks = [
{
@@ -142,6 +143,15 @@ export const navLinks = [
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
accent: '#8b5cf6',
},
{
id: 'infra',
label: 'Infra',
path: '/infra',
subtitle: 'NODE PIPELINE',
description: 'NAS↔Windows 워커 파이프라인 실시간 관측',
icon: <span style={{fontSize:'1.2em'}}>🛰</span>,
accent: '#22d3ee',
},
{
id: 'lab',
label: 'Lab',
@@ -240,6 +250,10 @@ export const appRoutes = [
path: 'agent-office',
lazy: () => import('./pages/agent-office/AgentOffice'),
},
{
path: 'infra',
element: <InfraMonitor />,
},
{
path: 'tarot',
element: <Tarot />,