Compare commits

11 Commits

Author SHA1 Message Date
3fa4dbda3c Merge feature/lotto-curator-evolution: Lotto Curator Evolution (frontend)
7 commits for Phase F + G:
- api.js: getLatestReview / getReviewHistory / bulkPurchase 헬퍼
- useReview 훅 + useBriefing 4계층 normalize
- DecisionCard + RetrospectiveBox + TierModeToggle + TierSection + PickCard + BulkPurchaseButton
- BriefingTab 단일 화면 재구성
- 분석탭 → 자료실 라벨 + 9개 패널 details 접힘
- PurchasePanel 자동 채점 일치수 배지 + 4등↑ 플래그
- 4주 추세 차트(너 vs 큐레이터 평균 일치)

자세한 컨셉/계획: docs/superpowers/{specs,plans}/2026-05-11-*.md
2026-05-11 09:39:08 +09:00
baf34dd7aa feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치) 2026-05-11 09:06:54 +09:00
4ef76f6cce feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그 2026-05-11 09:05:22 +09:00
0bf1233e96 feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘 2026-05-11 09:03:10 +09:00
ff7ac48c6b feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성 2026-05-11 09:00:59 +09:00
329141c732 feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일 2026-05-11 08:59:00 +09:00
cd3c538eb7 feat(lotto): useReview 훅 + useBriefing 4계층 정규화 2026-05-11 08:57:14 +09:00
9d2dfad512 feat(api): review + bulkPurchase 헬퍼 2026-05-11 08:56:10 +09:00
42073a5bf3 docs(plan): Lotto Curator Evolution 구현 plan
23 task로 분해 (TDD 사이클 + 빈번한 commit):
- Phase A (1-2): weekly_review 테이블 + 4계층 마이그레이션
- Phase B (3-5): 채점 보조 함수 + 통합 잡 + cron
- Phase C (6-8): review/bulk/briefing 라우터
- Phase D (9-12): 큐레이터 4계층 스키마 + 회고 + pipeline
- Phase E (13-15): 텔레그램 알림 + webhook + cron 변경
- Phase F (16-19): api 헬퍼 + 훅 + DecisionCard
- Phase G (20-22): 자료실 강등 + 자동채점 표시 + 추세 차트
- Phase H (23): 1주차 운영 점검

