Files
inkling/docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
altair823 e6494b8778 docs(recall): #6 spec — RecallBanner + 4 telemetry events (v0.2.3)
mini-brainstorm 2개 결정:
- Q1=A: snooze in-memory (KST 다음 자정, ExpiryBanner 패턴 일관)
- Q2=B: ageDays = last_recalled_at ?? created_at 기준

자명 결정:
- Banner 위치: ExpiryBanner 다음 (stack 끝)
- 0건 시 null return
- "열어보기" 동작: scrollIntoView (NoteCard 항상 expanded)
- scroll target: id="note-${id}" (ref 시스템 복잡도 회피)

핵심 invariants 6개 + privacy invariant + tests 17개 약속.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:03:58 +09:00

12 KiB

v0.2.3 #6 리마인드 1 spike — Design Spec

작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #6 (7번째 / 마지막 cut)

1. Goal

Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (RecallBanner) 도입. 7일 이상 안 본 노트 중 가장 오래된 1건을 제시하여 사용자가 자기 기록을 재방문할 기회 제공. 4종 telemetry (recall_shown / recall_opened / recall_dismissed / recall_snoozed) 로 효과 측정 인프라 마련.

2. Decisions (mini-brainstorm 합의)

# 질문 선택 이유
Q1 다음에 snooze 영속화 A in-memory expiredSnoozeUntilMs 패턴 일관, schema migration v4 회피, dogfood telemetry 보고 v0.2.4 영속화 결정
Q2 ageDays 의미 B last_recalled_at ?? created_at 기준 algo 의 "7일 안 본 노트" trigger 와 동일 axis, 재추천 분포 측정 가치

자명 결정 (질문 없이 패턴 따름):

  • Banner 위치: ExpiryBanner 다음 (stack 끝, 시간 민감도 가장 낮음)
  • 0건 시: null return (ExpiryBanner 패턴)
  • Snooze duration: KST 다음 자정 (snoozeExpired 패턴)
  • "열어보기" 동작: scrollIntoView (NoteCard 항상 expanded — expand 동작 X)

3. Architecture & data flow

Inbox 마운트 시:
  loadInitial() → recallCandidate fetch (별도 fetch, 단일 노트 또는 null)

RecallBanner render (recallCandidate !== null && !snoozed):
  ┌─ "오늘 회상해볼 노트" + 노트 제목 + (N일 전)
  ├─ [열어보기] → scrollIntoView(noteCardRef) + markRecallOpened(id)
  │              → telemetry: recall_opened
  ├─ [다음에] → store.snoozeRecall() (KST 다음 자정까지 in-memory)
  │            → telemetry: recall_snoozed
  └─ [더 이상] → dismissRecall(id) (DB: recall_dismissed_at = now)
                 → telemetry: recall_dismissed

Banner 첫 렌더 시 자동 emit: recall_shown { noteId, ageDays }

다음 fetch 트리거:
  - markRecallOpened / dismissRecall 후 store 가 자동 다음 후보 fetch
  - refreshMeta (focus / inbox:noteUpdated) 도 fetch

