"use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { AlertsPanel } from "../../components/AlertsPanel"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { IntradayReturns } from "../../components/IntradayReturns"; import { InvestmentNote } from "../../components/InvestmentNote"; import { InvestorCumulative } from "../../components/InvestorCumulative"; import { MetricsPanel } from "../../components/MetricsPanel"; import { PeerComparePanel } from "../../components/PeerComparePanel"; import { NewsList } from "../../components/NewsList"; import { OrderbookPanel } from "../../components/OrderbookPanel"; import { PeriodReturns } from "../../components/PeriodReturns"; import { PeriodTabs, periodNeighbor, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; import { PriceTargets } from "../../components/PriceTargets"; import { RelatedStocks } from "../../components/RelatedStocks"; import { ShareButton } from "../../components/ShareButton"; import { StarButton } from "../../components/StarButton"; import { StockChart } from "../../components/StockChart"; import { SymbolSidebar } from "../../components/SymbolSidebar"; import { TradingValuePanel } from "../../components/TradingValuePanel"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; import { recent } from "../../lib/recent"; import { readTargets, type Targets } from "../../lib/targets"; import { watchlist } from "../../lib/watchlist"; import { markRecorded, wasRecordedToday } from "../../lib/views"; export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; const [chart, setChart] = useState(null); const [prediction, setPrediction] = useState(null); const [err, setErr] = useState(null); const [period, setPeriod] = useState("3M"); const [viewsToday, setViewsToday] = useState(null); const [targets, setTargets] = useState({}); const spec = periodSpec(period); const isIntraday = spec.interval === "10m"; // 종목 페이지 방문 시 최근 본 종목에 push. useEffect(() => { recent.push(code); }, [code]); // 조회 카운터. // - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회. // - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회. // - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝). // - 이미 봤던 종목이면 GET 만. useEffect(() => { let alive = true; const apply = (n: number) => { if (alive) setViewsToday(n); }; if (!wasRecordedToday(code)) { api.recordView(code) .then((r) => { // POST 성공 후에만 mark — 실패 시 다음 방문에 다시 시도 가능. markRecorded(code); apply(r.today_views); }) .catch(() => { // POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌). api.views(code).then((r) => apply(r.today_views)).catch(() => {}); }); } else { api.views(code).then((r) => apply(r.today_views)).catch(() => {}); } return () => { alive = false; }; }, [code]); // 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드. useEffect(() => { setTargets(readTargets(code)); }, [code]); // 종목 페이지 키보드 단축키: // ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap 안 함, 사용자가 끝 인지) // S : 관심종목 토글 // 입력 필드(검색창, 메모 등) 포커스 시 무시 — 텍스트 편집 방해 금지. // modifier(Ctrl/Cmd/Alt) 가 눌리면 브라우저/OS 단축키 양보. useEffect(() => { if (typeof window === "undefined") return; const isEditableTarget = (t: EventTarget | null): boolean => { if (!(t instanceof HTMLElement)) return false; const tag = t.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; if (t.isContentEditable) return true; return false; }; const onKey = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) return; if (isEditableTarget(e.target)) return; // 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음. // ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로, // 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인. if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; if (e.key === "ArrowLeft") { e.preventDefault(); setPeriod((cur) => periodNeighbor(cur, -1)); } else if (e.key === "ArrowRight") { e.preventDefault(); setPeriod((cur) => periodNeighbor(cur, 1)); } else if (e.key === "s" || e.key === "S") { e.preventDefault(); watchlist.toggle(code); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [code]); useEffect(() => { let alive = true; setErr(null); setChart(null); const load = () => { api .getChart(code, spec.days, spec.interval) .then((c) => { if (alive) setChart(c); }) .catch((e) => { if (alive) setErr(e instanceof Error ? e.message : String(e)); }); }; load(); // 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음. if (isIntraday) { const h = window.setInterval(load, 60_000); return () => { alive = false; window.clearInterval(h); }; } return () => { alive = false; }; }, [code, spec.days, spec.interval, isIntraday]); useEffect(() => { let alive = true; api .latestPrediction(code) .then((r) => { if (alive && r.found) setPrediction(r); }) .catch(() => { // 예측 이력 없음 — 무시 }); return () => { alive = false; }; }, [code]); // 현재가/전일가 + 헤더 sparkline 시리즈 + 오늘 흐름 OHLV — ohlcv 끝부분에서 산출. // // flow 의 정의 (period 모드별로 "오늘" 의 의미가 다름): // - 1D(10m): 같은 KST 날짜의 모든 봉을 묶어서 today 의 세션 OHLV 로 집계. // open = 첫 봉 open, high = max(high), low = min(low), volume = sum(volume). // - 1d/1w/1mo: 마지막 봉 자체가 한 단위 (일/주/월) — 그 봉의 OHLV 를 그대로 사용. // PriceHero hero 라인 아래에 "시 / 고 / 저 / 거래량" mini KPI 로 표시 — 차트 hover 없이도 // 페이지 진입 즉시 그날 흐름이 한 줄로 보임. const { current, prev, asOf, sparkSeries, flow } = useMemo(() => { const empty = { current: null as number | null, prev: null as number | null, asOf: null as string | null, sparkSeries: [] as number[], flow: null as { open: number; high: number; low: number; volume: number } | null, }; if (!chart || !chart.ohlcv.length) return empty; const valid = chart.ohlcv.filter((p) => p.close != null); if (!valid.length) return empty; const last = valid[valid.length - 1]; const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null; // 최근 30개 종가 (없으면 가용 전체). const recent = valid.slice(-30).map((p) => p.close as number); // flow 집계. const isIntra = chart.interval === "10m"; let bars: typeof valid; if (isIntra) { // 10m 모드는 'YYYY-MM-DDTHH:MM:SS' — 'T' 이전이 KST 날짜. 마지막 봉의 날짜와 같은 // 봉만 묶음 (intraday 세션 = 오늘). const lastDay = last.date.split("T")[0]; bars = valid.filter((p) => p.date.split("T")[0] === lastDay); } else { bars = [last]; } const opens = bars.map((p) => p.open).filter((v): v is number => v != null); const highs = bars.map((p) => p.high).filter((v): v is number => v != null); const lows = bars.map((p) => p.low).filter((v): v is number => v != null); const vols = bars.map((p) => p.volume).filter((v): v is number => v != null); const flow = opens.length && highs.length && lows.length ? { open: opens[0], high: Math.max(...highs), low: Math.min(...lows), // volume 누락 봉은 0 로 취급 (sum 안 그러면 NaN). 1d 모드는 단일 봉이라 무관. volume: vols.reduce((a, b) => a + b, 0), } : null; return { current: last.close, prev: prevPt?.close ?? null, asOf: last.date, sparkSeries: recent, flow, }; }, [chart]); return (
{/* 헤더 액션 바 — 2 행 구조: 1행) 좌: 페이지 nav 링크 (← 검색 / 관심종목), 우: 종목 액션 (비교/공유/Star) 2행) PeriodTabs (7 탭) 단독 — 항상 우측 정렬 한 줄에 nav+액션+7탭 을 모두 넣으면 모바일에서 wrap 되며 PeriodTabs 가 줄 중간에 끼어 어색해짐. 2행 분리 + flex-wrap + overflow-x-auto 로 모든 폭에서 일관. */}
← 검색 관심종목
⇄ 비교
{/* PeriodTabs 가 좁은 폭에서 컨테이너보다 넓어질 때: 부모에 `justify-end` 를 직접 걸면 음수 방향으로 첫 탭이 잘려서 스크롤로도 접근이 어려움. wrapper-of-wrapper 패턴 — 바깥은 overflow-x-auto 스크롤만, 안쪽 inline flex 가 폭에 맞춰 넓으면 좌측 정렬(스크롤 노출), 좁으면 우측 정렬(공간 차지 안 함). w-max + min-w-full 이 핵심. */}
setPeriod(id)} />
{err &&
차트 로딩 실패: {err}
} {chart && ( <>
{isIntraday && chart.intraday_status && (
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
)}
{/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */} {isIntraday ? ( ) : ( )}
)}
); }