스펙→코드베이스 보정 사항(테이블명/기존 컬럼/기존 자동채점) plan 상단에 명시
2026-05-11 04:26:00 +09:00
6b2fcda2af docs(spec): Lotto Curator Evolution 설계 문서
매주 같은 시간에 큐레이터가 한 번 더 똑똑해지는 컨셉으로
- 회고 컨텍스트(weekly_review + 자동 채점 잡)
- 4계층 위계(코어/보너스/확장/풀, 5~20세트)
- 결정 카드 단일 화면(브리핑 탭 재구성)
- 분석 탭은 자료실로 강등
- 월요일 09:00 큐레이션 + 텔레그램 푸시
2026-05-11 03:19:58 +09:00
acac2cd20e chore: ignore .superpowers/ (visual companion mockup files) 2026-05-11 03:19:57 +09:00
21 changed files with 3686 additions and 60 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
.env.local .env.local
# Superpowers visual companion (mockup files)
.superpowers/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Lotto Curator Evolution — Design Spec
- 일자: 2026-05-11
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
## 1. 동기와 문제
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
## 2. 컨셉
다음 두 축으로 강화한다.
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
## 3. 주간 사이클
```
토 20:35 추첨
일 03:00 추첨결과 sync (기존)
채점 잡 (신규) → weekly_review INSERT
lotto_purchase auto_graded UPDATE
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
├─ build_retrospective(target_draw)
├─ collect_candidates(N=30)
├─ build_context (+retrospective)
├─ Claude 호출 (회고+계층 규칙)
└─ briefings INSERT (4계층 picks)
월 09:05 텔레그램 헤드라인 푸시
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
토 20:35 추첨 → 다음 사이클
```
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
## 4. 결정 카드 (브리핑 탭 메인)
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
### 4계층 위계
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|---|---|---|---|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage``lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
### 분석 탭은 "Deep Dive" 자료실로 강등
- 라벨 변경: `📊 분석·통계``📚 자료실 / Deep Dive`
- 첫 진입 시 모든 패널 접힘
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
## 5. 데이터 모델
### 신규 테이블 — `weekly_review`
```sql
CREATE TABLE weekly_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER NOT NULL UNIQUE,
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
curator_avg_match REAL,
curator_best_tier TEXT, -- 안정 | 균형 | 공격
curator_best_match INTEGER,
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
user_avg_match REAL,
user_best_match INTEGER,
user_5plus_prizes INTEGER,
-- 패턴 갭 (서사 재료)
user_pattern_summary TEXT,
draw_pattern_summary TEXT,
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### `lotto_purchase` 컬럼 추가
```sql
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
```
### `briefings.picks` 구조 변경
JSON 컬럼을 4계층 구조로 마이그레이션:
```json
{
"core": [/* 5 */],
"bonus": [/* 5 */],
"extended": [/* 5 */],
"pool": [/* 5 */]
}
```
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
## 6. 큐레이터 변경
### 출력 스키마 (`agent-office/curator/schema.py`)
```python
class CuratorOutput(BaseModel):
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
narrative: Narrative # retrospective(60자 이내) 필드 추가
confidence: int # 0~100
```
### SYSTEM_PROMPT 추가 규칙
```
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
```
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
```python
def build_retrospective(target_draw_no: int) -> dict | None:
last = lotto_get_review(target_draw_no - 1)
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
if not last:
return None
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last["curator_avg_match"],
"curator_best_tier": last["curator_best_tier"],
"user_avg": last["user_avg_match"],
"user_5plus": last["user_5plus_prizes"],
"pattern_delta": last["pattern_delta"],
},
"trend_4w": {
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
}
}
```
### 후보 풀 N=30
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
```
실행: 매주 일요일 03:00 KST (cron)
입력: 가장 최근 sync된 추첨 회차
처리:
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
6) weekly_review UPSERT (draw_no 유니크)
7) lotto_purchase 채점:
- 일치 3개 → prize=5000, auto_graded=1
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
호출하여 텔레그램 별도 알림 트리거
- numbers NULL 인 행은 스킵
```
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
```
🎟 1154회 · 큐레이션 떴음
"이번 주는 안정 +1, 콜드 누적 보강."
신뢰도 72 · 분배 안정 3·균형 1·공격 1
▸ 회고: 너 2.0 / 나 1.8
너 저번호 편향 → 보너스 고번호 보강
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
```
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
## 9. 프론트 변경
### 파일 변경 맵
| 파일 | 종류 | 내용 |
|------|------|------|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
### 컴포넌트 격리 원칙
- `DecisionCard``briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
- `TierSection``tier`, `picks`, `rationale` 만 받아 4번 재사용.
- `BulkPurchaseButton``draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
## 10. 백엔드 변경
### `web-backend/lotto/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
### `web-backend/agent-office/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
## 11. API 추가·변경
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
기존 엔드포인트는 그대로 유지(스키마 호환).
## 12. 에러 처리 / 격리
| 단계 | 실패 | 처리 |
|------|------|------|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
## 13. 테스트
### 백엔드 (`lotto/`)
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
### 큐레이터 (`agent-office/curator/`)
- `build_retrospective`: review 1건 / 4건 / 0건
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
- `telegram_lotto.format`: retrospective 있음·없음
### 프론트
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
- `TierModeToggle` 단위: onChange 콜백 정확
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
- 자료실 탭 수동: 첫 진입 모두 접힘
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
## 14. 운영 점검 (배포 후 1주차)
수동으로 확인:
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
4. 결정 카드 렌더링 정상(모바일 + PC)
5. 원클릭 구매 정확 N건 INSERT
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
## 15. Out of Scope
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)

View File

@@ -680,3 +680,18 @@ export const getBatchJob = (id) => apiGet(`/api/music/generate-b
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`); export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
export const listGenres = () => apiGet('/api/music/genres'); export const listGenres = () => apiGet('/api/music/genres');
// === 주간 회고 (weekly_review) ===
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
throw e;
});
export const getReviewHistory = (limit = 4) =>
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });

View File

@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
const TABS = [ const TABS = [
{ id: 'briefing', label: '🗓 이번 주 브리핑' }, { id: 'briefing', label: '🗓 이번 주 브리핑' },
{ id: 'analysis', label: '📊 분석·통계' }, { id: 'analysis', label: '📚 자료실 / Deep Dive' },
{ id: 'purchase', label: '💰 구매·성과' }, { id: 'purchase', label: '💰 구매·성과' },
]; ];

