Files
stock_chart_site/web/components/InvestmentNote.tsx
claude-owner f9027f5365 feat(sidebar): collapsible AlertsPanel / InvestmentNote / RelatedStocks
Three lower-sidebar panels are now wrapped in a shared Collapsible so
mobile readers can fold away sections they're not actively using.

New shared pieces:
- lib/collapsible.ts — readOpen/writeOpen helpers backed by
  localStorage under 'panel-open:<name>'. Per-panel-name (not
  per-code) because the preference is a panel-level habit; users
  don't want different fold state for every symbol.
- components/Collapsible.tsx — button-as-header + chevron + body.
  aria-expanded for AT. Body uses 'hidden' instead of conditional
  unmount so textarea text, partial alert form input, and
  RelatedStocks fetch results all survive collapse cycles.
  SSR-safe: useState(defaultOpen) then useEffect-sync to avoid React
  hydration mismatch when localStorage diverges from default.

Panel refactors:
- RelatedStocks: outer rounded div + header replaced with Collapsible;
  loading/error/empty/data states all rendered inside body (header
  stays visible so user can still toggle/recognize the section even
  while loading).
- InvestmentNote: same swap. saved.updatedAt drives the subtitle.
- AlertsPanel: same swap. current price drives the subtitle.

Defaults are open=true everywhere; the surface area is the toggle
itself, not a forced collapse. User's first interaction sets the
sticky preference.
2026-06-01 10:26:33 +09:00

118 lines
4.0 KiB
TypeScript

"use client";
// 종목 페이지의 사이드 메모 패널.
// Toss "투자 메모" 패턴 — 진입 이유, 관전 포인트를 짧게 적는 자리.
// localStorage 만 사용, 서버 전송 없음.
// Collapsible 본문은 hidden 으로만 가리므로 접어도 textarea dirty 텍스트 유지.
import { useEffect, useMemo, useState } from "react";
import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes";
import { Collapsible } from "./Collapsible";
const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = {
saved: "저장됨",
deleted: "삭제됨",
failed: "저장 안됨",
};
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 res = writeNote(code, text);
if (res.status === "saved") {
setSaved(res.note);
setText(res.note.text);
} else if (res.status === "deleted") {
setSaved(null);
setText("");
}
// failed: 입력값 유지 — 사용자가 재시도하거나 일부를 줄일 수 있도록.
setFeedback(FEEDBACK_TEXT[res.status]);
};
const subtitle = saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장";
return (
<Collapsible title="투자 메모" subtitle={subtitle} storageKey="note">
<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>
</Collapsible>
);
}