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:
@@ -14,6 +14,7 @@ import { PeriodReturns } from "../../components/PeriodReturns";
|
|||||||
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||||
import { PriceHero } from "../../components/PriceHero";
|
import { PriceHero } from "../../components/PriceHero";
|
||||||
|
import { PriceTargets } from "../../components/PriceTargets";
|
||||||
import { RelatedStocks } from "../../components/RelatedStocks";
|
import { RelatedStocks } from "../../components/RelatedStocks";
|
||||||
import { StarButton } from "../../components/StarButton";
|
import { StarButton } from "../../components/StarButton";
|
||||||
import { StockChart } from "../../components/StockChart";
|
import { StockChart } from "../../components/StockChart";
|
||||||
@@ -21,7 +22,8 @@ import { SymbolSidebar } from "../../components/SymbolSidebar";
|
|||||||
import { TradingValuePanel } from "../../components/TradingValuePanel";
|
import { TradingValuePanel } from "../../components/TradingValuePanel";
|
||||||
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
||||||
import { recent } from "../../lib/recent";
|
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 } }) {
|
export default function CodePage({ params }: { params: { code: string } }) {
|
||||||
const { code } = params;
|
const { code } = params;
|
||||||
@@ -30,6 +32,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [period, setPeriod] = useState<Period>("3M");
|
const [period, setPeriod] = useState<Period>("3M");
|
||||||
const [viewsToday, setViewsToday] = useState<number | null>(null);
|
const [viewsToday, setViewsToday] = useState<number | null>(null);
|
||||||
|
const [targets, setTargets] = useState<Targets>({});
|
||||||
|
|
||||||
const spec = periodSpec(period);
|
const spec = periodSpec(period);
|
||||||
const isIntraday = spec.interval === "10m";
|
const isIntraday = spec.interval === "10m";
|
||||||
@@ -49,11 +52,17 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
const apply = (n: number) => {
|
const apply = (n: number) => {
|
||||||
if (alive) setViewsToday(n);
|
if (alive) setViewsToday(n);
|
||||||
};
|
};
|
||||||
if (shouldRecordView(code)) {
|
if (!wasRecordedToday(code)) {
|
||||||
api.recordView(code).then((r) => apply(r.today_views)).catch(() => {
|
api.recordView(code)
|
||||||
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
|
.then((r) => {
|
||||||
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
// POST 성공 후에만 mark — 실패 시 다음 방문에 다시 시도 가능.
|
||||||
});
|
markRecorded(code);
|
||||||
|
apply(r.today_views);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
|
||||||
|
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -62,6 +71,11 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
};
|
};
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
|
// 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드.
|
||||||
|
useEffect(() => {
|
||||||
|
setTargets(readTargets(code));
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setErr(null);
|
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 className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||||
<div>
|
<div>
|
||||||
<StockChart chart={chart} prediction={prediction} />
|
<StockChart chart={chart} prediction={prediction} targets={targets} />
|
||||||
{isIntraday && chart.intraday_status && (
|
{isIntraday && chart.intraday_status && (
|
||||||
<div className="mt-2 text-right text-[11px] text-zinc-500">
|
<div className="mt-2 text-right text-[11px] text-zinc-500">
|
||||||
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
|
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
|
||||||
@@ -182,6 +196,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SymbolSidebar code={code} />
|
<SymbolSidebar code={code} />
|
||||||
<OrderbookPanel code={code} />
|
<OrderbookPanel code={code} />
|
||||||
|
<PriceTargets code={code} current={current} onChange={setTargets} />
|
||||||
<AlertsPanel code={code} name={chart?.name} current={current} />
|
<AlertsPanel code={code} name={chart?.name} current={current} />
|
||||||
<RelatedStocks code={code} />
|
<RelatedStocks code={code} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
154
web/components/PriceTargets.tsx
Normal file
154
web/components/PriceTargets.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 목표가 / 손절가 시뮬레이터 (Toss "내 목표가" read-only 클론).
|
||||||
|
// - 현재가 기준 변동률 미리 계산해서 표시
|
||||||
|
// - 저장 시 localStorage 에만 — 차트 오버레이는 부모 page 가 같은 상태를 읽어 StockChart 에 전달
|
||||||
|
// - 알림은 별도 AlertsPanel 담당, 여기는 시각화 + 손익 프리뷰만
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { readTargets, writeTargets, type Targets } from "../lib/targets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
code: string;
|
||||||
|
current: number | null;
|
||||||
|
// 저장/해제 시 부모에 변경을 전달 — 차트에 즉시 반영하도록.
|
||||||
|
onChange?: (t: Targets) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseNum(s: string): number | null {
|
||||||
|
const cleaned = s.replace(/,/g, "").trim();
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(price: number | null, current: number | null): number | null {
|
||||||
|
if (price == null || current == null || current <= 0) return null;
|
||||||
|
return ((price - current) / current) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceTargets({ code, current, onChange }: Props) {
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
const [stop, setStop] = useState("");
|
||||||
|
const [saved, setSaved] = useState<Targets>({});
|
||||||
|
|
||||||
|
// 종목 바뀌면 새로 로드. 입력창도 저장값으로 초기화.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = readTargets(code);
|
||||||
|
setSaved(t);
|
||||||
|
setTarget(t.target != null ? String(t.target) : "");
|
||||||
|
setStop(t.stop != null ? String(t.stop) : "");
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const targetN = useMemo(() => parseNum(target), [target]);
|
||||||
|
const stopN = useMemo(() => parseNum(stop), [stop]);
|
||||||
|
const targetPct = pct(targetN, current);
|
||||||
|
const stopPct = pct(stopN, current);
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
(targetN ?? null) !== (saved.target ?? null) ||
|
||||||
|
(stopN ?? null) !== (saved.stop ?? null);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const next: Targets = { target: targetN, stop: stopN };
|
||||||
|
writeTargets(code, next);
|
||||||
|
setSaved(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
const clear = () => {
|
||||||
|
setTarget("");
|
||||||
|
setStop("");
|
||||||
|
const next: Targets = {};
|
||||||
|
writeTargets(code, next);
|
||||||
|
setSaved(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPct = (p: number | null): string =>
|
||||||
|
p == null ? "" : `${p >= 0 ? "+" : ""}${p.toFixed(2)}%`;
|
||||||
|
const pctCls = (p: number | null): string =>
|
||||||
|
p == null ? "text-zinc-500" : p >= 0 ? "text-rose-400" : "text-sky-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
|
||||||
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-zinc-300">내 목표가</h3>
|
||||||
|
<span className="text-[10px] text-zinc-500">브라우저 저장 · 비공개</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Row
|
||||||
|
label="목표가"
|
||||||
|
color="bg-rose-400/80"
|
||||||
|
value={target}
|
||||||
|
onChange={setTarget}
|
||||||
|
deltaText={fmtPct(targetPct)}
|
||||||
|
deltaCls={pctCls(targetPct)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="손절가"
|
||||||
|
color="bg-sky-400/80"
|
||||||
|
value={stop}
|
||||||
|
onChange={setStop}
|
||||||
|
deltaText={fmtPct(stopPct)}
|
||||||
|
deltaCls={pctCls(stopPct)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/80 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
disabled={saved.target == null && saved.stop == null && !target && !stop}
|
||||||
|
className="rounded-md border border-zinc-800 px-2 py-1 text-[11px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{current == null && (
|
||||||
|
<div className="mt-1 text-[10px] text-zinc-500">현재가 로딩 중 — 변동률은 잠시 후 표시</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
deltaText,
|
||||||
|
deltaCls,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (s: string) => void;
|
||||||
|
deltaText: string;
|
||||||
|
deltaCls: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-sm ${color}`} aria-hidden />
|
||||||
|
<span className="w-12 shrink-0 text-zinc-400">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="min-w-0 flex-1 rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1 text-right tabular-nums text-zinc-100 outline-none focus:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<span className={`w-16 shrink-0 text-right tabular-nums ${deltaCls}`}>
|
||||||
|
{deltaText}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type CandlestickData,
|
type CandlestickData,
|
||||||
type HistogramData,
|
type HistogramData,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
|
type IPriceLine,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
type LineData,
|
type LineData,
|
||||||
type LogicalRange,
|
type LogicalRange,
|
||||||
@@ -13,10 +14,13 @@ import {
|
|||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||||
|
import type { Targets } from "../lib/targets";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
chart: ChartPayload;
|
chart: ChartPayload;
|
||||||
prediction?: LatestPredictionResponse | null;
|
prediction?: LatestPredictionResponse | null;
|
||||||
|
// 사용자가 저장한 목표가/손절가 — 메인 캔들 위 가로 라인 오버레이.
|
||||||
|
targets?: Targets | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로.
|
// KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로.
|
||||||
@@ -198,7 +202,7 @@ function macd(
|
|||||||
return { macd: m, signal: sig, hist };
|
return { macd: m, signal: sig, hist };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StockChart({ chart, prediction }: Props) {
|
export function StockChart({ chart, prediction, targets }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
@@ -215,6 +219,9 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
|
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||||
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
|
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||||
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||||
|
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
|
||||||
|
const targetLineRef = useRef<IPriceLine | null>(null);
|
||||||
|
const stopLineRef = useRef<IPriceLine | null>(null);
|
||||||
|
|
||||||
// 보조 차트 crosshair 동기화용 time→value lookup.
|
// 보조 차트 crosshair 동기화용 time→value lookup.
|
||||||
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
|
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
|
||||||
@@ -430,6 +437,43 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
chartRef.current.timeScale().fitContent();
|
chartRef.current.timeScale().fitContent();
|
||||||
}, [prediction, isIntraday]);
|
}, [prediction, isIntraday]);
|
||||||
|
|
||||||
|
// 목표가/손절가 가로 라인 — targets prop 또는 캔들 시리즈 재생성 시 동기화.
|
||||||
|
// candleRef 가 바뀌면 이전 라인 ref 는 이미 해제된 상태라 그냥 새로 만들면 됨.
|
||||||
|
useEffect(() => {
|
||||||
|
const candle = candleRef.current;
|
||||||
|
if (!candle) return;
|
||||||
|
// 이전 라인 제거 후 새로 그림 — 입력 바뀔 때마다 가격값 변경하지 않고 그냥 재생성.
|
||||||
|
if (targetLineRef.current) {
|
||||||
|
try { candle.removePriceLine(targetLineRef.current); } catch { /* 이미 해제됨 */ }
|
||||||
|
targetLineRef.current = null;
|
||||||
|
}
|
||||||
|
if (stopLineRef.current) {
|
||||||
|
try { candle.removePriceLine(stopLineRef.current); } catch { /* 이미 해제됨 */ }
|
||||||
|
stopLineRef.current = null;
|
||||||
|
}
|
||||||
|
if (!targets) return;
|
||||||
|
if (targets.target != null && Number.isFinite(targets.target)) {
|
||||||
|
targetLineRef.current = candle.createPriceLine({
|
||||||
|
price: targets.target,
|
||||||
|
color: "#fb7185", // rose-400 (한국 상승색 톤)
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: 2, // dashed
|
||||||
|
axisLabelVisible: true,
|
||||||
|
title: "목표",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (targets.stop != null && Number.isFinite(targets.stop)) {
|
||||||
|
stopLineRef.current = candle.createPriceLine({
|
||||||
|
price: targets.stop,
|
||||||
|
color: "#60a5fa", // sky-400 (한국 하락색 톤)
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: 2,
|
||||||
|
axisLabelVisible: true,
|
||||||
|
title: "손절",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [targets, isIntraday]);
|
||||||
|
|
||||||
// RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스.
|
// RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스.
|
||||||
// priceScale fix (0-100) + 30/70 가이드 라인.
|
// priceScale fix (0-100) + 30/70 가이드 라인.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
49
web/lib/targets.ts
Normal file
49
web/lib/targets.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// 종목별 목표가/손절가 저장 (브라우저 한정 — 비로그인 앱).
|
||||||
|
//
|
||||||
|
// 키: `targets:<code>` → { target?: number, stop?: number, savedAt: ISO }
|
||||||
|
// 차트 오버레이 라인과 손익 미리보기에 사용. 알림 없음 (그건 AlertsPanel 담당).
|
||||||
|
|
||||||
|
const PREFIX = "targets:";
|
||||||
|
|
||||||
|
export type Targets = {
|
||||||
|
target?: number | null; // 익절가 (선택)
|
||||||
|
stop?: number | null; // 손절가 (선택)
|
||||||
|
savedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readTargets(code: string): Targets {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
|
||||||
|
return raw ? (JSON.parse(raw) as Targets) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTargets(code: string, t: Targets): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
|
||||||
|
const cleaned: Targets = {};
|
||||||
|
if (t.target != null && Number.isFinite(t.target)) cleaned.target = t.target;
|
||||||
|
if (t.stop != null && Number.isFinite(t.stop)) cleaned.stop = t.stop;
|
||||||
|
try {
|
||||||
|
if (cleaned.target == null && cleaned.stop == null) {
|
||||||
|
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleaned.savedAt = new Date().toISOString();
|
||||||
|
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(cleaned));
|
||||||
|
} catch {
|
||||||
|
/* quota 초과 등 — 무시 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTargets(code: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,11 +38,17 @@ function writeLogged(m: LoggedMap): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 오늘 처음 보는 code 면 true, 이미 봤으면 false. true 인 경우 mark 까지 수행. */
|
/** 오늘 이미 카운트된 종목인지. POST 호출 전 dedupe 게이트. */
|
||||||
export function shouldRecordView(code: string): boolean {
|
export function wasRecordedToday(code: string): boolean {
|
||||||
|
const today = kstToday();
|
||||||
|
const logged = readLogged();
|
||||||
|
return logged[code] === today;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST 성공 이후 호출. 실패 시 마크하지 않아 다음 방문에 재시도 가능. */
|
||||||
|
export function markRecorded(code: string): void {
|
||||||
const today = kstToday();
|
const today = kstToday();
|
||||||
const logged = readLogged();
|
const logged = readLogged();
|
||||||
if (logged[code] === today) return false;
|
|
||||||
logged[code] = today;
|
logged[code] = today;
|
||||||
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
|
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
|
||||||
const entries = Object.entries(logged);
|
const entries = Object.entries(logged);
|
||||||
@@ -52,5 +58,4 @@ export function shouldRecordView(code: string): boolean {
|
|||||||
} else {
|
} else {
|
||||||
writeLogged(logged);
|
writeLogged(logged);
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user