Files
stock_chart_site/web/lib/lines.ts
claude-owner d6fc9d8a0b feat(symbol-page): intraday short-term returns + hline storage-failure guard
- IntradayReturns: 10분/30분/1시간/3시간/시가대비 mini cards for 1D mode
  (PeriodReturns labels lie when interval is 10m; swap them out by isIntraday)
- addLine/removeLine/clearLines now return previous state when localStorage
  write fails — UI no longer diverges from storage in locked-storage env

addresses reviewer 318eae6 non-blocking caveat about write-failure divergence
2026-05-29 00:35:21 +09:00

108 lines
3.6 KiB
TypeScript

// 종목별 사용자 수평선(horizontal price lines) 저장소.
// TradingView/Toss 식 그리기 도구의 가장 가벼운 형태 — 가격 라인만, per-code, localStorage.
// 차트 자체는 라인 ID 를 모르고 좌표만 받음. 추가/삭제는 이 모듈을 통해서만.
const KEY = (code: string) => `hlines:${code}`;
export const HLINE_MAX = 10;
export type HLine = {
id: string;
price: number;
// 작성 시각 — 디버그/정렬용. UI 에선 노출 안 해도 됨.
createdAt: string;
};
function safeStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function readLines(code: string): HLine[] {
const s = safeStorage();
if (!s) return [];
try {
const raw = s.getItem(KEY(code));
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const out: HLine[] = [];
for (const item of parsed) {
if (
item &&
typeof item === "object" &&
typeof (item as HLine).id === "string" &&
typeof (item as HLine).price === "number" &&
Number.isFinite((item as HLine).price) &&
(item as HLine).price > 0
) {
out.push({
id: (item as HLine).id,
price: (item as HLine).price,
createdAt:
typeof (item as HLine).createdAt === "string"
? (item as HLine).createdAt
: new Date().toISOString(),
});
}
}
return out.slice(0, HLINE_MAX);
} catch {
return [];
}
}
function writeRaw(code: string, lines: HLine[]): boolean {
const s = safeStorage();
if (!s) return false;
try {
if (lines.length === 0) s.removeItem(KEY(code));
else s.setItem(KEY(code), JSON.stringify(lines.slice(0, HLINE_MAX)));
return true;
} catch {
return false;
}
}
// id 충돌 가능성이 거의 없는 short id. crypto.randomUUID 미지원 환경 폴백.
function newId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
// price 같음 검사용 — 유효숫자 4 자리 정도 비교로 부동소수 오차 무시.
function nearlyEqual(a: number, b: number): boolean {
const denom = Math.max(Math.abs(a), Math.abs(b), 1);
return Math.abs(a - b) / denom < 1e-4;
}
export function addLine(code: string, price: number): HLine[] {
if (!Number.isFinite(price) || price <= 0) return readLines(code);
const cur = readLines(code);
// 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발.
if (cur.some((l) => nearlyEqual(l.price, price))) return cur;
const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }];
// 저장 실패 시 이전 상태 반환 — UI 가 보여주는 라인과 스토리지가 어긋나
// 새로고침 후 사라지는 혼란을 막음 (시크릿 탭/스토리지 잠금 환경 대비).
if (!writeRaw(code, next)) return cur;
return next.slice(0, HLINE_MAX);
}
export function removeLine(code: string, id: string): HLine[] {
const cur = readLines(code);
const next = cur.filter((l) => l.id !== id);
// 삭제 저장 실패 시 이전 상태 유지 — UI 에서 사라졌다가 새로고침에 부활하면 더 혼란.
if (!writeRaw(code, next)) return cur;
return next;
}
export function clearLines(code: string): HLine[] {
if (!writeRaw(code, [])) return readLines(code);
return [];
}