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>
This commit is contained in:
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# 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. **단일 후보 fetch** — `LIMIT 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-memory** — `recallSnoozeUntilMs` 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`
|
||||
|
||||
```sql
|
||||
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`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
#### `dismissRecall(id: string, now: string): void`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
### 4.2 `CaptureService` (5 신규 메서드)
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
// 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),
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
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`)
|
||||
|
||||
```typescript
|
||||
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` + `loadInitial` 가 `loadRecallCandidate` 도 호출.
|
||||
|
||||
`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)
|
||||
- 첫 렌더 시 `useEffect` 가 `recallShownIds` 체크 후 미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.tsx` 가 `useRef<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)
|
||||
6. listRecallCandidate → repo.findRecallCandidate
|
||||
7. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
|
||||
8. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
|
||||
9. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)
|
||||
|
||||
### telemetryEvents.test.ts (3)
|
||||
10. recall_shown valid parse (noteId + ageDays)
|
||||
11. recall_shown extra field 거부 (privacy)
|
||||
12. recall_opened/dismissed/snoozed valid parse (noteId only)
|
||||
|
||||
### telemetryStats.test.ts (2)
|
||||
13. shown/opened/dismissed/snoozed 누적 + 열림율 계산
|
||||
14. 평균 ageDays 계산
|
||||
|
||||
### store.recall.test.ts (신규, 3)
|
||||
15. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
|
||||
16. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
|
||||
17. 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 영속화 결정
|
||||
Reference in New Issue
Block a user