feat(notes): per-code private investment note panel
- New lib/notes.ts: localStorage key `notes:<code>` 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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { AlertsPanel } from "../../components/AlertsPanel";
|
import { AlertsPanel } from "../../components/AlertsPanel";
|
||||||
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||||
|
import { InvestmentNote } from "../../components/InvestmentNote";
|
||||||
import { InvestorCumulative } from "../../components/InvestorCumulative";
|
import { InvestorCumulative } from "../../components/InvestorCumulative";
|
||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
||||||
@@ -198,6 +199,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
<OrderbookPanel code={code} />
|
<OrderbookPanel code={code} />
|
||||||
<PriceTargets code={code} current={current} onChange={setTargets} />
|
<PriceTargets code={code} current={current} onChange={setTargets} />
|
||||||
<AlertsPanel code={code} name={chart?.name} current={current} />
|
<AlertsPanel code={code} name={chart?.name} current={current} />
|
||||||
|
<InvestmentNote code={code} />
|
||||||
<RelatedStocks code={code} />
|
<RelatedStocks code={code} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
web/components/InvestmentNote.tsx
Normal file
107
web/components/InvestmentNote.tsx
Normal file
@@ -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<Note | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(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 (
|
||||||
|
<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">
|
||||||
|
{saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value.slice(0, NOTE_MAX_LEN))}
|
||||||
|
placeholder="이 종목에 대한 관전 포인트, 진입/청산 근거 등을 적어두세요."
|
||||||
|
rows={4}
|
||||||
|
className="w-full resize-y rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1.5 text-[12px] leading-relaxed text-zinc-100 outline-none focus:border-zinc-600"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] tabular-nums text-zinc-500">
|
||||||
|
{remaining.toLocaleString()}자 남음
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{feedback && (
|
||||||
|
<span className="text-[10px] text-zinc-400" aria-live="polite">
|
||||||
|
{feedback}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty}
|
||||||
|
className="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web/lib/notes.ts
Normal file
53
web/lib/notes.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 종목별 사용자 메모 (브라우저 한정 — 비로그인 앱).
|
||||||
|
//
|
||||||
|
// 키: `notes:<code>` → { text, updatedAt }
|
||||||
|
// 투자 결정의 근거/관전 포인트를 짧게 적어두는 용도. 다른 사용자에게는 보이지 않음.
|
||||||
|
|
||||||
|
const PREFIX = "notes:";
|
||||||
|
const MAX_LEN = 2000; // 너무 큰 입력 방어 — localStorage quota 보호.
|
||||||
|
|
||||||
|
export type Note = {
|
||||||
|
text: string;
|
||||||
|
updatedAt: string; // ISO
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readNote(code: string): Note | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Note>;
|
||||||
|
if (typeof parsed.text !== "string" || typeof parsed.updatedAt !== "string") return null;
|
||||||
|
return { text: parsed.text, updatedAt: parsed.updatedAt };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeNote(code: string, text: string): Note | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const trimmed = text.slice(0, MAX_LEN);
|
||||||
|
// 공백만이면 키 자체 제거 → 빈 잔여물 방지.
|
||||||
|
if (!trimmed.trim()) {
|
||||||
|
clearNote(code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const next: Note = { text: trimmed, updatedAt: new Date().toISOString() };
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return null; // quota 등 — UI 가 저장 실패 처리
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNote(code: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTE_MAX_LEN = MAX_LEN;
|
||||||
@@ -24,9 +24,10 @@ export function readTargets(code: string): Targets {
|
|||||||
export function writeTargets(code: string, t: Targets): void {
|
export function writeTargets(code: string, t: Targets): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
|
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
|
||||||
|
// 양수 유한값만 통과. UI 가 이미 가드하지만 lib 자체도 방어 — 외부에서 직접 호출돼도 안전.
|
||||||
const cleaned: Targets = {};
|
const cleaned: Targets = {};
|
||||||
if (t.target != null && Number.isFinite(t.target)) cleaned.target = t.target;
|
if (t.target != null && Number.isFinite(t.target) && t.target > 0) cleaned.target = t.target;
|
||||||
if (t.stop != null && Number.isFinite(t.stop)) cleaned.stop = t.stop;
|
if (t.stop != null && Number.isFinite(t.stop) && t.stop > 0) cleaned.stop = t.stop;
|
||||||
try {
|
try {
|
||||||
if (cleaned.target == null && cleaned.stop == null) {
|
if (cleaned.target == null && cleaned.stop == null) {
|
||||||
window.localStorage.removeItem(`${PREFIX}${code}`);
|
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user