# 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`의 `` 안에 추가: ```html ``` 기존 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 ( 호령 { 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 ( ); } ``` - [ ] **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 (

사주풀이 시작하기

onChange('name', e.target.value)} disabled={loading} />
onChange('year', e.target.value)} disabled={loading} min="1900" max="2100" /> onChange('month', e.target.value)} disabled={loading} min="1" max="12" /> onChange('day', e.target.value)} disabled={loading} min="1" max="31" />
onChange('hour', e.target.value)} disabled={loading} min="0" max="23" />
{error &&
{error}
}
); } ``` - [ ] **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 ( {ICON[icon] || '✦'} {title} {desc || '준비 중'} ); } return ( {ICON[icon] || '✦'} {title} {desc} ); } ``` - [ ] **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 (

전통 사주명리학 + AI 인사이트로
당신의 오늘을 풀어드립니다

호령이 반갑게
맞이하는 오늘의 사주

오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.
당신의 사주 8자에 담긴 운명을 만나보세요.

오늘의 운세 미리보기

사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를 한 번에
확인하실 수 있습니다.

); } ``` - [ ] **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 (
{pillars.map((p) => { const data = saju[p]; if (!data) { return (
{PILLAR_LABELS[p]}
-
); } return (
{PILLAR_LABELS[p]}
{data.stem} ({data.stem_kr})
{data.branch} ({data.branch_kr})
{data.ten_god}
{data.fortune}
); })}
); } ``` - [ ] **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 (
{ELEMENT_ORDER.map((e) => { const value = scores[e] || 0; const widthPct = (value / max) * 100; return (
{e} ({ELEMENT_KR[e]})
{value.toFixed(1)}%
); })}
); } ``` - [ ] **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 (
{items.map((it) => { const isOpen = openKey === it.key; return (
setOpenKey(isOpen ? null : it.key)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }} > {it.title || it.key} {isOpen ? '▾' : '▸'}
{isOpen && (

{it.content}

{it.evidence && (
근거: {it.evidence.saju_element}
해석 논리: {it.evidence.reasoning}
)}
)}
); })}
); } ``` - [ ] **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 (
{text}
); } ``` - [ ] **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 (
{flow.map((m) => (
{m.month}월 {m.score} {m.label}
))}
); } ``` - [ ] **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 (

사주 정보가 없어요

먼저 메인 페이지에서 사주를 입력해주세요.

메인으로 가기
); } if (loading) { return (

호령이 사주를 풀어보는 중...

); } if (error || !data) { return (

사주 결과를 찾을 수 없어요

{error || '다시 입력해주세요.'}

메인으로 가기
); } 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 (

사주풀이

{data.birth_year}년 {data.birth_month}월 {data.birth_day}일 {data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '} {data.gender === 'male' ? '남' : '여'} ·{' '} {data.calendar_type === 'lunar' ? '음력' : '양력'}

{interp?.summary && ( )}

사주 4기둥

오행 분석

{analysis?.day_master_strength && (

일간 강도

{analysis.day_master_strength.result} · 점수 {analysis.day_master_strength.score}
{(analysis.day_master_strength.reasons || []).join(' · ')}

)} {monthlyFlow && (

12개월 운세 흐름

)} {interp?.items && (

AI 12항목 해석

)} {interp?.advice && (

호령의 조언

)}
오늘의 운세 📖 새 사주 보기
); } ``` - [ ] **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 (
{safe}
/ {max}
); } ``` - [ ] **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 (
{ICON_BY_CATEGORY[category]} {TITLE_BY_CATEGORY[category]}
{safe}/100
); } ``` - [ ] **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 (
럭키 컬러
{(lucky.color || []).join(' · ')}
럭키 숫자
{lucky.number}
럭키 방향
{lucky.direction}
); } ``` - [ ] **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: } ``` - [ ] **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 (

사주가 필요해요

오늘의 운세를 보려면 먼저 사주를 입력해주세요.

사주 입력하러 가기
); } if (loading) { return (

오늘의 운세를 풀어보는 중...

); } if (error || !data) { return (

결과를 찾을 수 없어요

{error || '다시 입력해주세요.'}

메인으로 가기
); } const scores = data.fortune_scores; const lucky = data.lucky; return (

오늘의 운세

오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.

{scores && (

오늘의 종합점

)} {lucky && (

오늘의 럭키

)} {(lucky?.good_signs?.length || lucky?.warnings?.length) ? (
{lucky.good_signs?.length > 0 && (
✦ 행운 알림
    {lucky.good_signs.map((s, i) =>
  • {s}
  • )}
)} {lucky.warnings?.length > 0 && (
⚠ 주의사항
    {lucky.warnings.map((s, i) =>
  • {s}
  • )}
)}
) : null}
📖 사주풀이 보기 궁합 (준비 중)
); } ``` - [ ] **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 (

궁합보기는 곧 만나요

두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.
조금만 기다려 주세요.

메인으로 돌아가기
); } ``` - [ ] **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`