3.1 Invariants

  1. 단일 후보 fetchLIMIT 1 + ORDER BY created_at ASC (가장 오래된 1건)
  2. KST 보정 — SQL 의 date('now') 자리 모두 date('now','+9 hours')
  3. 마감 임박 노트 제외due_date < today 인 노트는 ExpiryBanner 영역 (#5) 이라 회상 후보에서 빠짐
  4. Snooze in-memoryrecallSnoozeUntilMs store 변수, KST 다음 자정 (ExpiryBanner 패턴)
  5. emit 순서recall_shown (banner 첫 렌더) → recall_opened/dismissed/snoozed (사용자 액션)
  6. Snooze 시 recall_shown 1회만 — 같은 후보가 다시 보여도 recall_shown 재emit 안 함 (notes 1건당 session 1 shown — recallShownIds: Set<string> in-memory)

4. Components

4.1 NoteRepository

findRecallCandidate(): Note | null

SELECT * FROM notes
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
  AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
  AND ai_status = 'done'
  AND deleted_at IS NULL
  AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
ORDER BY created_at ASC
LIMIT 1

기존 hydrate(row) 사용 (이미 last_recalled_at / recall_dismissed_at 매핑 있음).

markRecallOpened(id: string, now: string): void

UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?

dismissRecall(id: string, now: string): void

UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?

4.2 CaptureService (5 신규 메서드)

async listRecallCandidate(): Promise<Note | null> {
  return this.repo.findRecallCandidate();
}

async markRecallOpened(noteId: string): Promise<{ note: Note }> {
  const note = this.repo.findById(noteId);
  if (!note) throw new Error(`note not found: ${noteId}`);
  this.repo.markRecallOpened(noteId, new Date().toISOString());
  if (this.deps.telemetry) {
    await this.deps.telemetry.emit({
      kind: 'recall_opened',
      payload: { noteId }
    }).catch(() => {});
  }
  return { note: this.repo.findById(noteId)! };
}

async dismissRecall(noteId: string): Promise<{ note: Note }> {
  this.repo.dismissRecall(noteId, new Date().toISOString());
  if (this.deps.telemetry) {
    await this.deps.telemetry.emit({
      kind: 'recall_dismissed',
      payload: { noteId }
    }).catch(() => {});
  }
  return { note: this.repo.findById(noteId)! };
}

async emitRecallShown(noteId: string): Promise<void> {
  const note = this.repo.findById(noteId);
  if (!note) return;
  const ageDays = this.computeAgeDays(note);
  if (this.deps.telemetry) {
    await this.deps.telemetry.emit({
      kind: 'recall_shown',
      payload: { noteId, ageDays }
    }).catch(() => {});
  }
}

async emitRecallSnoozed(noteId: string): Promise<void> {
  if (this.deps.telemetry) {
    await this.deps.telemetry.emit({
      kind: 'recall_snoozed',
      payload: { noteId }
    }).catch(() => {});
  }
}

private computeAgeDays(note: Note): number {
  const ref = note.lastRecalledAt ?? note.createdAt;
  const refMs = new Date(ref).getTime();
  const nowMs = Date.now();
  return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
}

4.3 IPC (5 신규 channels)

ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));

4.4 Preload + InboxApi shared type

// preload/index.ts
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
// shared/types.ts InboxApi
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;

4.5 telemetryEvents.ts zod

const RecallShownPayload = z.object({
  noteId: z.string().min(1),
  ageDays: z.number().int().nonnegative()
}).strict();

// recall_opened / recall_dismissed / recall_snoozed → 기존 NoteIdPayload 재사용

union 15 → 19 (recall_shown + recall_opened + recall_dismissed + recall_snoozed).

4.6 telemetryStats.ts

  • DailyRow +4 cols (recall_shown, recall_opened, recall_dismissed, recall_snoozed)
  • accumulators: recallShownCount, recallOpenedCount, recallDismissedCount, recallSnoozedCount, recallAgeDaysSum
  • summary lines:
    - 회상 추천: shown {N} / opened {O} / dismissed {D} / snoozed {S} (열림율 {O/N}%)
    - 회상 평균 ageDays: {avg}
    
    N=0 시 (데이터 없음)

4.7 TelemetryService.EmitInput union 15 → 19

4.8 Renderer store (src/renderer/inbox/store.ts)

interface InboxState {
  // ... existing ...
  recallCandidate: Note | null;
  recallSnoozeUntilMs: number | null;
  recallShownIds: Set<string>;  // session-local, "1 shown per note per session"
  loadRecallCandidate: () => Promise<void>;
  openRecall: (id: string) => Promise<void>;
  dismissRecallNote: (id: string) => Promise<void>;  // store action 명, IPC 와 구별
  snoozeRecall: () => Promise<void>;
}

refreshMeta + loadInitialloadRecallCandidate 도 호출.

openRecall(id):

  • inboxApi.markRecallOpened(id) → DB 갱신
  • loadRecallCandidate() → 다음 후보 fetch
  • (스크롤은 RecallBanner 컴포넌트가 자체 처리)

dismissRecallNote(id):

  • inboxApi.dismissRecall(id) → DB 갱신
  • loadRecallCandidate() → 다음 후보 fetch

snoozeRecall():

  • recallSnoozeUntilMs = nextKstMidnight() (snoozeExpired 패턴)
  • 현재 candidate noteId 기준 inboxApi.emitRecallSnoozed(id)

4.9 RecallBanner 컴포넌트

