# Plan: 국내/국제 시세 페이지 상단 미니 차트

## Summary
금·석유·배출권(국내)과 천연가스·커모디티 7종(국제) 페이지 상단에 Chart.js 라인 차트를 추가한다. 공용 `ChartData` 모델 + `Views/Component/Chart.cshtml` 파셜 + `price-chart.js` 하나로 전 페이지를 처리한다(Pagination 파셜과 동일한 구성 패턴). 국제 페이지는 이미 메모리에 있는 전체 시계열을 재사용(추가 API 0회), 국내 페이지는 같은 검색조건으로 차트용 데이터 1회를 추가 조회한다.

**가능 여부: 가능** — 모든 대상 페이지가 날짜-가격 시계열을 이미 서버에서 받고 있어 렌더링만 추가하면 된다.

## User Story
As a 사이트 방문자, I want 표를 읽기 전에 가격 흐름을 그래프로 한눈에 보기, so that 최근 추세(상승/하락)를 즉시 파악할 수 있다.

## Problem → Solution
현재: 모든 시세 페이지가 표만 표시 → 추세 파악에 스크롤·암산 필요.
변경 후: 표 위에 240px 높이 라인 차트(국내: 종목별 다중 라인, 국제: 단일 라인) 표시. 데이터 없으면 차트 영역 자체가 렌더링되지 않음.

## Metadata
- **Complexity**: Medium
- **Source PRD**: N/A (free-form)
- **PRD Phase**: N/A
- **Estimated Files**: 13 (CREATE 3, UPDATE 10)

---

## UX Design

### Before
```
[제목]  [검색조건 셀렉트]
┌─────────── 표 ───────────┐
│ 번호 | 날짜 | 가격 ...    │
└──────────────────────────┘
[페이지네이션]
```

### After
```
[제목]  [검색조건 셀렉트]
┌───────── 차트 (240px) ────────┐
│  ╭─╮    ← 라인차트             │
│ ╱   ╰─╮  국내: 종목별 여러 라인 │
│╱      ╰─ 국제: 단일 라인       │
└───────────────────────────────┘
┌─────────── 표 ───────────┐  ← 기존 그대로
└──────────────────────────┘
[페이지네이션]
```

### Interaction Changes
| Touchpoint | Before | After | Notes |
|---|---|---|---|
| 시세 페이지 상단 | 없음 | 라인 차트 + 호버 툴팁 + 범례(다중 시리즈 시) | Chart.js 기본 동작 |
| 검색조건 변경 | 표만 갱신 | 차트도 같은 조건으로 갱신 | 서버 렌더라 자동 |
| 데이터 없음/API 실패 | "No data." | 차트 미표시 + 기존 "No data." | 파셜이 null/빈 데이터면 아무것도 출력 안 함 |

---

## Mandatory Reading

| Priority | File | Lines | Why |
|---|---|---|---|
| P0 | `Views/Component/Pagination.cshtml` | all | 파셜 구성 원형. **주의: 이 파일의 `@section` 블록은 파셜에서 무시됨(작동 안 함)** — 새 Chart 파셜은 인라인 `<script>` 사용 |
| P0 | `Controllers/Price/GlobalController.cs` | Commodity 액션 | 차트 데이터를 Skip/Take **이전에** 추출해야 하는 위치 |
| P0 | `Controllers/Price/Domestic/GoldController.cs` | all | 국내 컨트롤러 원형 (Oil/Emission 동일 구조) |
| P1 | `Models/View.cs` | 1-15, 78 | `Pagination` 프로퍼티처럼 `Chart` 프로퍼티 추가할 위치 |
| P1 | `Models/Price/DomesticModel.cs` | GetGoldPriceInfo | 차트용 추가 조회에 재사용할 메서드 (이미 존재, 수정 불필요) |
| P1 | `Models/Response/Price/Domestic/Gold.cs` | 45-84 | 필드명: BasDt(yyyyMMdd 문자열), ItmsNm, Clpr(문자열 숫자). Emission도 동일 구조 |
| P1 | `Models/Response/Price/Domestic/Oil.cs` | 45-66 | 필드명: BasDt, OilCtg(유종), WtAvgPrcCptn(가중평균가격 문자열) |
| P2 | `Helpers/Common.cs` | 46-49 | `StringToDateFormat("20260721")` → "2026-07-21" 라벨 변환 |
| P2 | `Views/Price/Global/Commodity.cshtml` | all | 파셜 include 위치 참고 |

