diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 59c04c4..31bb588 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -17,6 +17,7 @@ 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"; export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; @@ -28,6 +29,11 @@ export default function CodePage({ params }: { params: { code: string } }) { const spec = periodSpec(period); const isIntraday = spec.interval === "10m"; + // 종목 페이지 방문 시 최근 본 종목에 push. + useEffect(() => { + recent.push(code); + }, [code]); + useEffect(() => { let alive = true; setErr(null); diff --git a/web/app/page.tsx b/web/app/page.tsx index 08f4e83..ba1fae3 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { IndicesPanel } from "../components/IndicesPanel"; import { MarketsOverview } from "../components/MarketsOverview"; +import { RecentTiles } from "../components/RecentTiles"; import { SearchBox } from "../components/SearchBox"; import { SeedTiles } from "../components/SeedTiles"; import { ThemeTiles } from "../components/ThemeTiles"; @@ -28,6 +29,10 @@ export default function HomePage() { +
+ +
+
diff --git a/web/components/RecentTiles.tsx b/web/components/RecentTiles.tsx new file mode 100644 index 0000000..54a4fee --- /dev/null +++ b/web/components/RecentTiles.tsx @@ -0,0 +1,129 @@ +"use client"; + +// 최근 본 종목 카드 그리드. localStorage 의 stockchart.recent 를 구독. +// 각 코드별로 /api/chart 2일치 받아서 현재가/전일대비 표시. +// 빈 리스트면 패널 자체를 안 그림. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api } from "../lib/api"; +import { recent } from "../lib/recent"; + +type Tile = { + code: string; + name: string; + market: string; + current: number | null; + prev: number | null; +}; + +export function RecentTiles() { + const [codes, setCodes] = useState([]); + const [tiles, setTiles] = useState([]); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + const refresh = () => setCodes(recent.get()); + refresh(); + return recent.subscribe(refresh); + }, []); + + useEffect(() => { + if (!hydrated || codes.length === 0) { + setTiles([]); + return; + } + let alive = true; + Promise.allSettled( + codes.map(async (code) => { + const c = await api.getChart(code, 5, "1d"); + const valid = c.ohlcv.filter((p) => p.close != null); + const last = valid[valid.length - 1] ?? null; + const prev = valid.length >= 2 ? valid[valid.length - 2] : null; + return { + code, + name: c.name, + market: c.market, + current: last?.close ?? null, + prev: prev?.close ?? null, + }; + }), + ).then((settled) => { + if (!alive) return; + setTiles( + settled.map((r, i) => { + if (r.status === "fulfilled") return r.value; + return { + code: codes[i], + name: codes[i], + market: "—", + current: null, + prev: null, + }; + }), + ); + }); + return () => { + alive = false; + }; + }, [codes, hydrated]); + + if (!hydrated || codes.length === 0) return null; + + return ( +
+
+
최근 본 종목
+ +
+
+ {tiles.map((t) => ( + + ))} +
+
+ ); +} + +function Tile({ t }: { t: Tile }) { + const change = + t.current != null && t.prev != null ? t.current - t.prev : null; + const pct = + change != null && t.prev != null && t.prev !== 0 + ? (change / t.prev) * 100 + : null; + const tone = + change == null || change === 0 + ? "text-zinc-400" + : change > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( + +
{t.name}
+
+ {t.code} · {t.market} +
+
+ {t.current != null + ? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} +
+
+ {pct == null + ? " " + : `${pct > 0 ? "+" : ""}${pct.toFixed(2)}%`} +
+ + ); +} diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx index 2ec9370..061a90f 100644 --- a/web/components/TopNav.tsx +++ b/web/components/TopNav.tsx @@ -9,14 +9,23 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { api, type Symbol } from "../lib/api"; +import { recent } from "../lib/recent"; export function TopNav() { const [q, setQ] = useState(""); const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); + const [recentCodes, setRecentCodes] = useState([]); const wrapRef = useRef(null); + // 최근 본 종목 구독 — popover 빈 상태에서 보여줌. + useEffect(() => { + const refresh = () => setRecentCodes(recent.get()); + refresh(); + return recent.subscribe(refresh); + }, []); + // 디바운스 검색. 입력 비면 결과 클리어. useEffect(() => { const term = q.trim(); @@ -112,6 +121,34 @@ export function TopNav() { )} + + {open && !q.trim() && recentCodes.length > 0 && ( +
+
+ 최근 본 종목 + +
+
    + {recentCodes.map((c) => ( +
  • + setOpen(false)} + className="block px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-800/60" + > + {c} + +
  • + ))} +
+
+ )} typeof s === "string") : []; + } catch { + return []; + } +} + +function write(codes: string[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(KEY, JSON.stringify(codes)); + // 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch. + window.dispatchEvent(new CustomEvent("recent:change")); +} + +export const recent = { + get(): string[] { + return read(); + }, + push(code: string): void { + if (!code) return; + const cur = read().filter((c) => c !== code); + cur.unshift(code); + write(cur.slice(0, MAX)); + }, + clear(): void { + write([]); + }, + subscribe(cb: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const onStorage = (e: StorageEvent) => { + if (e.key === KEY) cb(); + }; + const onCustom = () => cb(); + window.addEventListener("storage", onStorage); + window.addEventListener("recent:change", onCustom as EventListener); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("recent:change", onCustom as EventListener); + }; + }, +};