파일: src/renderer/inbox/components/RecallBanner.tsx (신규)

  • 위치: <ExpiryBanner /> 다음 (App.tsx)
  • 첫 렌더 시 useEffectrecallShownIds 체크 후 미emit 시 inboxApi.emitRecallShown(id) 호출 + Set 에 추가
  • Banner UI: 노트 제목 + ageDays + 3개 버튼 (열어보기 / 다음에 / 더 이상)
  • null return: candidate=null OR snoozed (Date.now < snoozeUntilMs)
  • snoozeUntilMs 만료 시 setTimeout re-render 트리거 (ExpiryBanner 패턴)

4.10 NoteCard ref 시스템 (scroll target)

App.tsx 가 noteRefs: Map<noteId, HTMLDivElement | null> ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 scrollIntoView({ behavior: 'smooth', block: 'center' }) 호출.

구체 구현:

  • App.tsxuseRef<Map<string, HTMLDivElement | null>>(new Map()) 보유
  • <NoteCard>ref={(el) => { noteRefs.current.set(note.id, el); }} 전달 (NoteCard 가 ref forwardRef 지원 필요)
  • RecallBanner 가 noteRefs prop 으로 받아 사용

대안 (단순): document.getElementById(\note-${id}`)— App.tsx 의 NoteCard 가id={`note-${note.id}`}` 만 추가하면 됨. 이 spike 에선 이 단순 방식 채택 (ref 시스템 복잡도 회피).

5. Privacy invariant

  • recall_shown.payload: { noteId, ageDays } — noteId 기존 패턴, ageDays 정수
  • recall_opened/dismissed/snoozed.payload: { noteId }NoteIdPayload 재사용
  • .strict() zod 가드 + extra field 거부 테스트

6. Tests (≥17개)

NoteRepository.test.ts (5)

  1. 빈 db → null
  2. last_recalled_at 5일 전 노트 제외 (7일 이내)
  3. last_recalled_at 8일 전 노트 후보 (7일 초과)
  4. recall_dismissed_at 25일 전 제외, 35일 전 후보
  5. deleted_at / ai_status='pending' / due_date < today 모두 제외

CaptureService.test.ts (4)

  1. listRecallCandidate → repo.findRecallCandidate
  2. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
  3. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
  4. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)

telemetryEvents.test.ts (3)

  1. recall_shown valid parse (noteId + ageDays)
  2. recall_shown extra field 거부 (privacy)
  3. recall_opened/dismissed/snoozed valid parse (noteId only)

telemetryStats.test.ts (2)

  1. shown/opened/dismissed/snoozed 누적 + 열림율 계산
  2. 평균 ageDays 계산

store.recall.test.ts (신규, 3)

  1. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
  2. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
  3. dismissRecallNote → 후보 다시 fetch

총 신규 단위 17개. 기존 단위 386 + 17 = 403 예상.

7. Out of scope

(roadmap §3 #6 + 본 cut 결정)

  • 잠금해제 hook (F4-A, strategy.md)
  • 무작위 토스트 (F4-D)
  • ambient if-then (F4-B)
  • 임베딩 유사도 추천 (#3 vocab 후속)
  • spaced repetition (Leitner / SM-2)
  • 다중 후보 추천 (현재 LIMIT 1 only)
  • snooze 영속화 (Q1=A in-memory)
  • 사용자 정의 회상 주기 (7일 hardcoded)
  • "회상 history" 보기 (last_recalled_at 만 저장, 이전 history X)
  • RecallBanner 컴포넌트 단위 테스트 (Inkling 패턴: store 단위만 테스트)

8. Gates (roadmap §3.1)

  • typecheck 0
  • 단위 386 → 403 (+17), 모두 통과
  • e2e 1/1
  • 새 SQL: 복합 조건 — idx_notes_ai_status + idx_notes_created_at 활용. 별도 인덱스 불필요.

9. strategy.md 갱신 (별도 task)

roadmap §3 #6 In 절: §2.3 / §4.3 / §8 갱신:

  • Capitalize 본격 진입 (회상 surface 도입)
  • "오늘 회상" surface 정의
  • F4-A/B/D deferred 항목의 측정 인프라 마련 명시 (recall_* telemetry 가 그 기반)

10. Roadmap relation

  • v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
  • 머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프
  • v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정