From 442cc38c3202d69fa7030dc39e708e881011bb5e Mon Sep 17 00:00:00 2001 From: claude-owner Date: Fri, 29 May 2026 00:05:33 +0900 Subject: [PATCH] feat(notes): per-code private investment note panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/notes.ts: localStorage key `notes:` storing { text, updatedAt }, 2000-char cap, blank/whitespace removes the key. - New InvestmentNote sidebar panel: textarea + character counter, save button enabled only when dirty, "방금 전 / N분 전 / 오늘 HH:MM / YYYY-MM-DD" relative timestamp on the last save, short save-confirmation toast. - Mounted in [code]/page.tsx sidebar after AlertsPanel. Also tightens writeTargets() to require strictly positive finite values, matching the UI's parseNum() guard so the lib is safe under direct external calls (reviewer's non-blocking note on a4a4a7f). Co-Authored-By: Claude Opus 4.7 --- web/app/[code]/page.tsx | 2 + web/components/InvestmentNote.tsx | 107 ++++++++++++++++++++++++++++++ web/lib/notes.ts | 53 +++++++++++++++ web/lib/targets.ts | 5 +- 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 web/components/InvestmentNote.tsx create mode 100644 web/lib/notes.ts diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 7bbea2d..a2b8dbb 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { AlertsPanel } from "../../components/AlertsPanel"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; +import { InvestmentNote } from "../../components/InvestmentNote"; import { InvestorCumulative } from "../../components/InvestorCumulative"; import { MetricsPanel } from "../../components/MetricsPanel"; import { PeerComparePanel } from "../../components/PeerComparePanel"; @@ -198,6 +199,7 @@ export default function CodePage({ params }: { params: { code: string } }) { + diff --git a/web/components/InvestmentNote.tsx b/web/components/InvestmentNote.tsx new file mode 100644 index 0000000..a3ee5bd --- /dev/null +++ b/web/components/InvestmentNote.tsx @@ -0,0 +1,107 @@ +"use client"; + +// 종목 페이지의 사이드 메모 패널. +// Toss "투자 메모" 패턴 — 진입 이유, 관전 포인트를 짧게 적는 자리. +// localStorage 만 사용, 서버 전송 없음. + +import { useEffect, useMemo, useState } from "react"; +import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes"; + +type Props = { + code: string; +}; + +// '방금 전', '5분 전', '오늘 14:30', 'YYYY-MM-DD' 단계로 압축. +function fmtUpdatedAt(iso: string): string { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return ""; + const now = Date.now(); + const diffSec = Math.max(0, Math.floor((now - t) / 1000)); + if (diffSec < 60) return "방금 전"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}분 전`; + const d = new Date(t); + const pad = (n: number) => String(n).padStart(2, "0"); + const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + // 같은 날짜면 '오늘 HH:MM', 아니면 'YYYY-MM-DD' + const today = new Date(); + if ( + today.getFullYear() === d.getFullYear() && + today.getMonth() === d.getMonth() && + today.getDate() === d.getDate() + ) { + return `오늘 ${hm}`; + } + return ymd; +} + +export function InvestmentNote({ code }: Props) { + const [text, setText] = useState(""); + const [saved, setSaved] = useState(null); + const [feedback, setFeedback] = useState(null); + + useEffect(() => { + const n = readNote(code); + setSaved(n); + setText(n?.text ?? ""); + setFeedback(null); + }, [code]); + + const dirty = (saved?.text ?? "") !== text; + + // 저장 직후 잠깐 띄우는 toast 같은 안내. dirty 또는 종목 전환 시 자동 해제. + useEffect(() => { + if (!feedback) return; + const h = window.setTimeout(() => setFeedback(null), 2500); + return () => window.clearTimeout(h); + }, [feedback]); + + const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]); + + const save = () => { + const next = writeNote(code, text); + setSaved(next); + setText(next?.text ?? ""); + setFeedback(next ? "저장됨" : "저장 안됨"); + }; + + return ( +
+
+

투자 메모

+ + {saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장"} + +
+