- Phase A 백엔드 확장 (Task 1-5): fortune_scores + lucky + monthly_flow + DB 마이그레이션 + 응답 확장 - Phase B 캐릭터 자산 (Task 6): horyung.png + saju_color_sheet.png에서 6 PNG 추출 (PIL) - Phase C 프론트 구축 (Task 7-16): CSS 격리 + 컴포넌트 11개 + 3 페이지 + e2e 검증 - TDD + 빈번한 commit + 시안 1:1 매칭 목표 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2914 lines
90 KiB
Markdown
2914 lines
90 KiB
Markdown
# saju-lab UI v1 — 호령 사주 페이지 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:** 시안 기반 호령 사주 페이지 v1 (메인 + 사주풀이 + 오늘운세) 구축. 백엔드는 fortune_scores + lucky + monthly_flow 산출 추가.
|
|
|
|
**Architecture:** saju-lab 백엔드에 3 신규 calculator 모듈 + 응답 확장 + DB schema migration. web-ui에 호령 캐릭터 자산 + saju-page scope CSS + 3 페이지 컴포넌트 + 공통 컴포넌트. 데이터 흐름은 메인에서 1회 입력 → reading_id URL query로 다른 페이지 공유.
|
|
|
|
**Tech Stack:** Python 3.12 + FastAPI + SQLite (saju-lab 확장) / React 18 + Vite + React Router v6 + Pretendard + Noto Serif KR (web-ui). PIL for 캐릭터 PNG 추출.
|
|
|
|
**Reference spec:** `docs/superpowers/specs/2026-05-26-saju-ui-design.md`
|
|
|
|
---
|
|
|
|
## Phase A — 백엔드 확장 (saju-lab)
|
|
|
|
### Task 1: calculator/fortune_scores.py — 4 카테고리 점수
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/fortune_scores.py`
|
|
- Create: `saju-lab/tests/test_fortune_scores.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`saju-lab/tests/test_fortune_scores.py`:
|
|
|
|
```python
|
|
import json
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
from app.calculator.core import calculate_saju
|
|
from app.calculator.analysis import perform_full_analysis
|
|
from app.calculator.fortune_scores import calculate_fortune_scores
|
|
|
|
|
|
def _saju_for(year, month, day, hour, gender):
|
|
saju = calculate_saju(year, month, day, hour, gender)
|
|
analysis = perform_full_analysis(saju, 2026)
|
|
return saju, analysis
|
|
|
|
|
|
def test_scores_in_valid_range():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
result = calculate_fortune_scores(saju, analysis, 2026)
|
|
for key in ("wealth", "romance", "social", "career", "overall"):
|
|
assert key in result, f"missing key: {key}"
|
|
assert 0 <= result[key] <= 100, f"{key}={result[key]} out of range"
|
|
|
|
|
|
def test_overall_weighted_average():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_fortune_scores(saju, analysis, 2026)
|
|
expected = round(
|
|
r["wealth"] * 0.3 + r["career"] * 0.3 + r["romance"] * 0.2 + r["social"] * 0.2
|
|
)
|
|
assert abs(r["overall"] - expected) <= 1, f"overall mismatch: {r['overall']} vs {expected}"
|
|
|
|
|
|
def test_clamping_lower_bound():
|
|
"""매우 약한 입력 — score는 0 미만이 될 수 없음."""
|
|
saju, analysis = _saju_for(2000, 2, 29, 12, "male")
|
|
r = calculate_fortune_scores(saju, analysis, 2026)
|
|
assert all(v >= 0 for v in r.values())
|
|
|
|
|
|
def test_clamping_upper_bound():
|
|
"""매우 강한 입력 — score는 100 초과 될 수 없음."""
|
|
saju, analysis = _saju_for(1985, 1, 1, 0, "female")
|
|
r = calculate_fortune_scores(saju, analysis, 2026)
|
|
assert all(v <= 100 for v in r.values())
|
|
|
|
|
|
def test_different_inputs_different_scores():
|
|
"""다른 사주는 다른 점수 — degenerate to all-same-60 방지."""
|
|
s1, a1 = _saju_for(1990, 5, 15, 14, "male")
|
|
s2, a2 = _saju_for(1985, 1, 1, 0, "female")
|
|
r1 = calculate_fortune_scores(s1, a1, 2026)
|
|
r2 = calculate_fortune_scores(s2, a2, 2026)
|
|
assert r1 != r2, "scores should differ for different sajus"
|
|
|
|
|
|
def test_handles_missing_hour():
|
|
"""시간 미상 사주도 동작."""
|
|
saju, analysis = _saju_for(1990, 5, 15, None, "male")
|
|
r = calculate_fortune_scores(saju, analysis, 2026)
|
|
assert all(0 <= v <= 100 for v in r.values())
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify FAIL**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_fortune_scores.py -v`
|
|
Expected: FAIL with `ModuleNotFoundError: app.calculator.fortune_scores`
|
|
|
|
- [ ] **Step 3: 구현 작성**
|
|
|
|
`saju-lab/app/calculator/fortune_scores.py`:
|
|
|
|
```python
|
|
"""4 카테고리 점수 — 재물/연애/인간관계/직장 + 종합점."""
|
|
from typing import Dict
|
|
|
|
from .constants import (
|
|
FIVE_ELEMENTS, IS_YANG_STEM, SHENG_CYCLE, KE_CYCLE,
|
|
)
|
|
|
|
|
|
def _ten_god_counts(saju: dict) -> Dict[str, int]:
|
|
"""4기둥의 ten_god 카운트."""
|
|
counts: Dict[str, int] = {}
|
|
for p in ("year", "month", "day", "hour"):
|
|
pillar = saju.get(p)
|
|
if not pillar:
|
|
continue
|
|
tg = pillar.get("ten_god", "")
|
|
counts[tg] = counts.get(tg, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _branch_has_chong(saju: dict) -> bool:
|
|
"""일지가 다른 기둥과 6충 관계인지."""
|
|
LIU_CHONG = {
|
|
frozenset(["子", "午"]), frozenset(["丑", "未"]),
|
|
frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
|
|
frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
|
|
}
|
|
day_branch = saju["day"]["branch"]
|
|
for p in ("year", "month", "hour"):
|
|
pillar = saju.get(p)
|
|
if not pillar:
|
|
continue
|
|
if frozenset([day_branch, pillar["branch"]]) in LIU_CHONG:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _branch_has_he(saju: dict) -> bool:
|
|
"""일지가 다른 기둥과 6합 관계인지."""
|
|
LIU_HE = {
|
|
frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
|
|
frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
|
|
frozenset(["巳", "申"]), frozenset(["午", "未"]),
|
|
}
|
|
day_branch = saju["day"]["branch"]
|
|
for p in ("year", "month", "hour"):
|
|
pillar = saju.get(p)
|
|
if not pillar:
|
|
continue
|
|
if frozenset([day_branch, pillar["branch"]]) in LIU_HE:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _clamp(v: int) -> int:
|
|
return max(0, min(100, v))
|
|
|
|
|
|
def calculate_fortune_scores(saju: dict, analysis: dict, current_year: int) -> Dict[str, int]:
|
|
"""4 카테고리 + overall (가중평균)."""
|
|
counts = _ten_god_counts(saju)
|
|
has_chong = _branch_has_chong(saju)
|
|
has_he = _branch_has_he(saju)
|
|
|
|
strength = analysis.get("day_master_strength", {}).get("result", "중화")
|
|
|
|
# 재물운: 정재/편재 강도
|
|
wealth = 60
|
|
wealth += counts.get("정재", 0) * 8
|
|
wealth += counts.get("편재", 0) * 6
|
|
wealth += counts.get("식신", 0) * 4 # 식상생재
|
|
wealth -= counts.get("비견", 0) * 5
|
|
wealth -= counts.get("겁재", 0) * 5
|
|
wealth = _clamp(wealth)
|
|
|
|
# 연애운: 일지 합/충 + 정관/정재
|
|
romance = 60
|
|
if has_he:
|
|
romance += 15
|
|
if has_chong:
|
|
romance -= 15
|
|
romance += counts.get("정관", 0) * 5
|
|
romance += counts.get("정재", 0) * 5
|
|
romance = _clamp(romance)
|
|
|
|
# 인간관계: 인성 + 비겁 적정 + 식상
|
|
social = 60
|
|
social += counts.get("정인", 0) * 5
|
|
social += counts.get("편인", 0) * 4
|
|
social += min(counts.get("비견", 0), 2) * 5 # 적정선까지만
|
|
social += counts.get("식신", 0) * 3
|
|
social += counts.get("상관", 0) * 3
|
|
social = _clamp(social)
|
|
|
|
# 직장운: 정관 + 편관(제어) + 일간 강도
|
|
career = 60
|
|
career += counts.get("정관", 0) * 10
|
|
career += counts.get("편관", 0) * 5
|
|
if strength == "신강":
|
|
career += 8
|
|
elif strength == "신약":
|
|
career -= 5
|
|
career = _clamp(career)
|
|
|
|
overall = round(wealth * 0.3 + career * 0.3 + romance * 0.2 + social * 0.2)
|
|
overall = _clamp(overall)
|
|
|
|
return {
|
|
"wealth": wealth,
|
|
"romance": romance,
|
|
"social": social,
|
|
"career": career,
|
|
"overall": overall,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify PASS**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_fortune_scores.py -v`
|
|
Expected: 6 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/fortune_scores.py saju-lab/tests/test_fortune_scores.py
|
|
git commit -m "feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: calculator/lucky.py — 럭키 데이터
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/lucky.py`
|
|
- Create: `saju-lab/tests/test_lucky.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`saju-lab/tests/test_lucky.py`:
|
|
|
|
```python
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from app.calculator.core import calculate_saju
|
|
from app.calculator.analysis import perform_full_analysis
|
|
from app.calculator.lucky import calculate_lucky
|
|
|
|
|
|
def _saju_for(year, month, day, hour, gender):
|
|
saju = calculate_saju(year, month, day, hour, gender)
|
|
analysis = perform_full_analysis(saju, 2026)
|
|
return saju, analysis
|
|
|
|
|
|
def test_lucky_keys():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
for k in ("color", "number", "direction", "good_signs", "warnings"):
|
|
assert k in r, f"missing {k}"
|
|
|
|
|
|
def test_lucky_number_range():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
assert 1 <= r["number"] <= 9
|
|
|
|
|
|
def test_lucky_color_from_yongshin():
|
|
"""용신 오행이 적용된 컬러."""
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
assert isinstance(r["color"], list)
|
|
assert len(r["color"]) >= 1
|
|
yongshin = analysis["yong_shin"]["yong_shin"]
|
|
valid_colors_by_element = {
|
|
"木": {"청록", "녹색"},
|
|
"火": {"빨강", "주황"},
|
|
"土": {"황색", "베이지"},
|
|
"金": {"흰색", "은색"},
|
|
"水": {"파랑", "검정"},
|
|
}
|
|
expected_set = valid_colors_by_element[yongshin]
|
|
assert all(c in expected_set for c in r["color"])
|
|
|
|
|
|
def test_lucky_direction_from_yongshin():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
valid_dirs = {"동쪽", "남쪽", "중앙", "서쪽", "북쪽"}
|
|
assert r["direction"] in valid_dirs
|
|
|
|
|
|
def test_good_signs_and_warnings_are_lists():
|
|
saju, analysis = _saju_for(1990, 5, 15, 14, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
assert isinstance(r["good_signs"], list)
|
|
assert isinstance(r["warnings"], list)
|
|
|
|
|
|
def test_handles_missing_hour():
|
|
saju, analysis = _saju_for(1990, 5, 15, None, "male")
|
|
r = calculate_lucky(saju, analysis, date(2026, 5, 26))
|
|
assert 1 <= r["number"] <= 9
|
|
```
|
|
|
|
- [ ] **Step 2: Run, expect FAIL**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_lucky.py -v`
|
|
Expected: FAIL — ModuleNotFoundError
|
|
|
|
- [ ] **Step 3: 구현**
|
|
|
|
`saju-lab/app/calculator/lucky.py`:
|
|
|
|
```python
|
|
"""오늘의 럭키 컬러/숫자/방향 + 행운/위험 알림."""
|
|
from datetime import date as _date
|
|
from typing import Dict, List
|
|
|
|
import sxtwl
|
|
|
|
from .constants import HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS
|
|
|
|
|
|
LUCKY_COLOR_BY_ELEMENT: Dict[str, List[str]] = {
|
|
"木": ["청록", "녹색"],
|
|
"火": ["빨강", "주황"],
|
|
"土": ["황색", "베이지"],
|
|
"金": ["흰색", "은색"],
|
|
"水": ["파랑", "검정"],
|
|
}
|
|
|
|
LUCKY_DIRECTION_BY_ELEMENT: Dict[str, str] = {
|
|
"木": "동쪽",
|
|
"火": "남쪽",
|
|
"土": "중앙",
|
|
"金": "서쪽",
|
|
"水": "북쪽",
|
|
}
|
|
|
|
|
|
def _day_stem_branch_idx(target: _date) -> tuple[int, int]:
|
|
"""양력 → sxtwl 일주 천간/지지 인덱스."""
|
|
day_obj = sxtwl.fromSolar(target.year, target.month, target.day)
|
|
try:
|
|
gz = day_obj.getDayGZ()
|
|
return gz.tg, gz.dz
|
|
except AttributeError:
|
|
return day_obj.getDayTG(), day_obj.getDayDZ()
|
|
|
|
|
|
def calculate_lucky(saju: dict, analysis: dict, target_date: _date) -> dict:
|
|
"""오늘의 럭키 정보."""
|
|
yongshin = analysis["yong_shin"]["yong_shin"]
|
|
color = LUCKY_COLOR_BY_ELEMENT.get(yongshin, ["흰색"])
|
|
direction = LUCKY_DIRECTION_BY_ELEMENT.get(yongshin, "중앙")
|
|
|
|
# 럭키 숫자: 오늘 일진 천간+지지 인덱스 합 % 9 + 1 → 1~9
|
|
day_stem_idx, day_branch_idx = _day_stem_branch_idx(target_date)
|
|
number = (day_stem_idx + day_branch_idx) % 9 + 1
|
|
|
|
# 행운/위험 신호
|
|
good_signs: List[str] = []
|
|
warnings: List[str] = []
|
|
|
|
today_stem = HEAVENLY_STEMS[day_stem_idx]
|
|
today_branch = EARTHLY_BRANCHES[day_branch_idx]
|
|
day_master = saju["day_stem"]
|
|
|
|
# 오늘 천간이 일간의 재성(剋받는)이면 재물 기회
|
|
day_elem = FIVE_ELEMENTS[day_master]
|
|
today_elem = FIVE_ELEMENTS[today_stem]
|
|
from .constants import KE_CYCLE
|
|
if KE_CYCLE.get(day_elem) == today_elem:
|
|
good_signs.append("재물 기회가 다가옵니다")
|
|
if KE_CYCLE.get(today_elem) == day_elem:
|
|
warnings.append("강한 압박이 있을 수 있어요")
|
|
|
|
# 오늘 지지가 일지와 6충이면 경고
|
|
LIU_CHONG = {
|
|
frozenset(["子", "午"]), frozenset(["丑", "未"]),
|
|
frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
|
|
frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
|
|
}
|
|
day_branch = saju["day"]["branch"]
|
|
if frozenset([day_branch, today_branch]) in LIU_CHONG:
|
|
warnings.append("대인 갈등에 주의하세요")
|
|
|
|
# 오늘 지지가 일지와 6합이면 행운
|
|
LIU_HE = {
|
|
frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
|
|
frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
|
|
frozenset(["巳", "申"]), frozenset(["午", "未"]),
|
|
}
|
|
if frozenset([day_branch, today_branch]) in LIU_HE:
|
|
good_signs.append("좋은 인연을 만날 수 있어요")
|
|
|
|
return {
|
|
"color": color,
|
|
"number": number,
|
|
"direction": direction,
|
|
"good_signs": good_signs,
|
|
"warnings": warnings,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, PASS**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_lucky.py -v`
|
|
Expected: 6 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/lucky.py saju-lab/tests/test_lucky.py
|
|
git commit -m "feat(saju-lab): lucky.py — 럭키 컬러/숫자/방향 + 행운/위험 알림 (6 tests)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: calculator/monthly_flow.py — 12개월 운세 흐름
|
|
|
|
**Files:**
|
|
- Create: `saju-lab/app/calculator/monthly_flow.py`
|
|
- Create: `saju-lab/tests/test_monthly_flow.py`
|
|
|
|
- [ ] **Step 1: 테스트**
|
|
|
|
`saju-lab/tests/test_monthly_flow.py`:
|
|
|
|
```python
|
|
import pytest
|
|
|
|
from app.calculator.core import calculate_saju
|
|
from app.calculator.monthly_flow import calculate_monthly_flow
|
|
|
|
|
|
def test_returns_12_entries():
|
|
saju = calculate_saju(1990, 5, 15, 14, "male")
|
|
flow = calculate_monthly_flow(saju, 2026)
|
|
assert len(flow) == 12
|
|
|
|
|
|
def test_entries_have_required_keys():
|
|
saju = calculate_saju(1990, 5, 15, 14, "male")
|
|
flow = calculate_monthly_flow(saju, 2026)
|
|
for i, entry in enumerate(flow):
|
|
assert entry["month"] == i + 1
|
|
for k in ("stem", "branch", "score", "label"):
|
|
assert k in entry, f"month {i+1} missing {k}"
|
|
assert 0 <= entry["score"] <= 100
|
|
|
|
|
|
def test_labels_are_valid():
|
|
saju = calculate_saju(1990, 5, 15, 14, "male")
|
|
flow = calculate_monthly_flow(saju, 2026)
|
|
valid_labels = {"변동", "성장", "안정", "도전", "정체"}
|
|
for entry in flow:
|
|
assert entry["label"] in valid_labels
|
|
|
|
|
|
def test_different_sajus_different_flows():
|
|
s1 = calculate_saju(1990, 5, 15, 14, "male")
|
|
s2 = calculate_saju(1985, 1, 1, 0, "female")
|
|
f1 = calculate_monthly_flow(s1, 2026)
|
|
f2 = calculate_monthly_flow(s2, 2026)
|
|
scores_1 = [e["score"] for e in f1]
|
|
scores_2 = [e["score"] for e in f2]
|
|
assert scores_1 != scores_2
|
|
```
|
|
|
|
- [ ] **Step 2: Run, FAIL**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_monthly_flow.py -v`
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: 구현**
|
|
|
|
`saju-lab/app/calculator/monthly_flow.py`:
|
|
|
|
```python
|
|
"""12개월 운세 흐름 — 월운(月運) + 일간 관계."""
|
|
from typing import List
|
|
|
|
from .constants import (
|
|
HEAVENLY_STEMS, EARTHLY_BRANCHES,
|
|
FIVE_ELEMENTS, SHENG_CYCLE, KE_CYCLE,
|
|
)
|
|
|
|
|
|
# 寅월부터 시작하는 월지 순서 (1월=寅, 2월=卯, ..., 12월=丑)
|
|
MONTH_BRANCHES = ["寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", "子", "丑"]
|
|
|
|
# 6충/6합
|
|
LIU_CHONG = {
|
|
frozenset(["子", "午"]), frozenset(["丑", "未"]),
|
|
frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
|
|
frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
|
|
}
|
|
LIU_HE = {
|
|
frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
|
|
frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
|
|
frozenset(["巳", "申"]), frozenset(["午", "未"]),
|
|
}
|
|
|
|
|
|
def _month_stem_for_year(year_stem: str, branch_idx: int) -> str:
|
|
"""월간(月干) — 五虎遁訣 아닌 saju-web의 단순 공식: (yearStemIdx * 2 + branchIdx) % 10."""
|
|
year_stem_idx = HEAVENLY_STEMS.index(year_stem)
|
|
stem_idx = (year_stem_idx * 2 + branch_idx) % 10
|
|
return HEAVENLY_STEMS[stem_idx]
|
|
|
|
|
|
def _label_for(score: int) -> str:
|
|
"""점수 → 라벨."""
|
|
if score >= 80:
|
|
return "성장"
|
|
if score >= 65:
|
|
return "안정"
|
|
if score >= 50:
|
|
return "변동"
|
|
if score >= 35:
|
|
return "도전"
|
|
return "정체"
|
|
|
|
|
|
def calculate_monthly_flow(saju: dict, year: int) -> List[dict]:
|
|
"""12개월 운세 흐름.
|
|
|
|
각 월: 월간/월지 + 일간과의 관계로 score 산출.
|
|
"""
|
|
day_stem = saju["day_stem"]
|
|
day_element = FIVE_ELEMENTS[day_stem]
|
|
day_branch = saju["day"]["branch"]
|
|
year_stem = saju["year"]["stem"]
|
|
|
|
flow: List[dict] = []
|
|
for i, branch in enumerate(MONTH_BRANCHES):
|
|
branch_idx = EARTHLY_BRANCHES.index(branch)
|
|
stem = _month_stem_for_year(year_stem, branch_idx)
|
|
stem_element = FIVE_ELEMENTS[stem]
|
|
branch_element = FIVE_ELEMENTS[branch]
|
|
|
|
score = 60
|
|
|
|
# 천간 관계: 상생/상극
|
|
if SHENG_CYCLE.get(day_element) == stem_element:
|
|
score += 5 # 일간이 월간 생함 (식상)
|
|
elif SHENG_CYCLE.get(stem_element) == day_element:
|
|
score += 10 # 월간이 일간 생함 (인성)
|
|
elif KE_CYCLE.get(day_element) == stem_element:
|
|
score += 8 # 일간이 월간 극함 (재성)
|
|
elif KE_CYCLE.get(stem_element) == day_element:
|
|
score -= 8 # 월간이 일간 극함 (관성, 부정적)
|
|
elif stem_element == day_element:
|
|
score += 3 # 비겁
|
|
|
|
# 지지 관계: 합/충
|
|
if frozenset([day_branch, branch]) in LIU_HE:
|
|
score += 10
|
|
elif frozenset([day_branch, branch]) in LIU_CHONG:
|
|
score -= 12
|
|
|
|
# 지지 오행 관계
|
|
if SHENG_CYCLE.get(branch_element) == day_element:
|
|
score += 4
|
|
elif KE_CYCLE.get(branch_element) == day_element:
|
|
score -= 4
|
|
|
|
score = max(0, min(100, score))
|
|
|
|
flow.append({
|
|
"month": i + 1,
|
|
"stem": stem,
|
|
"branch": branch,
|
|
"score": score,
|
|
"label": _label_for(score),
|
|
})
|
|
|
|
return flow
|
|
```
|
|
|
|
- [ ] **Step 4: Run, PASS**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_monthly_flow.py -v`
|
|
Expected: 4 passed
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/calculator/monthly_flow.py saju-lab/tests/test_monthly_flow.py
|
|
git commit -m "feat(saju-lab): monthly_flow.py — 12개월 운세 흐름 (4 tests)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: db.py — saju_records 3 컬럼 추가 (ALTER TABLE)
|
|
|
|
**Files:**
|
|
- Modify: `saju-lab/app/db.py`
|
|
- Create: `saju-lab/tests/test_db_migration.py`
|
|
|
|
- [ ] **Step 1: 마이그레이션 테스트**
|
|
|
|
`saju-lab/tests/test_db_migration.py`:
|
|
|
|
```python
|
|
import sqlite3
|
|
import pytest
|
|
|
|
from app import db as db_module
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(monkeypatch, tmp_path):
|
|
db_file = tmp_path / "test_saju.db"
|
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
|
yield
|
|
try:
|
|
if db_file.exists():
|
|
db_file.unlink()
|
|
except PermissionError:
|
|
pass
|
|
|
|
|
|
def test_new_columns_exist():
|
|
"""init_db 후 3 신규 컬럼이 존재."""
|
|
db_module.init_db()
|
|
conn = sqlite3.connect(db_module.DB_PATH)
|
|
cols = [row[1] for row in conn.execute("PRAGMA table_info(saju_records)").fetchall()]
|
|
conn.close()
|
|
assert "fortune_scores_json" in cols
|
|
assert "lucky_json" in cols
|
|
assert "monthly_flow_json" in cols
|
|
|
|
|
|
def test_idempotent_init():
|
|
"""init_db를 두 번 호출해도 에러 없음."""
|
|
db_module.init_db()
|
|
db_module.init_db() # 두 번째 호출 — ALTER TABLE이 OperationalError 캐치
|
|
|
|
|
|
def test_save_and_get_with_new_fields():
|
|
"""새 필드 포함 저장 + 조회."""
|
|
db_module.init_db()
|
|
rid = db_module.save_saju_record({
|
|
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": 14,
|
|
"gender": "male", "calendar_type": "solar",
|
|
"saju_data": {"day_stem": "辛"},
|
|
"analysis_data": {"element_balance": {"金": 3.0}},
|
|
"daeun_data": [{"age": 10}],
|
|
"interpretation_json": {"items": []},
|
|
"model": "claude-sonnet-4-6",
|
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
|
"fortune_scores_json": {"wealth": 80, "romance": 60, "social": 70, "career": 75, "overall": 73},
|
|
"lucky_json": {"color": ["청록"], "number": 5, "direction": "동쪽", "good_signs": ["S1"], "warnings": []},
|
|
"monthly_flow_json": [{"month": 1, "stem": "壬", "branch": "寅", "score": 65, "label": "변동"}],
|
|
})
|
|
row = db_module.get_saju_record(rid)
|
|
assert row["fortune_scores"]["wealth"] == 80
|
|
assert row["lucky"]["number"] == 5
|
|
assert row["monthly_flow"][0]["month"] == 1
|
|
|
|
|
|
def test_save_without_new_fields_backwards_compat():
|
|
"""기존 호출 패턴 (새 필드 없이) 도 동작 (NULL 저장)."""
|
|
db_module.init_db()
|
|
rid = db_module.save_saju_record({
|
|
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
|
|
"gender": "male",
|
|
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
|
|
"model": "x",
|
|
})
|
|
row = db_module.get_saju_record(rid)
|
|
assert row["fortune_scores"] is None
|
|
assert row["lucky"] is None
|
|
assert row["monthly_flow"] is None
|
|
```
|
|
|
|
- [ ] **Step 2: Run, expect FAIL**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_db_migration.py -v`
|
|
Expected: FAIL — column doesn't exist
|
|
|
|
- [ ] **Step 3: db.py 수정**
|
|
|
|
`saju-lab/app/db.py`의 `init_db()` 함수 끝(L70 직전)에 ALTER TABLE 추가:
|
|
|
|
```python
|
|
def init_db() -> None:
|
|
with _conn() as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS saju_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
birth_year INTEGER NOT NULL,
|
|
birth_month INTEGER NOT NULL,
|
|
birth_day INTEGER NOT NULL,
|
|
birth_hour INTEGER,
|
|
gender TEXT NOT NULL,
|
|
calendar_type TEXT DEFAULT 'solar',
|
|
saju_data TEXT NOT NULL,
|
|
analysis_data TEXT NOT NULL,
|
|
daeun_data TEXT NOT NULL,
|
|
interpretation_json TEXT,
|
|
model TEXT,
|
|
tokens_in INTEGER DEFAULT 0,
|
|
tokens_out INTEGER DEFAULT 0,
|
|
cost_usd REAL DEFAULT 0,
|
|
latency_ms INTEGER DEFAULT 0,
|
|
reroll_count INTEGER DEFAULT 0,
|
|
favorite INTEGER DEFAULT 0,
|
|
memo TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_saju_created
|
|
ON saju_records(created_at DESC)
|
|
""")
|
|
# 신규 컬럼 ALTER (idempotent — 이미 있으면 OperationalError로 skip)
|
|
for col in (
|
|
"fortune_scores_json TEXT",
|
|
"lucky_json TEXT",
|
|
"monthly_flow_json TEXT",
|
|
):
|
|
try:
|
|
conn.execute(f"ALTER TABLE saju_records ADD COLUMN {col}")
|
|
except sqlite3.OperationalError:
|
|
pass # already exists
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS compat_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
person_a TEXT NOT NULL,
|
|
person_b TEXT NOT NULL,
|
|
saju_a TEXT NOT NULL,
|
|
saju_b TEXT NOT NULL,
|
|
score INTEGER NOT NULL,
|
|
breakdown TEXT NOT NULL,
|
|
interpretation_json TEXT,
|
|
model TEXT,
|
|
tokens_in INTEGER DEFAULT 0,
|
|
tokens_out INTEGER DEFAULT 0,
|
|
cost_usd REAL DEFAULT 0,
|
|
latency_ms INTEGER DEFAULT 0,
|
|
reroll_count INTEGER DEFAULT 0,
|
|
favorite INTEGER DEFAULT 0,
|
|
memo TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
""")
|
|
```
|
|
|
|
그리고 `save_saju_record`를 신규 컬럼 저장하도록 수정:
|
|
|
|
```python
|
|
def save_saju_record(data: Dict[str, Any]) -> int:
|
|
with _conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO saju_records
|
|
(birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
|
|
saju_data, analysis_data, daeun_data, interpretation_json,
|
|
model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count,
|
|
fortune_scores_json, lucky_json, monthly_flow_json)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(
|
|
data["birth_year"], data["birth_month"], data["birth_day"],
|
|
data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
|
|
json.dumps(data["saju_data"], ensure_ascii=False),
|
|
json.dumps(data["analysis_data"], ensure_ascii=False),
|
|
json.dumps(data["daeun_data"], ensure_ascii=False),
|
|
json.dumps(data.get("interpretation_json"), ensure_ascii=False) if data.get("interpretation_json") else None,
|
|
data.get("model"),
|
|
data.get("tokens_in", 0), data.get("tokens_out", 0),
|
|
data.get("cost_usd", 0.0), data.get("latency_ms", 0),
|
|
data.get("reroll_count", 0),
|
|
json.dumps(data["fortune_scores_json"], ensure_ascii=False) if data.get("fortune_scores_json") else None,
|
|
json.dumps(data["lucky_json"], ensure_ascii=False) if data.get("lucky_json") else None,
|
|
json.dumps(data["monthly_flow_json"], ensure_ascii=False) if data.get("monthly_flow_json") else None,
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
```
|
|
|
|
그리고 `_saju_row_to_dict`에 3 컬럼 응답 추가:
|
|
|
|
```python
|
|
def _saju_row_to_dict(r) -> Dict[str, Any]:
|
|
def _safe_json(val):
|
|
if val is None:
|
|
return None
|
|
try:
|
|
return json.loads(val)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
return {
|
|
"id": r["id"],
|
|
"created_at": r["created_at"],
|
|
"birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
|
|
"birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
|
|
"saju_data": _safe_json(r["saju_data"]),
|
|
"analysis_data": _safe_json(r["analysis_data"]),
|
|
"daeun_data": _safe_json(r["daeun_data"]),
|
|
"interpretation_json": _safe_json(r["interpretation_json"]),
|
|
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
|
|
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
|
|
"favorite": int(r["favorite"]), "memo": r["memo"],
|
|
# 신규
|
|
"fortune_scores": _safe_json(r["fortune_scores_json"]) if "fortune_scores_json" in r.keys() else None,
|
|
"lucky": _safe_json(r["lucky_json"]) if "lucky_json" in r.keys() else None,
|
|
"monthly_flow": _safe_json(r["monthly_flow_json"]) if "monthly_flow_json" in r.keys() else None,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run all db tests, PASS**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_db.py tests/test_db_migration.py -v`
|
|
Expected: 10 (기존 test_db.py) + 4 (test_db_migration.py) = 14 passed
|
|
|
|
만약 기존 `test_db.py`가 실패하면 (row.keys() 검사 등 행동 변화로 인해), 기존 테스트 확인 후 호환 유지.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/db.py saju-lab/tests/test_db_migration.py
|
|
git commit -m "feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: routers/saju.py + models.py — 응답 확장
|
|
|
|
**Files:**
|
|
- Modify: `saju-lab/app/models.py`
|
|
- Modify: `saju-lab/app/routers/saju.py`
|
|
- Modify: `saju-lab/tests/test_routes.py`
|
|
|
|
- [ ] **Step 1: models.py에 3 필드 추가**
|
|
|
|
`saju-lab/app/models.py`의 `SajuInterpretResponse` 클래스 수정:
|
|
|
|
```python
|
|
class SajuInterpretResponse(BaseModel):
|
|
saju: dict
|
|
analysis: dict
|
|
daeun: List[dict]
|
|
interpretation_json: dict
|
|
reading_id: int
|
|
model: str
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
latency_ms: int
|
|
reroll_count: int = 0
|
|
fortune_scores: dict
|
|
lucky: dict
|
|
monthly_flow: List[dict]
|
|
```
|
|
|
|
- [ ] **Step 2: routers/saju.py 수정**
|
|
|
|
`saju-lab/app/routers/saju.py`의 `interpret_saju_endpoint` 함수에 계산 + 저장 + 응답 추가. 기존 코드를 다음으로 교체:
|
|
|
|
```python
|
|
@router.post("/interpret", response_model=SajuInterpretResponse)
|
|
async def interpret_saju_endpoint(req: SajuInterpretRequest):
|
|
"""사주 입력 → 계산 + AI 해석 + DB 저장."""
|
|
from datetime import date
|
|
|
|
# 음력 입력 시 양력 변환
|
|
if req.calendar_type == "lunar":
|
|
sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month)
|
|
else:
|
|
sy, sm, sd = req.year, req.month, req.day
|
|
|
|
try:
|
|
saju = calculate_saju(sy, sm, sd, req.hour, req.gender)
|
|
analysis = perform_full_analysis(saju, 2026)
|
|
daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"])
|
|
# 신규 — 4 카테고리 + 럭키 + 월운
|
|
from ..calculator.fortune_scores import calculate_fortune_scores
|
|
from ..calculator.lucky import calculate_lucky
|
|
from ..calculator.monthly_flow import calculate_monthly_flow
|
|
fortune_scores = calculate_fortune_scores(saju, analysis, 2026)
|
|
lucky = calculate_lucky(saju, analysis, date.today())
|
|
monthly_flow = calculate_monthly_flow(saju, 2026)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
|
|
|
|
try:
|
|
interp_result = await pipeline.interpret_saju(saju, analysis, daeun, 2026)
|
|
except pipeline.SajuError as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
rid = db_module.save_saju_record({
|
|
"birth_year": req.year, "birth_month": req.month, "birth_day": req.day,
|
|
"birth_hour": req.hour, "gender": req.gender,
|
|
"calendar_type": req.calendar_type,
|
|
"saju_data": saju,
|
|
"analysis_data": analysis,
|
|
"daeun_data": daeun,
|
|
"interpretation_json": interp_result["interpretation_json"],
|
|
"model": interp_result["model"],
|
|
"tokens_in": interp_result["tokens_in"],
|
|
"tokens_out": interp_result["tokens_out"],
|
|
"cost_usd": interp_result["cost_usd"],
|
|
"latency_ms": interp_result["latency_ms"],
|
|
"reroll_count": interp_result["reroll_count"],
|
|
"fortune_scores_json": fortune_scores,
|
|
"lucky_json": lucky,
|
|
"monthly_flow_json": monthly_flow,
|
|
})
|
|
|
|
return {
|
|
"saju": saju,
|
|
"analysis": analysis,
|
|
"daeun": daeun,
|
|
"interpretation_json": interp_result["interpretation_json"],
|
|
"reading_id": rid,
|
|
"model": interp_result["model"],
|
|
"tokens_in": interp_result["tokens_in"],
|
|
"tokens_out": interp_result["tokens_out"],
|
|
"cost_usd": interp_result["cost_usd"],
|
|
"latency_ms": interp_result["latency_ms"],
|
|
"reroll_count": interp_result["reroll_count"],
|
|
"fortune_scores": fortune_scores,
|
|
"lucky": lucky,
|
|
"monthly_flow": monthly_flow,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: test_routes.py 수정 — 응답에 새 필드 검증 추가**
|
|
|
|
기존 `test_saju_interpret_endpoint` 테스트의 assert 부분 강화:
|
|
|
|
```python
|
|
def test_saju_interpret_endpoint(monkeypatch):
|
|
"""saju interpret이 pipeline mock으로 동작 + 신규 필드 검증."""
|
|
async def fake_interpret(*args, **kwargs):
|
|
return _interpret_result()
|
|
|
|
from app.routers import saju as saju_router
|
|
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
|
|
|
|
with TestClient(app) as c:
|
|
r = c.post("/api/saju/interpret", json={
|
|
"year": 1990, "month": 5, "day": 15, "hour": 14,
|
|
"gender": "male", "calendar_type": "solar"
|
|
})
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert "saju" in data
|
|
assert "analysis" in data
|
|
assert "daeun" in data
|
|
assert "reading_id" in data
|
|
assert data["reading_id"] > 0
|
|
# 신규 필드
|
|
assert "fortune_scores" in data
|
|
for k in ("wealth", "romance", "social", "career", "overall"):
|
|
assert k in data["fortune_scores"]
|
|
assert 0 <= data["fortune_scores"][k] <= 100
|
|
assert "lucky" in data
|
|
for k in ("color", "number", "direction", "good_signs", "warnings"):
|
|
assert k in data["lucky"]
|
|
assert "monthly_flow" in data
|
|
assert len(data["monthly_flow"]) == 12
|
|
```
|
|
|
|
- [ ] **Step 4: Run all route tests**
|
|
|
|
Run: `cd saju-lab && python -m pytest tests/test_routes.py -v`
|
|
Expected: 10 passed (기존 10개, 그중 1개 강화)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add saju-lab/app/models.py saju-lab/app/routers/saju.py saju-lab/tests/test_routes.py
|
|
git commit -m "feat(saju-lab): /interpret 응답에 fortune_scores + lucky + monthly_flow 포함"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase B — 캐릭터 자산 추출
|
|
|
|
### Task 6: 호령 6 PNG 추출 (Python PIL)
|
|
|
|
**Files:**
|
|
- Create: `scripts/extract_horyung.py` (web-backend 또는 web-ui 어디든)
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-front.png`
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-bust.png`
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-greeting.png`
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-thinking.png`
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-pointing.png`
|
|
- Create: `web-ui/public/images/saju/horyung/horyung-happy.png`
|
|
|
|
- [ ] **Step 1: 소스 이미지 크기 확인**
|
|
|
|
Run:
|
|
```bash
|
|
python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/characters/horyung.png'); print(im.size)"
|
|
python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/images/saju_page/saju_color_sheet.png'); print(im.size)"
|
|
```
|
|
|
|
기록한 두 이미지 크기를 다음 step에 사용.
|
|
|
|
- [ ] **Step 2: 추출 스크립트 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ui/scripts/extract_horyung.py`:
|
|
|
|
```python
|
|
"""호령 캐릭터 PNG 추출 — horyung.png(3 view) + saju_color_sheet.png(8 emotion).
|
|
|
|
Usage:
|
|
cd web-ui
|
|
python scripts/extract_horyung.py
|
|
"""
|
|
import os
|
|
from PIL import Image
|
|
|
|
SOURCE_ROOT = "../source"
|
|
HORYUNG_PATH = f"{SOURCE_ROOT}/characters/horyung.png"
|
|
COLORSHEET_PATH = f"{SOURCE_ROOT}/images/saju_page/saju_color_sheet.png"
|
|
|
|
OUT_DIR = "public/images/saju/horyung"
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
|
|
def crop_save(src_path, box, out_name):
|
|
"""src에서 box=(x1,y1,x2,y2) 영역 crop → out_name으로 저장."""
|
|
im = Image.open(src_path).convert("RGBA")
|
|
cropped = im.crop(box)
|
|
cropped.save(f"{OUT_DIR}/{out_name}")
|
|
print(f"saved {out_name} ({cropped.size})")
|
|
|
|
|
|
# horyung.png — 3 view 레이아웃 (실제 크기에 따라 좌표 조정 필요).
|
|
# Step 1에서 측정한 실제 크기로 다음 비율 계산:
|
|
# 시안 horyung.png는 대략 1200x1700 (세로형). 3 view는 세로로 분할:
|
|
# - bust shot: 상단 1/3
|
|
# - back view: 중단 1/3
|
|
# - front view: 하단 1/3 (메인 hero용)
|
|
|
|
# 다음은 1200x1700 기준 예시 좌표 — 실제 크기에 맞춰 조정:
|
|
crop_save(HORYUNG_PATH, (300, 30, 900, 570), "horyung-bust.png") # bust shot
|
|
crop_save(HORYUNG_PATH, (250, 1130, 950, 1690), "horyung-front.png") # front view (메인 hero)
|
|
|
|
# saju_color_sheet.png — 우측 하단 캐릭터 감정 스티커 8개 (가로 4 x 세로 2)
|
|
# 시안 컬러시트는 대략 1600x1100. 8 emotion sticker는 우측 하단 영역 (대략 x=1100~1600, y=850~1100)
|
|
# 각 sticker는 약 125x125
|
|
EMOTION_BASE_X = 1100
|
|
EMOTION_BASE_Y = 850
|
|
EMOTION_W = 125
|
|
EMOTION_H = 125
|
|
|
|
# 4 emotion만 추출 (8 중 가장 유용한 4개)
|
|
crop_save(COLORSHEET_PATH,
|
|
(EMOTION_BASE_X, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y + EMOTION_H),
|
|
"horyung-greeting.png")
|
|
crop_save(COLORSHEET_PATH,
|
|
(EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y + EMOTION_H),
|
|
"horyung-thinking.png")
|
|
crop_save(COLORSHEET_PATH,
|
|
(EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y + EMOTION_H),
|
|
"horyung-pointing.png")
|
|
crop_save(COLORSHEET_PATH,
|
|
(EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*4, EMOTION_BASE_Y + EMOTION_H),
|
|
"horyung-happy.png")
|
|
|
|
print("Done.")
|
|
```
|
|
|
|
- [ ] **Step 3: 실행 + 결과 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
pip install Pillow # 이미 있으면 skip
|
|
python scripts/extract_horyung.py
|
|
ls public/images/saju/horyung/
|
|
```
|
|
|
|
Expected: 6 PNG 파일 생성 (`horyung-front.png`, `horyung-bust.png`, `horyung-greeting.png`, `horyung-thinking.png`, `horyung-pointing.png`, `horyung-happy.png`)
|
|
|
|
- [ ] **Step 4: 시각 검증**
|
|
|
|
각 PNG를 image viewer로 열어 확인. 좌표가 부정확하면 step 2의 box 좌표를 조정 후 step 3 재실행.
|
|
|
|
품질 기준:
|
|
- horyung-front.png: 호령 전신이 중앙에 위치, 여백 적절
|
|
- horyung-bust.png: 호령 얼굴 + 어깨까지 보임
|
|
- horyung-greeting/thinking/pointing/happy.png: 각 표정 잘 보이고 잘림 없음
|
|
|
|
좌표 조정 필요 시 step 2 코드 수정 후 다시 실행.
|
|
|
|
- [ ] **Step 5: Commit (web-ui repo)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add scripts/extract_horyung.py public/images/saju/horyung/
|
|
git commit -m "feat(saju): 호령 캐릭터 PNG 6개 추출 (horyung.png + saju_color_sheet.png)"
|
|
```
|
|
|
|
> ⚠️ Step 4에서 시각 검증 후 좌표 조정이 여러 번 필요할 수 있음. 모든 PNG가 시안 품질에 가까워야 다음 task로 진행.
|
|
|
|
---
|
|
|
|
## Phase C — 프론트엔드 구축 (web-ui)
|
|
|
|
> Phase C부터 모든 작업은 `C:/Users/jaeoh/Desktop/workspace/web-ui`에서. 별도 git 저장소이므로 commit도 web-ui repo에 들어감.
|
|
|
|
### Task 7: Saju.css + 폰트 + 글로벌 토큰
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/saju/Saju.css`
|
|
- Modify: `web-ui/index.html` (Noto Serif KR Google Fonts 추가)
|
|
|
|
- [ ] **Step 1: index.html에 Noto Serif KR 추가**
|
|
|
|
`web-ui/index.html`의 `<head>` 안에 추가:
|
|
|
|
```html
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
|
|
```
|
|
|
|
기존 Pretendard 폰트는 유지.
|
|
|
|
- [ ] **Step 2: Saju.css 작성**
|
|
|
|
`web-ui/src/pages/saju/Saju.css`:
|
|
|
|
```css
|
|
/* saju-page scope — 다른 페이지에 영향 없음 */
|
|
.saju-page {
|
|
/* 베이스 */
|
|
--saju-cream: #FAF6EE;
|
|
--saju-paper: #F2EAD8;
|
|
--saju-ink: #2E2D45;
|
|
--saju-ink-deep: #1F1D38;
|
|
|
|
/* 액센트 */
|
|
--saju-gold: #D4A574;
|
|
--saju-gold-deep: #B5874E;
|
|
--saju-apricot: #C58F76;
|
|
--saju-rose: #D9A2A6;
|
|
--saju-jade: #4B7065;
|
|
--saju-violet: #6A5285;
|
|
|
|
/* 카테고리 (3 ActionCard) */
|
|
--saju-today-bg: #4B7065;
|
|
--saju-gunghab-bg: #A8736E;
|
|
--saju-saju-bg: #4F4A78;
|
|
|
|
/* 점수 카테고리 (4 ScoreCard) */
|
|
--saju-wealth: #D4A574;
|
|
--saju-romance: #D9A2A6;
|
|
--saju-social: #4B7065;
|
|
--saju-career: #6A5285;
|
|
|
|
min-height: 100vh;
|
|
background: var(--saju-cream);
|
|
color: var(--saju-ink);
|
|
font-family: 'Pretendard', sans-serif;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.saju-page * {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.saju-page .saju-h1,
|
|
.saju-page .saju-h2,
|
|
.saju-page .saju-h3 {
|
|
font-family: 'Noto Serif KR', 'Pretendard', serif;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
color: var(--saju-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.saju-page .saju-h1 { font-size: clamp(2.5rem, 4vw, 3.5rem); line-height: 1.2; }
|
|
.saju-page .saju-h2 { font-size: clamp(1.8rem, 3vw, 2.5rem); line-height: 1.3; }
|
|
.saju-page .saju-h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); }
|
|
|
|
/* 호령 마스코트 */
|
|
.horyung-mascot {
|
|
display: block;
|
|
object-fit: contain;
|
|
}
|
|
.horyung-mascot--sm { width: 80px; height: auto; }
|
|
.horyung-mascot--md { width: 180px; height: auto; }
|
|
.horyung-mascot--lg { width: 320px; height: auto; }
|
|
|
|
/* 상단 네비게이션 */
|
|
.saju-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 2rem;
|
|
background: var(--saju-ink);
|
|
color: var(--saju-cream);
|
|
}
|
|
.saju-nav__logo {
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
}
|
|
.saju-nav__links {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.saju-nav__links a {
|
|
color: var(--saju-cream);
|
|
text-decoration: none;
|
|
font-size: 0.95rem;
|
|
opacity: 0.85;
|
|
}
|
|
.saju-nav__links a:hover { opacity: 1; }
|
|
.saju-nav__cta {
|
|
background: var(--saju-gold);
|
|
color: var(--saju-ink);
|
|
border: none;
|
|
padding: 0.5rem 1.25rem;
|
|
border-radius: 999px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
font-family: 'Pretendard', sans-serif;
|
|
}
|
|
|
|
/* Hero (메인) */
|
|
.saju-hero {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1.4fr;
|
|
gap: 3rem;
|
|
padding: 3rem 2rem;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
.saju-hero__left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
}
|
|
.saju-quote-box {
|
|
background: var(--saju-paper);
|
|
padding: 1rem 1.25rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--saju-gold-deep);
|
|
color: var(--saju-ink);
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
max-width: 280px;
|
|
}
|
|
.saju-hero__right {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
justify-content: center;
|
|
}
|
|
.saju-sub {
|
|
color: var(--saju-ink);
|
|
opacity: 0.7;
|
|
margin: 0;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* ActionCard */
|
|
.saju-action-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.saju-action-card {
|
|
background: var(--saju-saju-bg);
|
|
color: var(--saju-cream);
|
|
padding: 1.5rem 1rem;
|
|
border-radius: 16px;
|
|
text-decoration: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
transition: transform 0.2s;
|
|
font-family: 'Pretendard', sans-serif;
|
|
}
|
|
.saju-action-card:hover { transform: translateY(-4px); }
|
|
.saju-action-card--today { background: var(--saju-today-bg); }
|
|
.saju-action-card--gunghab { background: var(--saju-gunghab-bg); }
|
|
.saju-action-card--saju { background: var(--saju-saju-bg); }
|
|
.saju-action-card[aria-disabled="true"] {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.saju-action-card__icon { font-size: 2rem; }
|
|
.saju-action-card__title { font-size: 1.1rem; font-weight: 700; }
|
|
.saju-action-card__desc { font-size: 0.85rem; opacity: 0.85; text-align: center; }
|
|
|
|
/* Bottom (메인 — 통계 + 입력) */
|
|
.saju-bottom {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 3rem;
|
|
padding: 3rem 2rem;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
background: var(--saju-ink);
|
|
color: var(--saju-cream);
|
|
border-radius: 24px 24px 0 0;
|
|
}
|
|
.saju-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.saju-form input,
|
|
.saju-form select {
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--saju-gold-deep);
|
|
background: var(--saju-ink-deep);
|
|
color: var(--saju-cream);
|
|
font-family: inherit;
|
|
font-size: 1rem;
|
|
}
|
|
.saju-form button {
|
|
background: var(--saju-gold);
|
|
color: var(--saju-ink);
|
|
border: none;
|
|
padding: 0.875rem;
|
|
border-radius: 999px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 1rem;
|
|
}
|
|
.saju-form button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
.saju-form__error {
|
|
background: rgba(217, 162, 166, 0.2);
|
|
color: var(--saju-rose);
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Score / Fortune ring */
|
|
.saju-fortune-ring {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
}
|
|
.saju-fortune-ring svg { width: 200px; height: 200px; }
|
|
.saju-fortune-ring__score {
|
|
position: absolute;
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--saju-ink);
|
|
}
|
|
.saju-fortune-ring__total { font-size: 0.9rem; color: var(--saju-ink); opacity: 0.6; }
|
|
|
|
.saju-score-card {
|
|
background: var(--saju-cream);
|
|
border-radius: 16px;
|
|
padding: 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
border: 1px solid var(--saju-paper);
|
|
}
|
|
.saju-score-card__head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.saju-score-card__icon { font-size: 1.5rem; }
|
|
.saju-score-card__title { font-weight: 700; font-size: 0.95rem; }
|
|
.saju-score-card__value {
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--saju-ink);
|
|
}
|
|
.saju-score-card__bar {
|
|
height: 6px;
|
|
background: var(--saju-paper);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
.saju-score-card__bar > div {
|
|
height: 100%;
|
|
background: var(--saju-gold);
|
|
transition: width 0.5s;
|
|
}
|
|
|
|
/* Lucky box */
|
|
.saju-lucky-box {
|
|
background: var(--saju-paper);
|
|
border-radius: 16px;
|
|
padding: 1.5rem;
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 1rem;
|
|
}
|
|
.saju-lucky-box__item { text-align: center; }
|
|
.saju-lucky-box__label {
|
|
font-size: 0.8rem;
|
|
color: var(--saju-ink);
|
|
opacity: 0.7;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.saju-lucky-box__value {
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--saju-ink);
|
|
}
|
|
|
|
/* SajuPillars (4기둥) */
|
|
.saju-pillars {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 0.75rem;
|
|
}
|
|
.saju-pillar {
|
|
background: var(--saju-paper);
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
.saju-pillar__label {
|
|
font-size: 0.8rem;
|
|
color: var(--saju-ink);
|
|
opacity: 0.6;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.saju-pillar__stem,
|
|
.saju-pillar__branch {
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
display: block;
|
|
}
|
|
.saju-pillar__stem-kr,
|
|
.saju-pillar__branch-kr {
|
|
font-size: 0.85rem;
|
|
opacity: 0.7;
|
|
}
|
|
.saju-pillar__ten-god,
|
|
.saju-pillar__fortune {
|
|
font-size: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* Element bar chart (오행) */
|
|
.saju-element-bars {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 1.5rem;
|
|
background: var(--saju-cream);
|
|
border-radius: 16px;
|
|
}
|
|
.saju-element-bar {
|
|
display: grid;
|
|
grid-template-columns: 30px 1fr 50px;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
.saju-element-bar__label { font-size: 0.9rem; font-weight: 700; }
|
|
.saju-element-bar__track {
|
|
height: 12px;
|
|
background: var(--saju-paper);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.saju-element-bar__fill {
|
|
height: 100%;
|
|
border-radius: 6px;
|
|
transition: width 0.5s;
|
|
}
|
|
.saju-element-bar__fill--木 { background: #4B7065; }
|
|
.saju-element-bar__fill--火 { background: #C56F5C; }
|
|
.saju-element-bar__fill--土 { background: #D4A574; }
|
|
.saju-element-bar__fill--金 { background: #B8B5A8; }
|
|
.saju-element-bar__fill--水 { background: #4A5878; }
|
|
.saju-element-bar__value { text-align: right; font-size: 0.85rem; opacity: 0.7; }
|
|
|
|
/* Monthly flow */
|
|
.saju-monthly-flow {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, 1fr);
|
|
gap: 0.25rem;
|
|
padding: 1rem;
|
|
background: var(--saju-cream);
|
|
border-radius: 16px;
|
|
}
|
|
.saju-monthly-flow__cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 0.5rem 0.25rem;
|
|
border-radius: 8px;
|
|
background: var(--saju-paper);
|
|
}
|
|
.saju-monthly-flow__month { font-size: 0.7rem; opacity: 0.7; }
|
|
.saju-monthly-flow__score {
|
|
font-family: 'Noto Serif KR', serif;
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
}
|
|
.saju-monthly-flow__label { font-size: 0.7rem; opacity: 0.8; margin-top: 0.25rem; }
|
|
|
|
/* Horyung quote */
|
|
.saju-horyung-quote {
|
|
background: var(--saju-ink);
|
|
color: var(--saju-cream);
|
|
padding: 1.5rem;
|
|
border-radius: 16px;
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: flex-start;
|
|
}
|
|
.saju-horyung-quote__text {
|
|
font-size: 0.95rem;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Interpret accordion */
|
|
.saju-interpret-accordion {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.saju-interpret-item {
|
|
background: var(--saju-cream);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--saju-paper);
|
|
overflow: hidden;
|
|
}
|
|
.saju-interpret-item__header {
|
|
padding: 1rem;
|
|
background: var(--saju-paper);
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-weight: 700;
|
|
user-select: none;
|
|
}
|
|
.saju-interpret-item__body {
|
|
padding: 1rem;
|
|
font-size: 0.95rem;
|
|
line-height: 1.6;
|
|
}
|
|
.saju-interpret-item__evidence {
|
|
background: var(--saju-paper);
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
margin-top: 0.75rem;
|
|
font-size: 0.85rem;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* Stub (Compatibility v2) */
|
|
.saju-stub {
|
|
max-width: 480px;
|
|
margin: 5rem auto;
|
|
text-align: center;
|
|
padding: 2rem;
|
|
background: var(--saju-paper);
|
|
border-radius: 24px;
|
|
}
|
|
.saju-stub a {
|
|
display: inline-block;
|
|
margin-top: 1.5rem;
|
|
background: var(--saju-gold);
|
|
color: var(--saju-ink);
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 999px;
|
|
text-decoration: none;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* 반응형 */
|
|
@media (max-width: 1280px) {
|
|
.saju-hero { grid-template-columns: 1fr; text-align: center; }
|
|
.saju-hero__left { order: 2; }
|
|
.saju-hero__right { order: 1; }
|
|
.saju-bottom { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.saju-nav { padding: 0.75rem 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
|
.saju-nav__links { display: none; }
|
|
.saju-action-cards { grid-template-columns: 1fr; }
|
|
.saju-pillars { grid-template-columns: repeat(2, 1fr); }
|
|
.saju-monthly-flow { grid-template-columns: repeat(4, 1fr); }
|
|
.horyung-mascot--lg { width: 220px; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 빌드 검증**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: 빌드 성공 (CSS 파일 정상 처리)
|
|
|
|
- [ ] **Step 4: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/Saju.css index.html
|
|
git commit -m "feat(saju): Saju.css 컬러 토큰 + 폰트 + 격리 + Noto Serif KR Google Fonts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: HoryungMascot + SajuNav 공통 컴포넌트
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/saju/components/HoryungMascot.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/SajuNav.jsx`
|
|
|
|
- [ ] **Step 1: HoryungMascot 작성**
|
|
|
|
`web-ui/src/pages/saju/components/HoryungMascot.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
const POSE_TO_FILE = {
|
|
front: '/images/saju/horyung/horyung-front.png',
|
|
bust: '/images/saju/horyung/horyung-bust.png',
|
|
greeting: '/images/saju/horyung/horyung-greeting.png',
|
|
thinking: '/images/saju/horyung/horyung-thinking.png',
|
|
pointing: '/images/saju/horyung/horyung-pointing.png',
|
|
happy: '/images/saju/horyung/horyung-happy.png',
|
|
};
|
|
|
|
export default function HoryungMascot({ pose = 'front', size = 'lg', className = '' }) {
|
|
const src = POSE_TO_FILE[pose] || POSE_TO_FILE.front;
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt="호령"
|
|
className={`horyung-mascot horyung-mascot--${size} ${className}`}
|
|
onError={(e) => { e.target.style.visibility = 'hidden'; }}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: SajuNav 작성**
|
|
|
|
`web-ui/src/pages/saju/components/SajuNav.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import { Link, NavLink } from 'react-router-dom';
|
|
|
|
export default function SajuNav() {
|
|
return (
|
|
<nav className="saju-nav" aria-label="호령 사주">
|
|
<Link to="/saju" className="saju-nav__logo">호령사주</Link>
|
|
<ul className="saju-nav__links">
|
|
<li><NavLink to="/saju/today">오늘의 운세</NavLink></li>
|
|
<li><NavLink to="/saju/compatibility">궁합보기</NavLink></li>
|
|
<li><NavLink to="/saju/result">사주풀이</NavLink></li>
|
|
</ul>
|
|
<Link to="/saju" className="saju-nav__cta">사주풀이 시작하기</Link>
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 빌드 + 시각 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run dev
|
|
```
|
|
|
|
브라우저: http://127.0.0.1:3007/saju (현재는 Task 28의 placeholder만 보임 — 다음 task에서 교체)
|
|
|
|
빌드 오류 없는지 콘솔 확인.
|
|
|
|
- [ ] **Step 4: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/components/HoryungMascot.jsx src/pages/saju/components/SajuNav.jsx
|
|
git commit -m "feat(saju): HoryungMascot + SajuNav 공통 컴포넌트"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: SajuInputForm + ActionCard + useSajuForm hook
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/saju/hooks/useSajuForm.js`
|
|
- Create: `web-ui/src/pages/saju/components/SajuInputForm.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/ActionCard.jsx`
|
|
|
|
- [ ] **Step 1: useSajuForm hook**
|
|
|
|
`web-ui/src/pages/saju/hooks/useSajuForm.js`:
|
|
|
|
```javascript
|
|
import { useState, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { sajuInterpret } from '../../../api';
|
|
|
|
const INITIAL_FORM = {
|
|
name: '',
|
|
year: '',
|
|
month: '',
|
|
day: '',
|
|
hour: '',
|
|
gender: 'male',
|
|
calendar_type: 'solar',
|
|
};
|
|
|
|
export default function useSajuForm() {
|
|
const [form, setForm] = useState(INITIAL_FORM);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const navigate = useNavigate();
|
|
|
|
const handleChange = useCallback((field, value) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
}, []);
|
|
|
|
const handleSubmit = useCallback(async (e) => {
|
|
if (e?.preventDefault) e.preventDefault();
|
|
setError(null);
|
|
|
|
// 검증
|
|
if (!form.year || !form.month || !form.day) {
|
|
setError('생년월일을 모두 입력해주세요.');
|
|
return;
|
|
}
|
|
const year = parseInt(form.year, 10);
|
|
const month = parseInt(form.month, 10);
|
|
const day = parseInt(form.day, 10);
|
|
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) {
|
|
setError('올바른 생년월일을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const body = {
|
|
year,
|
|
month,
|
|
day,
|
|
gender: form.gender,
|
|
calendar_type: form.calendar_type,
|
|
};
|
|
if (form.hour !== '') {
|
|
body.hour = parseInt(form.hour, 10);
|
|
}
|
|
const result = await sajuInterpret(body);
|
|
navigate(`/saju/result?rid=${result.reading_id}`);
|
|
} catch (err) {
|
|
console.error('사주 분석 실패', err);
|
|
setError(err.message || '잠시 후 다시 시도해주세요.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [form, navigate]);
|
|
|
|
return { form, handleChange, handleSubmit, loading, error };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: SajuInputForm**
|
|
|
|
`web-ui/src/pages/saju/components/SajuInputForm.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
export default function SajuInputForm({ form, onChange, onSubmit, loading, error }) {
|
|
return (
|
|
<form className="saju-form" onSubmit={onSubmit}>
|
|
<h3 className="saju-h3" style={{ color: 'var(--saju-cream)', marginBottom: '0.5rem' }}>
|
|
사주풀이 시작하기
|
|
</h3>
|
|
<input
|
|
type="text"
|
|
placeholder="이름 (선택)"
|
|
value={form.name}
|
|
onChange={(e) => onChange('name', e.target.value)}
|
|
disabled={loading}
|
|
/>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
|
<input type="number" placeholder="년 (1900-2100)" value={form.year}
|
|
onChange={(e) => onChange('year', e.target.value)} disabled={loading} min="1900" max="2100" />
|
|
<input type="number" placeholder="월" value={form.month}
|
|
onChange={(e) => onChange('month', e.target.value)} disabled={loading} min="1" max="12" />
|
|
<input type="number" placeholder="일" value={form.day}
|
|
onChange={(e) => onChange('day', e.target.value)} disabled={loading} min="1" max="31" />
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
|
<input type="number" placeholder="시 (선택, 0-23)" value={form.hour}
|
|
onChange={(e) => onChange('hour', e.target.value)} disabled={loading} min="0" max="23" />
|
|
<select value={form.gender} onChange={(e) => onChange('gender', e.target.value)} disabled={loading}>
|
|
<option value="male">남</option>
|
|
<option value="female">여</option>
|
|
</select>
|
|
<select value={form.calendar_type} onChange={(e) => onChange('calendar_type', e.target.value)} disabled={loading}>
|
|
<option value="solar">양력</option>
|
|
<option value="lunar">음력</option>
|
|
</select>
|
|
</div>
|
|
{error && <div className="saju-form__error">{error}</div>}
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? '호령이 풀어보는 중...' : '사주풀이 시작하기 ✦'}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: ActionCard**
|
|
|
|
`web-ui/src/pages/saju/components/ActionCard.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
|
|
const ICON = {
|
|
today: '☀',
|
|
heart: '♥',
|
|
book: '📖',
|
|
};
|
|
|
|
export default function ActionCard({ to, icon, title, desc, variant = 'saju', disabled = false }) {
|
|
const cls = `saju-action-card saju-action-card--${variant}`;
|
|
if (disabled) {
|
|
return (
|
|
<span className={cls} aria-disabled="true">
|
|
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
|
<span className="saju-action-card__title">{title}</span>
|
|
<span className="saju-action-card__desc">{desc || '준비 중'}</span>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<Link to={to} className={cls}>
|
|
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
|
<span className="saju-action-card__title">{title}</span>
|
|
<span className="saju-action-card__desc">{desc}</span>
|
|
</Link>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 빌드 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: 빌드 성공.
|
|
|
|
- [ ] **Step 5: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/hooks/useSajuForm.js \
|
|
src/pages/saju/components/SajuInputForm.jsx \
|
|
src/pages/saju/components/ActionCard.jsx
|
|
git commit -m "feat(saju): useSajuForm + SajuInputForm + ActionCard"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Saju.jsx — 메인 페이지
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/saju/Saju.jsx`
|
|
|
|
- [ ] **Step 1: Saju.jsx 전체 교체 (placeholder → 정식 구현)**
|
|
|
|
`web-ui/src/pages/saju/Saju.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import './Saju.css';
|
|
import SajuNav from './components/SajuNav';
|
|
import HoryungMascot from './components/HoryungMascot';
|
|
import SajuInputForm from './components/SajuInputForm';
|
|
import ActionCard from './components/ActionCard';
|
|
import useSajuForm from './hooks/useSajuForm';
|
|
|
|
export default function Saju() {
|
|
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
|
|
|
return (
|
|
<div className="saju-page saju-page--main">
|
|
<SajuNav />
|
|
|
|
<section className="saju-hero">
|
|
<div className="saju-hero__left">
|
|
<HoryungMascot pose="greeting" size="lg" />
|
|
<div className="saju-quote-box">
|
|
<p style={{ margin: 0 }}>
|
|
전통 사주명리학 + AI 인사이트로<br />
|
|
당신의 오늘을 풀어드립니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="saju-hero__right">
|
|
<h1 className="saju-h1">호령이 반갑게<br />맞이하는 오늘의 사주</h1>
|
|
<p className="saju-sub">
|
|
오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.<br />
|
|
당신의 사주 8자에 담긴 운명을 만나보세요.
|
|
</p>
|
|
|
|
<div className="saju-action-cards">
|
|
<ActionCard
|
|
to="/saju/today"
|
|
icon="today"
|
|
title="오늘의 운세"
|
|
desc="오늘 하루의 흐름을 확인하세요"
|
|
variant="today"
|
|
/>
|
|
<ActionCard
|
|
to="/saju/compatibility"
|
|
icon="heart"
|
|
title="궁합보기"
|
|
desc="두 사람의 인연을 풀어보세요"
|
|
variant="gunghab"
|
|
disabled
|
|
/>
|
|
<ActionCard
|
|
to="/saju/result"
|
|
icon="book"
|
|
title="사주풀이"
|
|
desc="당신의 사주 8자를 자세히"
|
|
variant="saju"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="saju-bottom">
|
|
<div>
|
|
<h2 className="saju-h2" style={{ color: 'var(--saju-cream)' }}>
|
|
오늘의 운세 미리보기
|
|
</h2>
|
|
<p style={{ color: 'var(--saju-cream)', opacity: 0.7, lineHeight: 1.6 }}>
|
|
사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를 한 번에<br />
|
|
확인하실 수 있습니다.
|
|
</p>
|
|
</div>
|
|
<SajuInputForm
|
|
form={form}
|
|
onChange={handleChange}
|
|
onSubmit={handleSubmit}
|
|
loading={loading}
|
|
error={error}
|
|
/>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 빌드 + 로컬 실행 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
npm run dev
|
|
```
|
|
|
|
브라우저: http://127.0.0.1:3007/saju
|
|
확인:
|
|
- 상단 nav 정상 표시
|
|
- 호령 캐릭터 좌측 정상 표시 (또는 onError로 hidden 처리)
|
|
- 우측 h1 + 3 action card 정상
|
|
- 하단 입력 폼 정상 (다크 네이비 배경)
|
|
|
|
⚠️ 백엔드가 로컬에 안 떠 있으면 입력 제출 시 네트워크 에러 정상. 이 task는 UI 렌더링 검증.
|
|
|
|
- [ ] **Step 3: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/Saju.jsx
|
|
git commit -m "feat(saju): 메인 페이지 정식 구현 (호령 hero + 3 action card + 입력 폼)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: 사주풀이 컴포넌트 — SajuPillars + ElementBarChart + InterpretAccordion + HoryungQuote + MonthlyFlow
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/saju/hooks/useSajuReading.js`
|
|
- Create: `web-ui/src/pages/saju/components/SajuPillars.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/ElementBarChart.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/InterpretAccordion.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/HoryungQuote.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/MonthlyFlow.jsx`
|
|
|
|
- [ ] **Step 1: useSajuReading hook**
|
|
|
|
`web-ui/src/pages/saju/hooks/useSajuReading.js`:
|
|
|
|
```javascript
|
|
import { useState, useEffect } from 'react';
|
|
import { sajuGetReading } from '../../../api';
|
|
|
|
export default function useSajuReading(readingId) {
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
if (!readingId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
sajuGetReading(readingId)
|
|
.then((d) => {
|
|
if (!cancelled) {
|
|
setData(d);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled) {
|
|
setError(e.message || '사주 결과를 불러올 수 없습니다.');
|
|
setLoading(false);
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [readingId]);
|
|
|
|
return { data, loading, error };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: SajuPillars**
|
|
|
|
`web-ui/src/pages/saju/components/SajuPillars.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
|
|
|
|
export default function SajuPillars({ saju }) {
|
|
if (!saju) return null;
|
|
const pillars = ['year', 'month', 'day', 'hour'];
|
|
return (
|
|
<div className="saju-pillars">
|
|
{pillars.map((p) => {
|
|
const data = saju[p];
|
|
if (!data) {
|
|
return (
|
|
<div key={p} className="saju-pillar">
|
|
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
|
|
<div style={{ opacity: 0.4 }}>-</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div key={p} className="saju-pillar">
|
|
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
|
|
<div>
|
|
<span className="saju-pillar__stem">{data.stem}</span>
|
|
<span className="saju-pillar__stem-kr"> ({data.stem_kr})</span>
|
|
</div>
|
|
<div>
|
|
<span className="saju-pillar__branch">{data.branch}</span>
|
|
<span className="saju-pillar__branch-kr"> ({data.branch_kr})</span>
|
|
</div>
|
|
<div className="saju-pillar__ten-god">{data.ten_god}</div>
|
|
<div className="saju-pillar__fortune">{data.fortune}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: ElementBarChart**
|
|
|
|
`web-ui/src/pages/saju/components/ElementBarChart.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
const ELEMENT_ORDER = ['木', '火', '土', '金', '水'];
|
|
const ELEMENT_KR = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
|
|
|
|
export default function ElementBarChart({ scores }) {
|
|
if (!scores) return null;
|
|
const max = Math.max(...Object.values(scores), 1);
|
|
return (
|
|
<div className="saju-element-bars">
|
|
{ELEMENT_ORDER.map((e) => {
|
|
const value = scores[e] || 0;
|
|
const widthPct = (value / max) * 100;
|
|
return (
|
|
<div key={e} className="saju-element-bar">
|
|
<div className="saju-element-bar__label">{e} ({ELEMENT_KR[e]})</div>
|
|
<div className="saju-element-bar__track">
|
|
<div
|
|
className={`saju-element-bar__fill saju-element-bar__fill--${e}`}
|
|
style={{ width: `${widthPct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="saju-element-bar__value">{value.toFixed(1)}%</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: InterpretAccordion**
|
|
|
|
`web-ui/src/pages/saju/components/InterpretAccordion.jsx`:
|
|
|
|
```jsx
|
|
import React, { useState } from 'react';
|
|
|
|
export default function InterpretAccordion({ items }) {
|
|
const [openKey, setOpenKey] = useState(items?.[0]?.key);
|
|
if (!items || items.length === 0) return null;
|
|
return (
|
|
<div className="saju-interpret-accordion">
|
|
{items.map((it) => {
|
|
const isOpen = openKey === it.key;
|
|
return (
|
|
<div key={it.key} className="saju-interpret-item">
|
|
<div
|
|
className="saju-interpret-item__header"
|
|
onClick={() => setOpenKey(isOpen ? null : it.key)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }}
|
|
>
|
|
<span>{it.title || it.key}</span>
|
|
<span aria-hidden>{isOpen ? '▾' : '▸'}</span>
|
|
</div>
|
|
{isOpen && (
|
|
<div className="saju-interpret-item__body">
|
|
<p style={{ margin: 0 }}>{it.content}</p>
|
|
{it.evidence && (
|
|
<div className="saju-interpret-item__evidence">
|
|
<strong>근거:</strong> {it.evidence.saju_element}<br />
|
|
<strong>해석 논리:</strong> {it.evidence.reasoning}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: HoryungQuote**
|
|
|
|
`web-ui/src/pages/saju/components/HoryungQuote.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import HoryungMascot from './HoryungMascot';
|
|
|
|
export default function HoryungQuote({ pose = 'thinking', text }) {
|
|
if (!text) return null;
|
|
return (
|
|
<div className="saju-horyung-quote">
|
|
<HoryungMascot pose={pose} size="sm" />
|
|
<div className="saju-horyung-quote__text">{text}</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: MonthlyFlow**
|
|
|
|
`web-ui/src/pages/saju/components/MonthlyFlow.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
const LABEL_COLOR = {
|
|
'성장': '#4B7065',
|
|
'안정': '#D4A574',
|
|
'변동': '#6A5285',
|
|
'도전': '#C58F76',
|
|
'정체': '#888',
|
|
};
|
|
|
|
export default function MonthlyFlow({ flow }) {
|
|
if (!flow || flow.length === 0) return null;
|
|
return (
|
|
<div className="saju-monthly-flow">
|
|
{flow.map((m) => (
|
|
<div key={m.month} className="saju-monthly-flow__cell">
|
|
<span className="saju-monthly-flow__month">{m.month}월</span>
|
|
<span className="saju-monthly-flow__score" style={{ color: LABEL_COLOR[m.label] }}>
|
|
{m.score}
|
|
</span>
|
|
<span className="saju-monthly-flow__label">{m.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: 빌드 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: 빌드 성공.
|
|
|
|
- [ ] **Step 8: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/hooks/useSajuReading.js \
|
|
src/pages/saju/components/SajuPillars.jsx \
|
|
src/pages/saju/components/ElementBarChart.jsx \
|
|
src/pages/saju/components/InterpretAccordion.jsx \
|
|
src/pages/saju/components/HoryungQuote.jsx \
|
|
src/pages/saju/components/MonthlyFlow.jsx
|
|
git commit -m "feat(saju): 사주풀이 5 컴포넌트 + useSajuReading hook"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: SajuResult.jsx — 사주풀이 결과 페이지
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/saju/SajuResult.jsx`
|
|
|
|
- [ ] **Step 1: SajuResult.jsx 정식 구현**
|
|
|
|
`web-ui/src/pages/saju/SajuResult.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import { useSearchParams, Link } from 'react-router-dom';
|
|
import './Saju.css';
|
|
import SajuNav from './components/SajuNav';
|
|
import HoryungMascot from './components/HoryungMascot';
|
|
import SajuPillars from './components/SajuPillars';
|
|
import ElementBarChart from './components/ElementBarChart';
|
|
import InterpretAccordion from './components/InterpretAccordion';
|
|
import HoryungQuote from './components/HoryungQuote';
|
|
import MonthlyFlow from './components/MonthlyFlow';
|
|
import useSajuReading from './hooks/useSajuReading';
|
|
|
|
export default function SajuResult() {
|
|
const [params] = useSearchParams();
|
|
const rid = params.get('rid');
|
|
const ridNum = rid ? parseInt(rid, 10) : null;
|
|
const { data, loading, error } = useSajuReading(ridNum);
|
|
|
|
if (!rid) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<HoryungMascot pose="thinking" />
|
|
<h2 className="saju-h2">사주 정보가 없어요</h2>
|
|
<p>먼저 메인 페이지에서 사주를 입력해주세요.</p>
|
|
<Link to="/saju">메인으로 가기</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<HoryungMascot pose="thinking" />
|
|
<p>호령이 사주를 풀어보는 중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<h2 className="saju-h2">사주 결과를 찾을 수 없어요</h2>
|
|
<p>{error || '다시 입력해주세요.'}</p>
|
|
<Link to="/saju">메인으로 가기</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const saju = data.saju_data;
|
|
const analysis = data.analysis_data;
|
|
const daeun = data.daeun_data;
|
|
const interp = data.interpretation_json;
|
|
const monthlyFlow = data.monthly_flow;
|
|
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
|
|
<section className="saju-hero">
|
|
<div className="saju-hero__left">
|
|
<HoryungMascot pose="thinking" size="lg" />
|
|
</div>
|
|
<div className="saju-hero__right">
|
|
<h1 className="saju-h1">사주풀이</h1>
|
|
<p className="saju-sub">
|
|
{data.birth_year}년 {data.birth_month}월 {data.birth_day}일
|
|
{data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '}
|
|
{data.gender === 'male' ? '남' : '여'} ·{' '}
|
|
{data.calendar_type === 'lunar' ? '음력' : '양력'}
|
|
</p>
|
|
{interp?.summary && (
|
|
<HoryungQuote pose="thinking" text={interp.summary} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>사주 4기둥</h2>
|
|
<SajuPillars saju={saju} />
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오행 분석</h2>
|
|
<ElementBarChart scores={analysis?.element_scores} />
|
|
</div>
|
|
|
|
{analysis?.day_master_strength && (
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>일간 강도</h2>
|
|
<div className="saju-quote-box" style={{ maxWidth: 'none' }}>
|
|
<p style={{ margin: 0 }}>
|
|
<strong>{analysis.day_master_strength.result}</strong> · 점수 {analysis.day_master_strength.score}<br />
|
|
{(analysis.day_master_strength.reasons || []).join(' · ')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{monthlyFlow && (
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>12개월 운세 흐름</h2>
|
|
<MonthlyFlow flow={monthlyFlow} />
|
|
</div>
|
|
)}
|
|
|
|
{interp?.items && (
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>AI 12항목 해석</h2>
|
|
<InterpretAccordion items={interp.items} />
|
|
</div>
|
|
)}
|
|
|
|
{interp?.advice && (
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>호령의 조언</h2>
|
|
<HoryungQuote pose="happy" text={interp.advice} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
|
<Link to={`/saju/today?rid=${rid}`} className="saju-action-card saju-action-card--today" style={{ maxWidth: 240 }}>
|
|
<span className="saju-action-card__icon">☀</span>
|
|
<span className="saju-action-card__title">오늘의 운세</span>
|
|
</Link>
|
|
<Link to="/saju" className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
|
<span className="saju-action-card__icon">📖</span>
|
|
<span className="saju-action-card__title">새 사주 보기</span>
|
|
</Link>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 빌드 + 시각 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
npm run dev
|
|
```
|
|
|
|
브라우저: http://127.0.0.1:3007/saju → 입력 → 결과 페이지 정상 표시.
|
|
|
|
- [ ] **Step 3: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/SajuResult.jsx
|
|
git commit -m "feat(saju): 사주풀이 결과 페이지 (4기둥 + 오행 + 12개월 + AI 12항목)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: 오늘운세 컴포넌트 — FortuneRing + ScoreCard + LuckyBox
|
|
|
|
**Files:**
|
|
- Create: `web-ui/src/pages/saju/components/FortuneRing.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/ScoreCard.jsx`
|
|
- Create: `web-ui/src/pages/saju/components/LuckyBox.jsx`
|
|
|
|
- [ ] **Step 1: FortuneRing (SVG ring chart)**
|
|
|
|
`web-ui/src/pages/saju/components/FortuneRing.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
export default function FortuneRing({ score, max = 100 }) {
|
|
const radius = 80;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const safe = Math.max(0, Math.min(score || 0, max));
|
|
const dashOffset = circumference - (safe / max) * circumference;
|
|
|
|
return (
|
|
<div className="saju-fortune-ring">
|
|
<svg viewBox="0 0 200 200">
|
|
<circle
|
|
cx="100" cy="100" r={radius}
|
|
stroke="var(--saju-paper)" strokeWidth="14" fill="none"
|
|
/>
|
|
<circle
|
|
cx="100" cy="100" r={radius}
|
|
stroke="var(--saju-gold)" strokeWidth="14" fill="none"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={dashOffset}
|
|
strokeLinecap="round"
|
|
transform="rotate(-90 100 100)"
|
|
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
|
/>
|
|
</svg>
|
|
<div style={{ position: 'absolute', textAlign: 'center' }}>
|
|
<div className="saju-fortune-ring__score">{safe}</div>
|
|
<div className="saju-fortune-ring__total">/ {max}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: ScoreCard**
|
|
|
|
`web-ui/src/pages/saju/components/ScoreCard.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
const ICON_BY_CATEGORY = {
|
|
wealth: '💰',
|
|
romance: '💖',
|
|
social: '🤝',
|
|
career: '💼',
|
|
};
|
|
|
|
const COLOR_VAR_BY_CATEGORY = {
|
|
wealth: 'var(--saju-wealth)',
|
|
romance: 'var(--saju-romance)',
|
|
social: 'var(--saju-social)',
|
|
career: 'var(--saju-career)',
|
|
};
|
|
|
|
const TITLE_BY_CATEGORY = {
|
|
wealth: '재물운',
|
|
romance: '연애운',
|
|
social: '인간관계',
|
|
career: '직장운',
|
|
};
|
|
|
|
export default function ScoreCard({ category, score }) {
|
|
const safe = Math.max(0, Math.min(score || 0, 100));
|
|
return (
|
|
<div className="saju-score-card">
|
|
<div className="saju-score-card__head">
|
|
<span className="saju-score-card__icon">{ICON_BY_CATEGORY[category]}</span>
|
|
<span className="saju-score-card__title">{TITLE_BY_CATEGORY[category]}</span>
|
|
</div>
|
|
<div className="saju-score-card__value">{safe}<small style={{ fontSize: '1rem', opacity: 0.5 }}>/100</small></div>
|
|
<div className="saju-score-card__bar">
|
|
<div style={{ width: `${safe}%`, background: COLOR_VAR_BY_CATEGORY[category] }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: LuckyBox**
|
|
|
|
`web-ui/src/pages/saju/components/LuckyBox.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
|
|
export default function LuckyBox({ lucky }) {
|
|
if (!lucky) return null;
|
|
return (
|
|
<div className="saju-lucky-box">
|
|
<div className="saju-lucky-box__item">
|
|
<div className="saju-lucky-box__label">럭키 컬러</div>
|
|
<div className="saju-lucky-box__value">{(lucky.color || []).join(' · ')}</div>
|
|
</div>
|
|
<div className="saju-lucky-box__item">
|
|
<div className="saju-lucky-box__label">럭키 숫자</div>
|
|
<div className="saju-lucky-box__value">{lucky.number}</div>
|
|
</div>
|
|
<div className="saju-lucky-box__item">
|
|
<div className="saju-lucky-box__label">럭키 방향</div>
|
|
<div className="saju-lucky-box__value">{lucky.direction}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 빌드 + Commit**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
git add src/pages/saju/components/FortuneRing.jsx \
|
|
src/pages/saju/components/ScoreCard.jsx \
|
|
src/pages/saju/components/LuckyBox.jsx
|
|
git commit -m "feat(saju): 오늘운세 컴포넌트 3개 (FortuneRing + ScoreCard + LuckyBox)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Today.jsx — 오늘의 운세 페이지
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/saju/Today.jsx` (Task 28에서 placeholder로 만든 파일 — 정식 구현으로 교체)
|
|
- Modify: `web-ui/src/routes.jsx` (라우트가 SajuToday 또는 Today로 등록되어 있을 수 있음 — 확인 후 일치)
|
|
|
|
⚠️ Task 28에서는 `/saju/result`, `/saju/compatibility`만 라우트 등록했을 수 있음. Today 라우트가 없으면 추가.
|
|
|
|
- [ ] **Step 1: routes.jsx 확인 + Today 라우트 추가**
|
|
|
|
`web-ui/src/routes.jsx`에 `/saju/today` 라우트가 없으면 추가. Task 28의 패턴 따라:
|
|
|
|
```jsx
|
|
// 기존 saju 라우트 옆에 추가
|
|
const Today = lazy(() => import('./pages/saju/Today'));
|
|
|
|
// routes 배열에 추가
|
|
{ path: '/saju/today', element: <Today /> }
|
|
```
|
|
|
|
- [ ] **Step 2: Today.jsx 정식 구현**
|
|
|
|
`web-ui/src/pages/saju/Today.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import { useSearchParams, Link } from 'react-router-dom';
|
|
import './Saju.css';
|
|
import SajuNav from './components/SajuNav';
|
|
import HoryungMascot from './components/HoryungMascot';
|
|
import FortuneRing from './components/FortuneRing';
|
|
import ScoreCard from './components/ScoreCard';
|
|
import LuckyBox from './components/LuckyBox';
|
|
import HoryungQuote from './components/HoryungQuote';
|
|
import useSajuReading from './hooks/useSajuReading';
|
|
|
|
export default function Today() {
|
|
const [params] = useSearchParams();
|
|
const rid = params.get('rid');
|
|
const ridNum = rid ? parseInt(rid, 10) : null;
|
|
const { data, loading, error } = useSajuReading(ridNum);
|
|
|
|
if (!rid) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<HoryungMascot pose="thinking" />
|
|
<h2 className="saju-h2">사주가 필요해요</h2>
|
|
<p>오늘의 운세를 보려면 먼저 사주를 입력해주세요.</p>
|
|
<Link to="/saju">사주 입력하러 가기</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<HoryungMascot pose="thinking" />
|
|
<p>오늘의 운세를 풀어보는 중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<h2 className="saju-h2">결과를 찾을 수 없어요</h2>
|
|
<p>{error || '다시 입력해주세요.'}</p>
|
|
<Link to="/saju">메인으로 가기</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const scores = data.fortune_scores;
|
|
const lucky = data.lucky;
|
|
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
|
|
<section className="saju-hero">
|
|
<div className="saju-hero__left">
|
|
<HoryungMascot pose="pointing" size="lg" />
|
|
</div>
|
|
<div className="saju-hero__right">
|
|
<h1 className="saju-h1">오늘의 운세</h1>
|
|
<p className="saju-sub">
|
|
오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
|
{scores && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem', alignItems: 'center' }}>
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 종합점</h2>
|
|
<FortuneRing score={scores.overall} />
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
|
|
<ScoreCard category="wealth" score={scores.wealth} />
|
|
<ScoreCard category="romance" score={scores.romance} />
|
|
<ScoreCard category="social" score={scores.social} />
|
|
<ScoreCard category="career" score={scores.career} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{lucky && (
|
|
<div>
|
|
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 럭키</h2>
|
|
<LuckyBox lucky={lucky} />
|
|
</div>
|
|
)}
|
|
|
|
{(lucky?.good_signs?.length || lucky?.warnings?.length) ? (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
{lucky.good_signs?.length > 0 && (
|
|
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(75, 112, 101, 0.15)' }}>
|
|
<strong style={{ color: 'var(--saju-jade)' }}>✦ 행운 알림</strong>
|
|
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
|
{lucky.good_signs.map((s, i) => <li key={i}>{s}</li>)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{lucky.warnings?.length > 0 && (
|
|
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(197, 143, 118, 0.15)' }}>
|
|
<strong style={{ color: 'var(--saju-apricot)' }}>⚠ 주의사항</strong>
|
|
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
|
{lucky.warnings.map((s, i) => <li key={i}>{s}</li>)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
<HoryungQuote
|
|
pose="happy"
|
|
text="오늘 하루도 호령과 함께 평안하시길 바라요. 작은 신호에도 귀 기울이세요."
|
|
/>
|
|
</section>
|
|
|
|
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
|
<Link to={`/saju/result?rid=${rid}`} className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
|
<span className="saju-action-card__icon">📖</span>
|
|
<span className="saju-action-card__title">사주풀이 보기</span>
|
|
</Link>
|
|
<Link to="/saju" className="saju-action-card saju-action-card--gunghab" style={{ maxWidth: 240 }} aria-disabled="true">
|
|
<span className="saju-action-card__icon">♥</span>
|
|
<span className="saju-action-card__title">궁합 (준비 중)</span>
|
|
</Link>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 빌드 + 시각 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run build 2>&1 | tail -10
|
|
npm run dev
|
|
```
|
|
|
|
브라우저: http://127.0.0.1:3007/saju → 입력 → /saju/result?rid=N → "오늘의 운세 보기" 클릭 → /saju/today?rid=N 정상.
|
|
|
|
- [ ] **Step 4: Commit (web-ui)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/Today.jsx src/routes.jsx
|
|
git commit -m "feat(saju): 오늘운세 페이지 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Compatibility.jsx — v2 placeholder
|
|
|
|
**Files:**
|
|
- Modify: `web-ui/src/pages/saju/Compatibility.jsx`
|
|
|
|
- [ ] **Step 1: placeholder 정리**
|
|
|
|
`web-ui/src/pages/saju/Compatibility.jsx`:
|
|
|
|
```jsx
|
|
import React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import './Saju.css';
|
|
import SajuNav from './components/SajuNav';
|
|
import HoryungMascot from './components/HoryungMascot';
|
|
|
|
export default function Compatibility() {
|
|
return (
|
|
<div className="saju-page">
|
|
<SajuNav />
|
|
<div className="saju-stub">
|
|
<HoryungMascot pose="thinking" />
|
|
<h2 className="saju-h2">궁합보기는 곧 만나요</h2>
|
|
<p>두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.<br />조금만 기다려 주세요.</p>
|
|
<Link to="/saju">메인으로 돌아가기</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git add src/pages/saju/Compatibility.jsx
|
|
git commit -m "feat(saju): 궁합보기 v2 placeholder + SajuNav 통합"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: 로컬 e2e + 반응형 검증 + 최종 commit
|
|
|
|
**Files:**
|
|
- (수정 없음, 동작 검증만)
|
|
|
|
- [ ] **Step 1: 백엔드 로컬 실행 (선택)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
docker compose up -d saju-lab nginx frontend
|
|
```
|
|
|
|
또는 NAS의 운영 백엔드를 가리키도록 환경변수 설정 (vite proxy 사용 시).
|
|
|
|
- [ ] **Step 2: web-ui dev 서버 + 실제 입력 → 결과 흐름 e2e**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
npm run dev
|
|
```
|
|
|
|
브라우저 검증 시나리오:
|
|
1. http://127.0.0.1:3007/saju → 메인 페이지 정상 (호령 + 3 카드 + 입력 폼)
|
|
2. 입력 (1990 / 5 / 15 / 14 / male / solar) → 사주풀이 시작
|
|
3. 자동 navigate → /saju/result?rid=N
|
|
4. 4기둥 8자, 오행 차트, 12개월 흐름, AI 12항목 아코디언, 호령 조언 정상 표시
|
|
5. "오늘의 운세 보기" → /saju/today?rid=N
|
|
6. FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings 정상
|
|
7. "궁합 (준비 중)" 클릭 → /saju/compatibility placeholder 페이지
|
|
|
|
- [ ] **Step 3: 반응형 검증 (Chrome DevTools)**
|
|
|
|
DevTools → Toggle Device Toolbar로 3 크기 확인:
|
|
- 1280x720 (데스크탑) — 시안과 같이 보임
|
|
- 1024x768 (태블릿) — hero 컬럼 → 세로 스택, action card grid 유지
|
|
- 375x667 (모바일) — 호령 작게, action card 1열, 4기둥 2x2
|
|
|
|
깨진 부분 발견 시 `Saju.css`의 `@media` 영역 조정.
|
|
|
|
- [ ] **Step 4: 콘솔 에러 0건 확인**
|
|
|
|
브라우저 콘솔(F12) 열어 다음 3 페이지에서 에러/경고 없는지:
|
|
- /saju
|
|
- /saju/result?rid=N
|
|
- /saju/today?rid=N
|
|
|
|
호령 PNG 누락 메시지는 onError로 silent이므로 무시. 다른 에러(404, undefined property 등) 있으면 수정.
|
|
|
|
- [ ] **Step 5: 최종 정리 commit (만약 변경된 게 있다면)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
|
git status
|
|
# 변경 없으면 skip. 있으면:
|
|
git add -A
|
|
git commit -m "fix(saju): e2e 검증 후 미세 조정"
|
|
```
|
|
|
|
- [ ] **Step 6: push (선택, 사용자가 명시 후)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui && git push # 또는 npm run release:nas
|
|
```
|
|
|
|
---
|
|
|
|
## 전체 검증 체크리스트
|
|
|
|
### 백엔드 (saju-lab)
|
|
- [ ] `fortune_scores.py` + 6 tests passing
|
|
- [ ] `lucky.py` + 6 tests passing
|
|
- [ ] `monthly_flow.py` + 4 tests passing
|
|
- [ ] `db.py` migration 4 tests passing + 기존 10 db tests 영향 없음
|
|
- [ ] `/api/saju/interpret` 응답에 `fortune_scores`, `lucky`, `monthly_flow` 포함
|
|
- [ ] 기존 30 reference fixture 영향 없음 (응답 추가 필드만)
|
|
- [ ] 전체 saju-lab pytest 480+ passing (기존 474 + 신규 20)
|
|
|
|
### 자산 (web-ui public)
|
|
- [ ] `public/images/saju/horyung/` 6 PNG 존재
|
|
- [ ] 각 PNG가 시안 품질에 가까움 (수동 시각 검수)
|
|
|
|
### 프론트 (web-ui)
|
|
- [ ] `Saju.css` 컬러 토큰 + 폰트 + 반응형 모두 적용
|
|
- [ ] `Saju.jsx` (메인) — 호령 hero + 3 ActionCard + 입력 폼
|
|
- [ ] `SajuResult.jsx` — 4기둥 + 오행 + 12개월 + AI 12항목 + 호령 조언
|
|
- [ ] `Today.jsx` — FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings
|
|
- [ ] `Compatibility.jsx` — placeholder ("준비 중")
|
|
- [ ] HoryungMascot가 6 pose 모두 렌더 + onError fallback
|
|
- [ ] 1회 입력 → 3 페이지 자유롭게 이동 (reading_id 공유)
|
|
- [ ] 1280/1024/375 3 사이즈 반응형 정상
|
|
- [ ] 콘솔 에러 0건
|
|
|
|
---
|
|
|
|
## 위험 + 대응
|
|
|
|
| 위험 | 대응 |
|
|
|------|------|
|
|
| horyung.png crop 좌표 부정확 | Task 6 step 4에서 시각 검수 후 좌표 재조정 (반복 가능) |
|
|
| fortune_scores 점수가 매우 낮거나 매우 높음 | 5개 케이스 spot check + base 60 / 가산 폭 조정 |
|
|
| 음력 변환 시 calendar_type='lunar' 전달 누락 | useSajuForm에서 명시적 전달 + 백엔드 lunar_to_solar 호출 검증 |
|
|
| Noto Serif KR 로드 지연 → 폰트 깜빡임 | `display=swap` 사용 (Pretendard fallback) |
|
|
| /api/saju/interpret 504 timeout | 사용자가 DSM Reverse Proxy timeout 미리 늘려둠 (직전 작업에서 이미 다룸) |
|
|
| reading_id 무효(DB 삭제 등) | useSajuReading에서 404 catch + "메인으로 가기" 버튼 |
|
|
|
|
---
|
|
|
|
## 참고
|
|
|
|
- spec: `docs/superpowers/specs/2026-05-26-saju-ui-design.md`
|
|
- 시안: `source/images/saju_page/horyung_saju_{main,today,gunghab,saju}.png`
|
|
- 캐릭터: `source/characters/horyung.png`
|
|
- 컬러시트: `source/images/saju_page/saju_color_sheet.png`
|
|
- 백엔드 직전 commit: SHA 8123f75 (saju-lab 백엔드 474 tests)
|
|
- 프론트 직전 commit: web-ui SHA eab52ca (Task 28 placeholder)
|
|
- 직전 spec: `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md`
|