View File

@@ -1020,7 +1020,7 @@
.lotto-purchase-list__head { .lotto-purchase-list__head {
display: grid; display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px; grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px; gap: 8px;
padding: 10px 14px; padding: 10px 14px;
font-size: 11px; font-size: 11px;
@@ -1033,7 +1033,7 @@
.lotto-purchase-row { .lotto-purchase-row {
display: grid; display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px; grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding: 12px 14px; padding: 12px 14px;
@@ -1068,6 +1068,21 @@
justify-content: flex-end; justify-content: flex-end;
} }
.lotto-purchase-row__hits {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
overflow: hidden;
}
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
.is-pos { color: #97c9aa; } .is-pos { color: #97c9aa; }
.is-neg { color: #f7a8a5; } .is-neg { color: #f7a8a5; }
.is-prize { color: #fdd4b1; } .is-prize { color: #fdd4b1; }
@@ -1098,8 +1113,8 @@
gap: 8px; gap: 8px;
} }
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5), .lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) { .lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
display: none; display: none;
} }
@@ -1143,7 +1158,7 @@
.lotto-purchase-list__head, .lotto-purchase-list__head,
.lotto-purchase-row { .lotto-purchase-row {
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px; grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
} }
.lotto-purchase-list__head span:nth-child(4), .lotto-purchase-list__head span:nth-child(4),
@@ -1526,3 +1541,14 @@
font-size: 13px; font-size: 13px;
} }
} }
.lotto-section-fold { margin-bottom: 14px; }
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
.trend-chart { display: block; margin: 0 auto; }
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.dot--curator { background: #b8a8ff; }
.dot--user { background: #76e09a; }

View File

@@ -137,6 +137,7 @@ const PurchasePanel = ({
<span>투자금</span> <span>투자금</span>
<span>당첨금</span> <span>당첨금</span>
<span>손익</span> <span>손익</span>
<span>채점</span>
<span>메모</span> <span>메모</span>
<span /> <span />
</div> </div>
@@ -152,6 +153,14 @@ const PurchasePanel = ({
<span className={net >= 0 ? 'is-pos' : 'is-neg'}> <span className={net >= 0 ? 'is-pos' : 'is-neg'}>
{net >= 0 ? '+' : ''}{fmtWon(net)} {net >= 0 ? '+' : ''}{fmtWon(net)}
</span> </span>
<span className="lotto-purchase-row__hits">
{(rec.results || []).map((r, i) => (
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
))}
{(rec.results || []).some((r) => r.correct >= 4) && (
<span className="prize-flag">🚨 4 확인 필요</span>
)}
</span>
<span className="lotto-purchase-row__note">{rec.note || '-'}</span> <span className="lotto-purchase-row__note">{rec.note || '-'}</span>
<div className="lotto-purchase-row__actions"> <div className="lotto-purchase-row__actions">
<button className="button ghost small" onClick={() => onEditStart(rec)}> <button className="button ghost small" onClick={() => onEditStart(rec)}>

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { getReviewHistory } from '../../../api';
export default function PurchaseTrendChart() {
const [reviews, setReviews] = useState([]);
useEffect(() => {
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
}, []);
if (reviews.length === 0) return null;
const maxAvg = Math.max(
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
2.5
);
const w = 320, h = 80, pad = 16;
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
const line = (key) => reviews
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
.filter(p => p.y != null)
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
.join(' ');
return (
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
<h3> vs 큐레이터 평균 일치 </h3>
</div>
</div>
<svg width={w} height={h} className="trend-chart">
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
</svg>
<div className="trend-legend">
<span><span className="dot dot--curator" /> 큐레이터</span>
<span><span className="dot dot--user" /> </span>
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { bulkPurchase } from '../../../../api';
import { MODES } from './TierModeToggle';
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
const [busy, setBusy] = useState(false);
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
const onClick = async () => {
if (busy) return;
setBusy(true);
try {
await bulkPurchase({
draw_no: drawNo,
tier_mode: tierMode,
sets: mode.sets,
amount: mode.amount,
});
onSuccess?.();
alert(`${mode.sets}세트 구매 기록 완료!`);
} catch (e) {
alert(`구매 기록 실패: ${e?.message || e}`);
} finally {
setBusy(false);
}
};
return (
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
</button>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useMemo, useState } from 'react';
import RetrospectiveBox from './RetrospectiveBox';
import TierModeToggle, { MODES } from './TierModeToggle';
import TierSection from './TierSection';
import BulkPurchaseButton from './BulkPurchaseButton';
import './decision.css';
const TIER_CHAIN = {
core: ['core'],
core_bonus: ['core', 'bonus'],
core_bonus_extended: ['core', 'bonus', 'extended'],
full: ['core', 'bonus', 'extended', 'pool'],
};
const STORAGE_KEY = 'lotto.tier_mode';
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
const [tierMode, setTierMode] = useState(() =>
localStorage.getItem(STORAGE_KEY) || 'core'
);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, tierMode);
}, [tierMode]);
const visibleTiers = TIER_CHAIN[tierMode];
const totalSets = useMemo(
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
[briefing, visibleTiers]
);
// 분배 칩 — 보이는 계층의 risk_tag 합산
const balance = useMemo(() => {
const acc = { '안정': 0, '균형': 0, '공격': 0 };
for (const t of visibleTiers) {
for (const p of (briefing?.picks?.[t] || [])) {
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
}
}
return acc;
}, [briefing, visibleTiers]);
if (!briefing) return null;
let cursor = 0;
return (
<div className="lc-card">
<header className="lc-head">
<div>
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}</p>
<h3 className="lc-title">{briefing.narrative.headline}</h3>
</div>
<div className="lc-conf">
<div className="lc-conf__num">{briefing.confidence}</div>
<div className="lc-conf__lbl">CONFIDENCE</div>
</div>
</header>
<RetrospectiveBox briefing={briefing} review={review} />
<p className="lc-headline-3">
{(briefing.narrative.summary_3lines || []).join(' · ')}
</p>
<div className="lc-balance">
<div className="lc-balance__chips">
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
</div>
</div>
<TierModeToggle value={tierMode} onChange={setTierMode} />
{visibleTiers.map(tier => {
const picks = briefing.picks?.[tier] || [];
const idxBase = cursor;
cursor += picks.length;
return (
<TierSection
key={tier}
tier={tier}
picks={picks}
rationale={briefing.tier_rationale?.[tier]}
indexBase={idxBase}
totalSets={totalSets}
/>
);
})}
<div className="lc-actions">
<BulkPurchaseButton
drawNo={briefing.draw_no}
tierMode={tierMode}
onSuccess={onPurchaseSuccess}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
export default function PickCard({ pick, index, total }) {
const role = pick.risk_tag;
return (
<div className="lc-set">
<div className="lc-set__head">
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}> {role}</span>
<span className="lc-set__idx">Set {index + 1} / {total}</span>
</div>
<div className="lc-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="lc-set__reason">{pick.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function RetrospectiveBox({ briefing, review }) {
const retro = briefing?.narrative?.retrospective;
if (!retro) return null;
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
return (
<aside className="lc-retro">
<p className="lc-retro__time"> 지난 {drawNo ? `${drawNo}` : ''} 회고</p>
<p className="lc-retro__body">{retro}</p>
</aside>
);
}

View File

@@ -0,0 +1,28 @@
const MODES = [
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
];
export default function TierModeToggle({ value, onChange }) {
return (
<div className="lc-toggle" role="tablist">
{MODES.map((m, i) => (
<button
key={m.key}
role="tab"
aria-selected={value === m.key}
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
onClick={() => onChange(m.key)}
>
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
<span className="lc-toggle__lbl">{m.label}</span>
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}</span>
</button>
))}
</div>
);
}
export { MODES };

View File

@@ -0,0 +1,25 @@
import PickCard from './PickCard';
const TIER_TITLE = {
core: '코어 (필수, 5세트)',
bonus: '보너스 (+5)',
extended: '확장 (+5)',
pool: '풀 (+5)',
};
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
if (!picks?.length) return null;
return (
<section className={`lc-tier lc-tier--${tier}`}>
<header className="lc-tier__head">
<h4>{TIER_TITLE[tier]}</h4>
{rationale && tier !== 'core' && (
<p className="lc-tier__rationale">{rationale}</p>
)}
</header>
{picks.map((p, i) => (
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
))}
</section>
);
}

View File

@@ -0,0 +1,52 @@
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
.lc-balance__chips { display: flex; gap: 8px; }
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
.lc-tier { margin-bottom: 14px; }
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
padding: 14px; margin-bottom: 10px; }
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.lc-set__role--stable { color: #76e09a; }
.lc-set__role--balance { color: #ffce6e; }
.lc-set__role--aggro { color: #ff8aa0; }
.lc-set__idx { font-size: 10px; opacity: 0.4; }
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
font-size: 14px; min-width: 160px; }
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
@media (max-width: 480px) {
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -1,6 +1,18 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { getLatestBriefing, triggerLottoCurate } from '../../../api'; import { getLatestBriefing, triggerLottoCurate } from '../../../api';
const normalizePicks = (picks) => {
if (Array.isArray(picks)) {
return { core: picks, bonus: [], extended: [], pool: [] };
}
return {
core: picks?.core || [],
bonus: picks?.bonus || [],
extended: picks?.extended || [],
pool: picks?.pool || [],
};
};
export default function useBriefing() { export default function useBriefing() {
const [briefing, setBriefing] = useState(null); const [briefing, setBriefing] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -12,7 +24,7 @@ export default function useBriefing() {
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const data = await getLatestBriefing(); const data = await getLatestBriefing();
setBriefing(data); setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} finally { } finally {
@@ -33,7 +45,7 @@ export default function useBriefing() {
try { try {
const data = await getLatestBriefing(); const data = await getLatestBriefing();
if (data && data.generated_at !== prevGen) { if (data && data.generated_at !== prevGen) {
setBriefing(data); setBriefing({ ...data, picks: normalizePicks(data.picks) });
setRegenerating(false); setRegenerating(false);
clearInterval(pollingRef.current); clearInterval(pollingRef.current);
} }

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase, getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
bulkPurchase as apiBulkPurchase,
} from '../../../api'; } from '../../../api';
import { emptyPurchaseForm } from '../lottoUtils'; import { emptyPurchaseForm } from '../lottoUtils';
@@ -94,6 +95,12 @@ export default function usePurchases() {
} catch { refreshPurchases(); } } catch { refreshPurchases(); }
}, [refreshPurchases]); }, [refreshPurchases]);
const handleBulkPurchase = useCallback(async (params) => {
const result = await apiBulkPurchase(params);
await refreshPurchases();
return result;
}, [refreshPurchases]);
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return { return {
@@ -101,5 +108,6 @@ export default function usePurchases() {
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId, purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange, handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete, handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
handleBulkPurchase,
}; };
} }

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { getLatestReview, getReviewHistory } from '../../../api';
export default function useReview() {
const [latest, setLatest] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancel = false;
Promise.all([getLatestReview(), getReviewHistory(4)])
.then(([l, h]) => {
if (cancel) return;
setLatest(l);
setHistory(h);
})
.catch(() => {})
.finally(() => !cancel && setLoading(false));
return () => { cancel = true; };
}, []);
return { latest, history, loading };
}

View File

@@ -40,6 +40,8 @@ export default function AnalysisTab() {
<PerformanceBanner perf={ld.perfStats} /> <PerformanceBanner perf={ld.perfStats} />
{/* 종합 추론 번호 추천 */} {/* 종합 추론 번호 추천 */}
<details className="lotto-section-fold">
<summary>종합 추론 추천</summary>
<CombinedRecommendPanel <CombinedRecommendPanel
combined={ld.combined} combined={ld.combined}
history={ld.combinedHistory} history={ld.combinedHistory}
@@ -48,10 +50,11 @@ export default function AnalysisTab() {
onRun={ld.runCombinedRecommend} onRun={ld.runCombinedRecommend}
onCopy={copyNumbers} onCopy={copyNumbers}
/> />
</details>
{/* 최신 회차 + 시뮬레이션 추천 */} {/* 최신 회차 */}
<div className="lotto-grid"> <details className="lotto-section-fold">
{/* Latest Draw */} <summary>최신 회차</summary>
<section className="lotto-panel"> <section className="lotto-panel">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p> <p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)} )}
</section> </section>
</details>
{/* Simulation Picks */} {/* Simulation Picks */}
<details className="lotto-section-fold">
<summary>시뮬레이션 추천</summary>
<section className="lotto-panel"> <section className="lotto-panel">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -163,9 +169,11 @@ export default function AnalysisTab() {
</> </>
)} )}
</section> </section>
</div> </details>
{/* 이번 주 공략 리포트 */} {/* 이번 주 공략 리포트 */}
<details className="lotto-section-fold">
<summary>이번 공략 리포트</summary>
<ReportPanel <ReportPanel
report={ld.report} report={ld.report}
history={ld.reportHistory} history={ld.reportHistory}
@@ -173,8 +181,11 @@ export default function AnalysisTab() {
onRefresh={ld.refreshReport} onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport} onSelectDrw={ld.loadSpecificReport}
/> />
</details>
{/* 통계 분석 */} {/* 통계 분석 */}
<details className="lotto-section-fold">
<summary>통계 분석</summary>
<section className="lotto-panel lotto-panel--wide"> <section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -237,8 +248,11 @@ export default function AnalysisTab() {
</p> </p>
)} )}
</section> </section>
</details>
{/* 전체 번호 분포 */} {/* 전체 번호 분포 */}
<details className="lotto-section-fold">
<summary>전체 회차 번호 분포</summary>
<section className="lotto-panel lotto-panel--wide"> <section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -263,11 +277,17 @@ export default function AnalysisTab() {
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p> <p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)} )}
</section> </section>
</details>
{/* 내 번호 패턴 */} {/* 내 번호 패턴 */}
<details className="lotto-section-fold">
<summary> 번호 패턴</summary>
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} /> <PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
</details>
{/* 수동 추천 */} {/* 수동 추천 */}
<details className="lotto-section-fold">
<summary>수동 추천</summary>
<section className="lotto-panel"> <section className="lotto-panel">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -365,8 +385,11 @@ export default function AnalysisTab() {
<p className="lotto-empty">아직 추천 결과가 없습니다.</p> <p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)} )}
</section> </section>
</details>
{/* 추천 히스토리 */} {/* 추천 히스토리 */}
<details className="lotto-section-fold">
<summary>추천 히스토리</summary>
<section className="lotto-panel"> <section className="lotto-panel">
<div className="lotto-panel__head"> <div className="lotto-panel__head">
<div> <div>
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
</div> </div>
)} )}
</section> </section>
</details>
</> </>
); );
} }