## External Documentation

| Topic | Source | Key Takeaway |
|---|---|---|
| Chart.js 4.x | https://www.chartjs.org/docs/latest/ | UMD 단일 파일 CDN: `https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js`. 카테고리 X축은 문자열 라벨이면 어댑터 불필요 |

KEY_INSIGHT: Chart.js 4.x는 시간축(time scale)을 쓰려면 date 어댑터가 별도로 필요하지만, 라벨을 문자열 배열(category)로 주면 어댑터 없이 동작한다.
APPLIES_TO: price-chart.js — labels는 "yyyy-MM-dd" 문자열 배열로 전달.
GOTCHA: `maintainAspectRatio: false` + 부모 div 고정 높이(240px) 조합이 아니면 캔버스가 무한히 커지는 고전 버그가 있다.

KEY_INSIGHT: `wwwroot/lib`에 차트 라이브러리 없음(bootstrap/jquery뿐). 사이트는 이미 외부 스크립트(googletagmanager, coupang iframe)를 로드하므로 CDN 사용에 제약 없음.
APPLIES_TO: Chart 파셜의 스크립트 태그.

---

## Patterns to Mirror

### SHARED_VIEWMODEL_PROPERTY (SOURCE: Models/View.cs:78)
```csharp
public Pagination Pagination { get; set; }
// → 동일하게 public ChartData? Chart { get; set; } 추가
```

### PARTIAL_INCLUDE (SOURCE: Views/Price/Global/Commodity.cshtml:86)
```razor
@await Html.PartialAsync("~/Views/Component/Pagination.cshtml", Model.Pagination)
```

### PARTIAL_EMPTY_GUARD (SOURCE: Views/Component/Pagination.cshtml:4)
```razor
@if (Model.TotalRows > 0)
{
<nav id="pagination" ...>
```

### RAW_VALUE_PARSE (SOURCE: Controllers/Price/GlobalController.cs — Commodity 액션)
```csharp
if (decimal.TryParse(row.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal value) && value > 0)
```

### DOMESTIC_EXTRA_FETCH (SOURCE: Controllers/Price/Domestic/GoldController.cs:39-40 변형)
```csharp
DomesticModel domesticModel = new DomesticModel(_dataGoKR, null);
Response itemList = await domesticModel.GetGoldPriceInfo(request);
// 차트용: 같은 모델 메서드를 numOfRows 크게 잡아 1회 더 호출 (request 복제본 사용)
```

### DATE_LABEL (SOURCE: Helpers/Common.cs:46-49)
```csharp
Common.StringToDateFormat(row.BasDt) // "20260721" → "2026-07-21"
```

### PAGE_JS (SOURCE: wwwroot/js/gold.js) — 클래스 + 전역 인스턴스 스타일. price-chart.js도 같은 톤 유지.

---

## Files to Change

| File | Action | Justification |
|---|---|---|
| `Models/ChartData.cs` | CREATE | 공용 차트 모델 (Labels + Series) |
| `Views/Component/Chart.cshtml` | CREATE | 캔버스 + JSON 직렬화 + 스크립트 로드 파셜 |
| `wwwroot/js/price-chart.js` | CREATE | JSON 읽어 Chart.js 라인차트 렌더 |
| `Models/View.cs` | UPDATE | `Chart` 프로퍼티 추가 |
| `Controllers/Price/GlobalController.cs` | UPDATE | NaturalGas·Commodity 두 액션에 차트 데이터 구성 |
| `Controllers/Price/Domestic/GoldController.cs` | UPDATE | 차트용 추가 조회 + 그룹핑 |
| `Controllers/Price/Domestic/OilController.cs` | UPDATE | 〃 (OilCtg/WtAvgPrcCptn) |
| `Controllers/Price/Domestic/EmissionController.cs` | UPDATE | 〃 (ItmsNm/Clpr) |
| `Views/Price/Domestic/Gold.cshtml` | UPDATE | 표 위에 파셜 include |
| `Views/Price/Domestic/Oil.cshtml` | UPDATE | 〃 |
| `Views/Price/Domestic/Emission.cshtml` | UPDATE | 〃 |
| `Views/Price/Global/NaturalGas.cshtml` | UPDATE | 〃 |
| `Views/Price/Global/Commodity.cshtml` | UPDATE | 〃 |

