feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7 final) #19

Merged
altair823 merged 11 commits from feat/v023-recall-spike into main 2026-05-02 04:52:48 +00:00
Owner

Summary

v0.2.3 dogfood feedback roadmap §3 #6 cut — 마지막 항목 (7/7). Inbox ���단에 "오늘 회상해볼 노트" 1건 추천 배너 + 4종 telemetry. 머지 후 v0.2.3 cut 7/7 완료 → binary 빌드 단계.

Decisions (mini-brainstorm)

  • Q1=A: snooze in-memory (KST 자정, ExpiryBanner 패턴 일관)
  • Q2=B: ageDays = last_recalled_at ?? created_at 기준 (algo trigger 와 동일 axis)

자명 결정: Banner 위치 = ExpiryBanner 다음 / 0건 시 null return / "열어보기" = scrollIntoView / scroll target = id="note-${id}".

Changes

  • NoteRepository: findRecallCandidate() (KST 보정 + 7일/30일/마감 + LIMIT 1) + markRecallOpened + dismissRecall
  • CaptureService: 5 methods (list / open / dismiss / emitShown / emitSnoozed) + private computeAgeDays
  • IPC + preload + InboxApi types: 5 channels
  • telemetry: RecallShownPayload zod (.strict for privacy) + 4 union members + stats 누적 (열림율 + 평균 ageDays)
  • TelemetryService.EmitInput: union 15 → 19
  • Renderer store: recallCandidate + recallSnoozeUntilMs + recallShownIds + 4 actions
  • RecallBanner 컴포넌트 (파란 테마, ExpiryBanner 다음 위치)
  • NoteCard outermost div id="note-${note.id}" (scrollIntoView target)
  • strategy.md: §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신

Spec & plan

  • Spec: docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
  • Plan: docs/superpowers/plans/2026-05-02-v023-recall-spike.md

Test Plan

  • typecheck 0
  • 단위 386 → 403 (+17)
  • e2e 1/1
  • dogfood: 7일 이상 안 본 노트 추천 동작 확인
  • dogfood: 일정 시간 후 stats.md 의 "회상 추천" 열림율 + 평균 ageDays 확인
  • dogfood: "다음에" 클릭 시 KST 자정까지 숨김 검증

Roadmap

머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프.

## Summary v0.2.3 dogfood feedback roadmap §3 #6 cut — **마지막 항목** (7/7). Inbox ���단에 "오늘 회상해볼 노트" 1건 추천 배너 + 4종 telemetry. 머지 후 v0.2.3 cut 7/7 완료 → binary 빌드 단계. ## Decisions (mini-brainstorm) - Q1=A: snooze in-memory (KST 자정, ExpiryBanner 패턴 일관) - Q2=B: ageDays = `last_recalled_at ?? created_at` 기준 (algo trigger 와 동일 axis) 자명 결정: Banner 위치 = ExpiryBanner 다음 / 0건 시 null return / "열어보기" = scrollIntoView / scroll target = `id="note-${id}"`. ## Changes - **NoteRepository**: `findRecallCandidate()` (KST 보정 + 7일/30일/마감 + LIMIT 1) + `markRecallOpened` + `dismissRecall` - **CaptureService**: 5 methods (list / open / dismiss / emitShown / emitSnoozed) + private computeAgeDays - **IPC + preload + InboxApi types**: 5 channels - **telemetry**: `RecallShownPayload` zod (.strict for privacy) + 4 union members + stats 누적 (열림율 + 평균 ageDays) - **TelemetryService.EmitInput**: union 15 → 19 - **Renderer store**: `recallCandidate` + `recallSnoozeUntilMs` + `recallShownIds` + 4 actions - **RecallBanner** 컴포넌트 (파란 테마, ExpiryBanner 다음 위치) - **NoteCard** outermost div `id="note-${note.id}"` (scrollIntoView target) - **strategy.md**: §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신 ## Spec & plan - Spec: docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md - Plan: docs/superpowers/plans/2026-05-02-v023-recall-spike.md ## Test Plan - [x] typecheck 0 - [x] 단위 386 → 403 (+17) - [x] e2e 1/1 - [ ] dogfood: 7일 이상 안 본 노트 추천 동작 확인 - [ ] dogfood: 일정 시간 후 stats.md 의 "회상 추천" 열림율 + 평균 ageDays 확인 - [ ] dogfood: "다음에" 클릭 시 KST 자정까지 숨김 검증 ## Roadmap 머지 후 v0.2.3 cut **7/7 완료** → v0.2.3 binary 빌드 + 핸드오프.
altair823 added 10 commits 2026-05-02 04:33:43 +00:00
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>
8 task TDD plan:
T1 NoteRepository (find/markOpened/dismiss, +5 cases)
T2 telemetryEvents (recall_shown 4 union members, +3 cases)
T3 telemetryStats + EmitInput union 19 (+2 cases)
T4 CaptureService (5 methods, +4 cases)
T5 IPC + preload + types (5 channels)
T6 Renderer store (recallCandidate + 4 actions, +3 cases)
T7 RecallBanner + App.tsx + NoteCard id
T8 closure (strategy.md + roadmap + gates)

