From 8ef0ba81f2eac12ad5afd42739defb97c389ea69 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 26 May 2026 07:54:13 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20saju-lab=20UI=20v1=20=E2=80=94=20?= =?UTF-8?q?=ED=98=B8=EB=A0=B9=20=EC=82=AC=EC=A3=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../plans/2026-05-26-saju-ui-v1.md | 2913 +++++++++++++++++ 1 file changed, 2913 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-saju-ui-v1.md diff --git a/docs/superpowers/plans/2026-05-26-saju-ui-v1.md b/docs/superpowers/plans/2026-05-26-saju-ui-v1.md new file mode 100644 index 0000000..5f0c654 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-saju-ui-v1.md @@ -0,0 +1,2913 @@ +# 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`