## NOT Building
- 품목별(Item)·화훼(Flower) 페이지 차트 — 행이 시계열이 아니라 카탈로그 성격이라 제외
- 클라이언트측 기간 변경/줌 등 인터랙션 — 기존 검색조건(서버 렌더)에 따름
- 캔들차트/거래량 보조축 — "간단하게" 요구에 맞춰 종가 라인만
- Chart.js 로컬 번들링 — CDN 사용 (오프라인 필요 시 후속으로 wwwroot/lib/chartjs에 복사만 하면 됨)

---

## Step-by-Step Tasks

### Task 1: 공용 차트 모델
- **ACTION**: `Models/ChartData.cs` 생성, namespace `economy.Models` (View/Pagination과 동급).
- **IMPLEMENT**:
```csharp
namespace economy.Models
{
    // 시세 페이지 상단 미니 차트 데이터
    public class ChartData
    {
        public List<string> Labels { get; set; } = [];       // X축 날짜 (오름차순, "yyyy-MM-dd")
        public List<ChartSeries> Series { get; set; } = [];
    }

    public class ChartSeries
    {
        public string Name { get; set; } = "";               // 범례 이름 (종목명/유종)
        public List<decimal?> Values { get; set; } = [];     // Labels와 같은 길이, 결측은 null
    }
}
```
- **MIRROR**: SHARED_VIEWMODEL_PROPERTY 위치 감각
- **VALIDATE**: `dotnet build economy.sln` 0 에러

### Task 2: View 모델에 Chart 프로퍼티
- **ACTION**: [Models/View.cs](Models/View.cs)의 `Pagination` 프로퍼티(78행) 위에 `public ChartData? Chart { get; set; }` 추가.
- **GOTCHA**: nullable — 차트 없는 페이지(FIFA 등)도 이 제네릭을 공유하므로 기본 null이어야 함.
- **VALIDATE**: build 0 에러

### Task 3: Chart 파셜 + JS
- **ACTION**: `Views/Component/Chart.cshtml`, `wwwroot/js/price-chart.js` 생성.
- **IMPLEMENT** (파셜):
```razor
@model economy.Models.ChartData

@if (Model != null && Model.Labels.Count > 1 && Model.Series.Count > 0)
{
    <div class="mt-3" style="position: relative; height: 240px;">
        <canvas id="priceChart"></canvas>
    </div>
    <script type="application/json" id="priceChartData">@Json.Serialize(Model)</script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
    <script src="~/js/price-chart.js" asp-append-version="true"></script>
}
```
- **IMPLEMENT** (price-chart.js): `#priceChartData`의 JSON을 파싱해 라인차트 생성.
```javascript
class PriceChart {
    constructor() {
        const el = document.getElementById("priceChartData");
        if (!el || typeof Chart === "undefined") return;

        const data = JSON.parse(el.textContent);

        new Chart(document.getElementById("priceChart"), {
            type: "line",
            data: {
                labels: data.labels,
                datasets: data.series.map((s) => ({
                    label: s.name,
                    data: s.values,
                    borderWidth: 2,
                    pointRadius: 2,
                    tension: 0.2,
                    spanGaps: true
                }))
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                plugins: {
                    legend: { display: data.series.length > 1 }
                },
                scales: {
                    y: { beginAtZero: false }
                }
            }
        });
    }
}

const priceChart = new PriceChart();
```
- **MIRROR**: PARTIAL_EMPTY_GUARD, PAGE_JS
- **GOTCHA**: ① 파셜 안에서 `@section Scripts`는 **무시됨**(Pagination.cshtml 전례) — 반드시 인라인 `<script>`. ② `@Json.Serialize`는 camelCase(labels/series/name/values)로 직렬화 — JS는 camelCase로 접근. ③ `Labels.Count > 1` 조건 — 점 1개짜리 라인은 무의미. ④ 부모 div 고정높이 + `maintainAspectRatio:false` 조합 필수.
- **VALIDATE**: 아래 Task 4 이후 브라우저 확인