View File

@@ -1,25 +1,18 @@
import useBriefing from '../hooks/useBriefing'; import useBriefing from '../hooks/useBriefing';
import BriefingHeader from '../components/briefing/BriefingHeader'; import useReview from '../hooks/useReview';
import BriefingSummary from '../components/briefing/BriefingSummary'; import DecisionCard from '../components/decision/DecisionCard';
import PickSetCard from '../components/briefing/PickSetCard';
import BriefingEmpty from '../components/briefing/BriefingEmpty'; import BriefingEmpty from '../components/briefing/BriefingEmpty';
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
export default function BriefingTab() { export default function BriefingTab() {
const { briefing, loading, error, regenerating, regenerate } = useBriefing(); const { briefing, loading, error, regenerating, regenerate } = useBriefing();
const { latest: review } = useReview();
if (loading) return <div className="briefing-empty"><p>로딩 ...</p></div>; if (loading) return <div className="briefing-empty"><p>로딩 ...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />; if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return ( return (
<div className="briefing-tab"> <div className="briefing-tab">
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} /> <DecisionCard briefing={briefing} review={review} />
<BriefingSummary narrative={briefing.narrative} />
<div className="briefing-picks">
<h3>이번 5세트</h3>
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
</div>
<CuratorUsageFooter />
</div> </div>
); );
} }

View File

@@ -1,10 +1,13 @@
import usePurchases from '../hooks/usePurchases'; import usePurchases from '../hooks/usePurchases';
import PurchasePanel from '../components/PurchasePanel'; import PurchasePanel from '../components/PurchasePanel';
import PurchaseTrendChart from '../components/PurchaseTrendChart';
export default function PurchaseTab() { export default function PurchaseTab() {
const pur = usePurchases(); const pur = usePurchases();
return ( return (
<>
<PurchaseTrendChart />
<PurchasePanel <PurchasePanel
records={pur.purchases} records={pur.purchases}
stats={pur.purchaseStats} stats={pur.purchaseStats}
@@ -21,5 +24,6 @@ export default function PurchaseTab() {
onEditStart={pur.handlePurchaseEditStart} onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete} onDelete={pur.handlePurchaseDelete}
/> />
</>
); );
} }