feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3)

- 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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 13:28:58 +09:00
parent f4e1af83fe
commit 646fe7a7ab
3 changed files with 98 additions and 1 deletions

View File

@@ -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 {
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',

View File

@@ -184,7 +184,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
return (
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{!isTrash && showIntentBanner && (

View File

@@ -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 (
<div style={{
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>💭 <b> </b></span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
{title}
</span>
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays} </span>
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
onClick={onOpen}
style={{
background: '#4a7ec0', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void snoozeRecall()}
style={{
background: 'transparent', color: '#4a7ec0',
border: '1px solid #4a7ec0', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void dismissRecallNote(candidate.id)}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#888',
border: 'none', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
</div>
);
}
function computeAgeDays(refIso: string): number {
const refMs = new Date(refIso).getTime();
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
}