### Task 4: 국제 페이지 (GlobalController — 액션 2개)
- **ACTION**: NaturalGas·Commodity 액션에서 `itemList.Data = ... Skip/Take` **재할당 이전에** 차트 데이터 구성.
- **IMPLEMENT** (두 액션 공통, private 헬퍼로 추출):
```csharp
// 국제 시세 응답을 차트 데이터로 변환 (최신 60개, 오름차순)
private static ChartData BuildChart(string name, IEnumerable<(string Date, string Value)> rows)
{
    var points = rows
        .Select(r => (r.Date, Parsed: decimal.TryParse(r.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal v) ? v : (decimal?)null))
        .Where(p => p.Parsed.HasValue)
        .Take(60)
        .Reverse()
        .ToList();

    if (points.Count < 2)
    {
        return new ChartData();
    }

    return new ChartData
    {
        Labels = points.Select(p => p.Date).ToList(),
        Series = [new ChartSeries { Name = name, Values = points.Select(p => p.Parsed).ToList() }]
    };
}
```
호출부(예: Commodity 액션, `if (itemList.Data != null && itemList.Data.Any())` 블록 첫 줄에서 보관 후, viewModel 구성 시 할당):
```csharp
ChartData chart = BuildChart(meta.Title, itemList.Data.Select(d => (d.Date, d.Value)));
// ... 기존 Skip/Take 페이징 그대로 ...
viewModel.Chart = chart;
```
NaturalGas 액션도 동일하게 `BuildChart("천연가스", ...)`.
- **MIRROR**: RAW_VALUE_PARSE
- **IMPORTS**: 기존 파일에 이미 `System.Globalization` 있음(2026-07-22 작업에서 추가됨)
- **GOTCHA**: ① 반드시 Skip/Take **이전** — 이후에는 Data가 페이지 조각으로 교체됨. ② Alpha 데이터는 최신순이므로 `Take(60)` 후 `Reverse()`. ③ 추가 API 호출 없음(전체 데이터가 이미 메모리에 있음 — 캐시 덕분에 쿼터 영향 0).
- **VALIDATE**: `/Price/Global/copper` 렌더 후 캔버스 존재 + 라인 1개

### Task 5: 국내 페이지 (Gold/Oil/Emission 컨트롤러 3개)
- **ACTION**: 각 Index 액션에서 표 조회와 별개로 차트용 데이터 1회 추가 조회(페이지와 무관하게 같은 날짜 범위 전체).
- **IMPLEMENT** (GoldController 예 — Oil/Emission은 필드만 치환):
```csharp
// 차트용 데이터 조회 (같은 검색조건, 최대 300행)
var chartRequest = new Request
{
    PageNo = 1,
    NumOfRows = 300,
    StartDate = request.StartDate,
    EndDate = request.EndDate,
    LikeSrtnCd = request.LikeSrtnCd   // Oil Request에는 이 필드 없음 — 제외
};
Response chartList = await domesticModel.GetGoldPriceInfo(chartRequest);

if (chartList.Body?.Items?.ItemList is not null)
{
    var rows = chartList.Body.Items.ItemList;
    var labels = rows.Select(r => r.BasDt).Distinct().OrderBy(d => d).ToList();

    var chart = new ChartData
    {
        Labels = labels.Select(d => Common.StringToDateFormat(d)).ToList(),
        Series = rows.GroupBy(r => r.ItmsNm).Select(g => new ChartSeries
        {
            Name = g.Key,
            Values = labels.Select(d =>
            {
                var row = g.FirstOrDefault(r => r.BasDt == d);
                return (row is not null && decimal.TryParse(row.Clpr, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal v)) ? v : (decimal?)null;
            }).ToList()
        }).ToList()
    };

    viewModel.Chart = chart;
}
```
필드 매핑: Gold/Emission → `ItmsNm`/`Clpr`, Oil → `OilCtg`/`WtAvgPrcCptn`.
- **MIRROR**: DOMESTIC_EXTRA_FETCH, DATE_LABEL
- **IMPORTS**: `System.Globalization` 추가 필요 (세 컨트롤러 모두 현재 없음)
- **GOTCHA**: ① 차트 조회는 **원본 request를 변경하지 말고 복제본** 사용 — request는 뷰/페이지네이션이 그대로 씀. ② 표 데이터(`itemList`)는 컨트롤러가 NumberFormat으로 **문자열 포맷을 덮어쓴 후**라 재사용 불가 — 반드시 별도 조회분(raw)으로 계산. ③ 그룹별 결측 날짜는 null(→ JS `spanGaps`가 이어줌). ④ data.go.kr 쿼터는 넉넉하므로 +1회/페이지뷰 무방. 실패 시 Body null → 차트만 생략(표 영향 없음). ⑤ Gold/Emission의 `Request.Interval` 같은 필드는 없음 — 각 페이지 Request 클래스 필드만 복제.
- **VALIDATE**: `/Price/Domestic/Gold` 렌더 → 종목 수만큼 라인 + 범례

