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);
+ };
+ },
+};