feat(targets): price target/stop simulator with chart overlay lines

- New lib/targets.ts: per-code localStorage (key `targets:<code>`) with
  read/write/clear; rejects non-positive/non-finite values.
- New PriceTargets sidebar panel: 목표가/손절가 inputs, live %-from-current
  preview (rose=up, sky=down), save/clear actions.
- StockChart accepts optional `targets` prop and draws dashed horizontal
  price lines on the candle series (rose=target, sky=stop) with axis labels.
- Page loads saved targets on mount/code-change and re-renders chart on save.

Also fixes view-counter dedupe caveat from reviewer: split into
wasRecordedToday() + markRecorded(); the page now marks localStorage only
after POST /api/views succeeds, so a failed POST stays retryable on the
next visit instead of being silently swallowed for the day.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-29 00:01:43 +09:00
parent ecf8b9112b
commit a4a4a7f98c
5 changed files with 279 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ import { PeriodReturns } from "../../components/PeriodReturns";
import { PeriodTabs, 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 { StarButton } from "../../components/StarButton";
import { StockChart } from "../../components/StockChart";
@@ -21,7 +22,8 @@ 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 { shouldRecordView } from "../../lib/views";
import { readTargets, type Targets } from "../../lib/targets";
import { markRecorded, wasRecordedToday } from "../../lib/views";
export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params;
@@ -30,6 +32,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
const [err, setErr] = useState<string | null>(null);
const [period, setPeriod] = useState<Period>("3M");
const [viewsToday, setViewsToday] = useState<number | null>(null);
const [targets, setTargets] = useState<Targets>({});
const spec = periodSpec(period);
const isIntraday = spec.interval === "10m";
@@ -49,11 +52,17 @@ export default function CodePage({ params }: { params: { code: string } }) {
const apply = (n: number) => {
if (alive) setViewsToday(n);
};
if (shouldRecordView(code)) {
api.recordView(code).then((r) => apply(r.today_views)).catch(() => {
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
});
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(() => {});
}
@@ -62,6 +71,11 @@ export default function CodePage({ params }: { params: { code: string } }) {
};
}, [code]);
// 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드.
useEffect(() => {
setTargets(readTargets(code));
}, [code]);
useEffect(() => {
let alive = true;
setErr(null);
@@ -169,7 +183,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
/>
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<div>
<StockChart chart={chart} prediction={prediction} />
<StockChart chart={chart} prediction={prediction} targets={targets} />
{isIntraday && chart.intraday_status && (
<div className="mt-2 text-right text-[11px] text-zinc-500">
10 · 60 · [{chart.intraday_status}]
@@ -182,6 +196,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
<div className="space-y-4">
<SymbolSidebar code={code} />
<OrderbookPanel code={code} />
<PriceTargets code={code} current={current} onChange={setTargets} />
<AlertsPanel code={code} name={chart?.name} current={current} />
<RelatedStocks code={code} />
</div>