From 646fe7a7ab801c3410927434d3d310c3035f88d6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 13:28:58 +0900 Subject: [PATCH] feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecallBanner: 노트 제목 + N일 전 + 3 버튼 (열어보기/다음에/더 이상) - 첫 렌더 시 emitRecallShown (recallShownIds Set 으로 per-session 1회 제약) - snoozeUntilMs 만료 setTimeout (ExpiryBanner 패턴) - 위치: ExpiryBanner 다음 (banner stack 끝) - NoteCard 외곽 div 에 id="note-${note.id}" — "열어보기" scrollIntoView target - 컬러 테마: 파랑 (#e8f0fe / #4a7ec0) — 다른 banner (적/황/적) 와 구별 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/App.tsx | 2 + src/renderer/inbox/components/NoteCard.tsx | 2 +- .../inbox/components/RecallBanner.tsx | 95 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/renderer/inbox/components/RecallBanner.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 2fb772e..b9b8719 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -11,6 +11,7 @@ import { RecoveryToast } from './components/RecoveryToast.js'; import { TagUndoToast } from './components/TagUndoToast.js'; import { ExpiryBanner } from './components/ExpiryBanner.js'; import { FailedBanner } from './components/FailedBanner.js'; +import { RecallBanner } from './components/RecallBanner.js'; export function App(): React.ReactElement { const { @@ -86,6 +87,7 @@ export function App(): React.ReactElement { + {tagFilter !== null && (
+
{formatted}
{!isTrash && showIntentBanner && ( diff --git a/src/renderer/inbox/components/RecallBanner.tsx b/src/renderer/inbox/components/RecallBanner.tsx new file mode 100644 index 0000000..231d5e3 --- /dev/null +++ b/src/renderer/inbox/components/RecallBanner.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import { useInbox } from '../store.js'; +import { inboxApi } from '../api.js'; + +export function RecallBanner(): React.ReactElement | null { + const candidate = useInbox((s) => s.recallCandidate); + const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs); + const shownIds = useInbox((s) => s.recallShownIds); + const openRecall = useInbox((s) => s.openRecall); + const dismissRecallNote = useInbox((s) => s.dismissRecallNote); + const snoozeRecall = useInbox((s) => s.snoozeRecall); + + // ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render + const [, setTick] = useState(0); + useEffect(() => { + if (snoozeUntilMs === null) return; + const remaining = snoozeUntilMs - Date.now(); + if (remaining <= 0) return; + const t = setTimeout(() => setTick((n) => n + 1), remaining); + return () => clearTimeout(t); + }, [snoozeUntilMs]); + + // first-render emit recall_shown (per-session 1회 per note) + useEffect(() => { + if (!candidate) return; + if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return; + if (shownIds.has(candidate.id)) return; + void inboxApi.emitRecallShown(candidate.id); + useInbox.setState({ recallShownIds: new Set([...shownIds, candidate.id]) }); + }, [candidate, snoozeUntilMs, shownIds]); + + if (candidate === null) return null; + if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null; + + const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt); + const title = candidate.aiTitle ?? candidate.rawText.slice(0, 60); + + function onOpen() { + void openRecall(candidate!.id); + const el = document.getElementById(`note-${candidate!.id}`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + return ( +
+
+ 💭 오늘 회상해볼 노트 + + {title} + + {ageDays}일 전 +
+
+ + + +
+
+ ); +} + +function computeAgeDays(refIso: string): number { + const refMs = new Date(refIso).getTime(); + return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000)); +}