총 신규 단위 +17. 단위 386 → 403 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1
- markRecallOpened(id, now): last_recalled_at 갱신
- dismissRecall(id, now): recall_dismissed_at 갱신
- KST 보정 SQL date('now','+9 hours')
- 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- RecallShownPayload { noteId, ageDays: int>=0 } .strict()
- recall_opened/dismissed/snoozed → NoteIdPayload 재사용
- TelemetryEventSchema union 15 → 19
- 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
                 "- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- listRecallCandidate(): repo.findRecallCandidate 위임
- markRecallOpened(id): last_recalled_at 갱신 + recall_opened emit
- dismissRecall(id): recall_dismissed_at 갱신 + recall_dismissed emit
- emitRecallShown(id): ageDays 계산 + recall_shown emit
- emitRecallSnoozed(id): recall_snoozed emit
- private computeAgeDays(note): last_recalled_at ?? created_at 기준 일수
- 단위 +4 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ipcMain.handle: list/markOpened/dismiss/emitShown/emitSnoozed
- preload inboxApi: 5 entries (ipcRenderer.invoke)
- shared/types InboxApi: 5 method signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- recallCandidate, recallSnoozeUntilMs, recallShownIds (Set) state
- loadInitial / refreshMeta 가 listRecallCandidate Promise.all 합류
- loadRecallCandidate / openRecall / dismissRecallNote / snoozeRecall actions
- snoozeRecall: KST 다음 자정 (snoozeExpired 패턴 일관) + emitRecallSnoozed
- openRecall / dismissRecallNote: API 호출 후 다음 후보 fetch
- 신규 store.recall.test.ts +3 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
- strategy.md §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신
- typecheck 0 / 단위 403 / e2e 1
- v0.2.3 7/7 — 모든 cut 완료. 다음: v0.2.3 binary 빌드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Round 1 review (controller-side)

항목 결과
Critical 0
Important 1 (i1: recall_shown double-emit race)
Minor 4 (m1~m4)
Nit 3 (n1: KST inline 4번째 복제, n2: NoteCard id 문서화, n3: ipcMain.on vs handle)

i1 (Important) — recall_shown double-emit race (RecallBanner.tsx:24-30)

useEffect deps에 shownIds 가 있어 setState 후 재트리거. React 18 batching 으로 평소엔 OK 지만 candidate 가 빠르게 새로 들어올 때 emit 2회 가능성.
Fix: useState 대신 useRef 로 shownIds 보유 — re-render 트리거 X, race 차단.

m1 (Minor) — snoozeRecall candidate-null 시 emit 누락 (store.ts:215-226)

candidate=null 일 때 snooze 만 set 되고 emit skip. 의도적이지만 명시 코멘트 필요.
Fix: 1줄 코멘트 추가.

m2 (Minor) — snooze+dismiss 시 snoozeUntilMs 미clear (store.ts:210-214)

사용자가 "다음에" 후 다음 candidate 로 dismiss 시 이전 snooze 가 새 candidate 에도 적용. 의도 위반.
Fix: dismissRecallNoteset({ recallSnoozeUntilMs: null }) 추가.

m3 (Minor) — 미사용 local before (CaptureService.ts:194)

Fix: rename before_ 또는 inline check.

m4 (Minor) — 빈 rawText + null aiTitle 시 빈 제목 (RecallBanner.tsx:36)

Fix: '(제목 없음)' fallback 추가.

n2 (Nit) — NoteCard id 의 load-bearing 의미 코멘트

Fix: 1줄 코멘트.

n1 / n3 (skip)

  • n1: KST inline 4번째 복제 — 프로젝트 전반 패턴, v0.2.4 backlog nextKstMidnightMs 통합 영역
  • n3: ipcMain.on vs handle — 다른 IPC 와 패턴 일관성 우선

Plan

i1+m1+m2+m3+m4+n2 fix 후 round 2 자체 verify. n1+n3 skip.