### Task 6: 뷰 5개에 파셜 include
- **ACTION**: 각 뷰의 `<div class="table-responsive">` **바로 위**에 한 줄 추가:
```razor
@await Html.PartialAsync("~/Views/Component/Chart.cshtml", Model.Chart)
```
- **MIRROR**: PARTIAL_INCLUDE
- **GOTCHA**: Model.Chart가 null이어도 파셜 첫 줄 `@if (Model != null ...)` guard가 렌더를 생략 — 페이지별 조건문 불필요 (guard는 Task 3에 포함됨).
- **VALIDATE**: 5개 페이지 전부 표 위 차트 확인

---

## Testing Strategy

repo에 테스트 프로젝트 없음 — 빌드 + 통합 스모크로 검증 (기존 방식).

### Edge Cases Checklist
- [ ] API 실패/빈 데이터 → 차트 없이 표의 "No data."만 (기존 alert 동작 유지)
- [ ] 데이터 1건뿐 → 차트 미표시 (Labels.Count > 1 guard)
- [ ] 국내: 날짜별 종목 결측 (신규 상장 등) → null 갭 처리
- [ ] 국제: interval=annual (점 수 적음) → 정상 렌더
- [ ] perPage/페이지 변경 → 차트는 동일 (페이지와 무관한 데이터)
- [ ] CDN 차단 환경 → `typeof Chart === "undefined"` guard로 JS 에러 없이 차트만 생략

## Validation Commands

### Build
```bash
dotnet build economy.sln
```
EXPECT: 0 errors

### Run & Smoke (기존 절차: 별도 출력 빌드로 사용자 실행 인스턴스 무간섭, 포트 5099)
```bash
curl -s http://localhost:5099/Price/Global/copper | grep -c "priceChartData"
```
EXPECT: copper/NaturalGas/Gold/Oil/Emission 각각 1 (차트 JSON 존재), 무관 페이지(FIFA 등) 0

### Manual Validation
- [ ] 5개 페이지에서 차트 렌더 + 호버 툴팁
- [ ] 금 페이지: 종목별 라인 + 범례 표시
- [ ] 원자재(지수) 페이지: 단일 라인
- [ ] 기간/interval 변경 시 차트 갱신

## Acceptance Criteria
- [ ] 국내 3종 + 국제(8개 메뉴, 뷰 2개) 페이지 상단 차트 표시
- [ ] 데이터 없는 상황에서 레이아웃 깨짐/JS 에러 없음
- [ ] 빌드 0 에러, 기존 표/페이지네이션 동작 무변화
- [ ] Alpha Vantage 추가 API 호출 0회 (국제), data.go.kr +1회/뷰 (국내)

## Completion Checklist
- [ ] 파셜/모델/JS가 Pagination 구성 패턴과 동형
- [ ] 하드코딩 없음 (CDN 버전 문자열은 파셜 1곳)
- [ ] 범위 외 페이지(품목별/화훼/FIFA 등) 무영향
- [ ] Self-contained — 구현 중 추가 탐색 불필요

## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| CDN 차단/오프라인 | 낮음 | 차트만 미표시 | JS에 `typeof Chart` guard — 페이지는 정상. 필요 시 로컬 번들 후속 |
| 국내 차트 추가 호출로 페이지 지연 | 중간 | 응답 +0.5~2초 | 지연 체감 시 후속으로 표 조회와 Task.WhenAll 병렬화 |
| 화훼/품목별에 차트 기대 | 낮음 | 기대 불일치 | NOT Building에 명시 — 필요 시 별도 계획 |
| 국내 300행 캡 초과(긴 기간 검색) | 낮음 | 차트가 일부만 표시 | 기본 검색창 5일 기준 충분. 캡 초과 시 최신 300행 |

## Notes
- 국제 페이지는 2026-07-22 배포된 IMemoryCache(1일 TTL) 위에서 동작 — 차트 추가로 인한 Alpha 쿼터 소모 없음.
- `Json.Serialize`는 MVC 기본 camelCase — JS 키는 `labels/series/name/values`.
- 배출권 응답 모델은 Gold와 동일 구조(ItmsNm/Clpr/BasDt) — EmissionController가 이미 동일 필드를 포맷팅 중.
- 운영 배포는 main 푸시만 하면 Jenkins `economy.web.or.kr` job이 5분 내 자동 배포(2026-07-22 구성).