Verdict

APPROVE WITH FIX — 6개 inline 수정 후 round 2.

## Round 1 review (controller-side) | 항목 | 결과 | |---|---| | Critical | 0 | | Important | 1 (i1: recall_shown double-emit race) | | Minor | 4 (m1~m4) | | Nit | 3 (n1: KST inline 4번째 복제, n2: NoteCard id 문서화, n3: ipcMain.on vs handle) | ### i1 (Important) — recall_shown double-emit race (`RecallBanner.tsx:24-30`) useEffect deps에 `shownIds` 가 있어 setState 후 재트리거. React 18 batching 으로 평소엔 OK 지만 candidate 가 빠르게 새로 들어올 때 emit 2회 가능성. **Fix**: useState 대신 useRef 로 `shownIds` 보유 — re-render 트리거 X, race 차단. ### m1 (Minor) — snoozeRecall candidate-null 시 emit 누락 (`store.ts:215-226`) candidate=null 일 때 snooze 만 set 되고 emit skip. 의도적이지만 명시 코멘트 필요. **Fix**: 1줄 코멘트 추가. ### m2 (Minor) — snooze+dismiss 시 snoozeUntilMs 미clear (`store.ts:210-214`) 사용자가 "다음에" 후 다음 candidate 로 dismiss 시 이전 snooze 가 새 candidate 에도 적용. 의도 위반. **Fix**: `dismissRecallNote` 가 `set({ recallSnoozeUntilMs: null })` 추가. ### m3 (Minor) — 미사용 local `before` (`CaptureService.ts:194`) **Fix**: rename `before` → `_` 또는 inline check. ### m4 (Minor) — 빈 rawText + null aiTitle 시 빈 제목 (`RecallBanner.tsx:36`) **Fix**: `'(제목 없음)'` fallback 추가. ### n2 (Nit) — NoteCard id 의 load-bearing 의미 코멘트 **Fix**: 1줄 코멘트. ### n1 / n3 (skip) - n1: KST inline 4번째 복제 — 프로젝트 전반 패턴, v0.2.4 backlog `nextKstMidnightMs` 통합 영역 - n3: ipcMain.on vs handle — 다른 IPC 와 패턴 일관성 우선 ### Plan i1+m1+m2+m3+m4+n2 fix 후 round 2 자체 verify. n1+n3 skip. ### Verdict **APPROVE WITH FIX** — 6개 inline 수정 후 round 2.
altair823 added 1 commit 2026-05-02 04:39:14 +00:00
- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
  store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트

skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
      n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Round 2 — APPROVE

항목 결과
Round 1 fix commit 61b6fa6
i1 (race) shownIds useState → useRef + store 필드 제거
m1 (snooze emit comment)
m2 (dismiss clear snooze)
m3 (dead before) inline check
m4 (제목 fallback) '(제목 없음)'
n2 (NoteCard id 코멘트)
Round 2 verdict APPROVE (0 new issue)
Gates typecheck 0 / 단위 403 / e2e 1

skip 항목:

  • n1: KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs util 통합 영역
  • n3: ipcMain.on vs handle — 다른 IPC 와 패턴 일관

머지 후 알려줘. closure 단계 (local main sync + 브랜치 정리 + memory backlog + v0.2.3 cut 7/7 완료 표시 + binary 빌드 안내) 진행.

## Round 2 — APPROVE | 항목 | 결과 | |---|---| | Round 1 fix commit | `61b6fa6` | | i1 (race) | ✅ shownIds useState → useRef + store 필드 제거 | | m1 (snooze emit comment) | ✅ | | m2 (dismiss clear snooze) | ✅ | | m3 (dead `before`) | ✅ inline check | | m4 (제목 fallback) | ✅ '(제목 없음)' | | n2 (NoteCard id 코멘트) | ✅ | | Round 2 verdict | **APPROVE** (0 new issue) | | Gates | typecheck 0 / 단위 403 / e2e 1 | skip 항목: - n1: KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 `nextKstMidnightMs` util 통합 영역 - n3: ipcMain.on vs handle — 다른 IPC 와 패턴 일관 머지 후 알려줘. closure 단계 (local main sync + 브랜치 정리 + memory backlog + v0.2.3 cut **7/7 완료** 표시 + binary 빌드 안내) 진행.
altair823 merged commit cb29ef6f89 into main 2026-05-02 04:52:48 +00:00
altair823 deleted branch feat/v023-recall-spike 2026-05-02 04:52:50 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/inkling#19