From 746671059ea471f2d7a88397e2100295edcbf704 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 13:08:32 +0900 Subject: [PATCH] =?UTF-8?q?docs(recall):=20#6=20plan=20=E2=80=94=208=20tas?= =?UTF-8?q?ks=20TDD=20+=2017=20=EB=8B=A8=EC=9C=84=20cases=20(v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-02-v023-recall-spike.md | 1343 +++++++++++++++++ 1 file changed, 1343 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-v023-recall-spike.md diff --git a/docs/superpowers/plans/2026-05-02-v023-recall-spike.md b/docs/superpowers/plans/2026-05-02-v023-recall-spike.md new file mode 100644 index 0000000..71e485e --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-v023-recall-spike.md @@ -0,0 +1,1343 @@ +# v0.2.3 #6 리마인드 1 spike Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (RecallBanner) + 4종 telemetry (recall_shown / recall_opened / recall_dismissed / recall_snoozed). + +**Architecture:** `repo.findRecallCandidate()` SQL (KST 보정 + 7일/30일/마감 조건 + LIMIT 1) → CaptureService 5 methods → 5 IPC channels → renderer store `recallCandidate` + 3 actions → RecallBanner 컴포넌트 (ExpiryBanner 다음 위치). Snooze in-memory KST 다음 자정. "열어보기" → `document.getElementById('note-${id}').scrollIntoView()`. + +**Tech Stack:** better-sqlite3 12.9, zod 4.3.6, zustand 5, vitest 4, TypeScript strict. Schema 변경 없음 (m003 가 이미 last_recalled_at + recall_dismissed_at 컬럼 prealloc). + +**Spec:** `docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md` + +--- + +## File Structure + +| File | Role | Action | +|------|------|--------| +| `src/main/repository/NoteRepository.ts` | DB layer | +`findRecallCandidate()` +`markRecallOpened(id, now)` +`dismissRecall(id, now)` | +| `src/main/services/telemetryEvents.ts` | zod schema | +`RecallShownPayload` + 4 union members → 15→19 | +| `src/main/services/telemetryStats.ts` | 누적 통계 | DailyRow +4 cols, accumulators, summary 2 lines | +| `src/main/services/TelemetryService.ts` | EmitInput type | union 15→19 | +| `src/main/services/CaptureService.ts` | recall API | +5 methods (list / open / dismiss / emit shown / emit snoozed) | +| `src/main/ipc/inboxApi.ts` | IPC | +5 handlers | +| `src/preload/index.ts` | preload | +5 inboxApi entries | +| `src/shared/types.ts` | InboxApi type | +5 method signatures | +| `src/renderer/inbox/store.ts` | zustand state | +recallCandidate +recallSnoozeUntilMs +recallShownIds + 4 actions | +| `src/renderer/inbox/components/RecallBanner.tsx` | UI | 신규 컴포넌트 | +| `src/renderer/inbox/App.tsx` | mount | RecallBanner 렌더 + NoteCard 에 `id={\`note-${note.id}\`}` | +| `src/renderer/inbox/components/NoteCard.tsx` | id attr | wrapper div 에 id 추가 | +| `tests/unit/NoteRepository.test.ts` | 테스트 | +5 cases | +| `tests/unit/telemetryEvents.test.ts` | 테스트 | +3 cases | +| `tests/unit/telemetryStats.test.ts` | 테스트 | +2 cases | +| `tests/unit/CaptureService.test.ts` | 테스트 | +4 cases | +| `tests/unit/store.recall.test.ts` | 신규 테스트 파일 | +3 cases | +| `docs/strategy.md` | strategy 갱신 | §2.3/§4.3/§8 | +| `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` | roadmap mark | #6 ✓ 완료 | + +총 신규 단위 17개. 단위 386 → **403** 예상. + +--- + +## Task 1: NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: Write failing test — empty db returns null** + +Append inside `describe('NoteRepository', ...)` (just before closing `});`): + +```typescript +it('findRecallCandidate returns null for empty db', () => { + expect(repo.findRecallCandidate()).toBeNull(); +}); +``` + +- [ ] **Step 2: Run test, verify fail** + +Run: `npm test -- NoteRepository` +Expected: FAIL — `repo.findRecallCandidate is not a function` + +- [ ] **Step 3: Implement 3 methods** + +In `src/main/repository/NoteRepository.ts`, add the 3 methods after `setNextRunAt(...)` method (existing `getTopUsedTags`/`getTagIdByName` are nearby — place these alongside in repo conventions): + +```typescript + /** + * v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선. + * - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전) + * - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트 + * - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today) + * KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가. + */ + findRecallCandidate(): Note | null { + const row = this.db + .prepare( + `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` + ) + .get() as Record | undefined; + return row ? this.hydrate(row) : null; + } + + /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */ + markRecallOpened(id: string, now: string): void { + this.db + .prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`) + .run(now, now, id); + } + + /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */ + dismissRecall(id: string, now: string): void { + this.db + .prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`) + .run(now, now, id); + } +``` + +- [ ] **Step 4: Run test, verify pass** + +Run: `npm test -- NoteRepository` +Expected: PASS + +- [ ] **Step 5: Add 4 more cases** + +Append the following tests after the `findRecallCandidate returns null` case: + +```typescript +it('findRecallCandidate excludes notes recalled within 7 days', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + // 5일 전 본 노트 → 제외 + const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString(); + repo.markRecallOpened(id, fiveDaysAgo); + expect(repo.findRecallCandidate()).toBeNull(); +}); + +it('findRecallCandidate includes notes recalled 8+ days ago', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString(); + repo.markRecallOpened(id, eightDaysAgo); + expect(repo.findRecallCandidate()?.id).toBe(id); +}); + +it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString(); + const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString(); + repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨 + repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능 + const candidate = repo.findRecallCandidate(); + expect(candidate?.id).toBe(b); +}); + +it('findRecallCandidate excludes deleted/pending/imminent due', () => { + const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10); + const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10); + // (a) deleted + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.trash(a, new Date().toISOString()); + // (b) pending (no AI) + repo.create({ rawText: 'b' }); + // (c) due_date 어제 + const c = repo.create({ rawText: 'c' }).id; + repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' }); + expect(repo.findRecallCandidate()).toBeNull(); + // (d) due_date today 는 OK (>=today 통과) + const d = repo.create({ rawText: 'd' }).id; + repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' }); + expect(repo.findRecallCandidate()?.id).toBe(d); +}); +``` + +- [ ] **Step 6: Run all tests, verify pass** + +Run: `npm test -- NoteRepository` +Expected: PASS — 5 new cases (1 from step 1 + 4 from step 5). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 2: telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` +- Test: `tests/unit/telemetryEvents.test.ts` + +- [ ] **Step 1: Write failing tests** + +Append to END of `tests/unit/telemetryEvents.test.ts`: + +```typescript +describe('validateEvent — recall', () => { + it('accepts recall_shown event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'recall_shown', + payload: { noteId: 'n1', ageDays: 14 } + }); + expect(e.kind).toBe('recall_shown'); + }); + + it('rejects recall_shown with extra field (privacy)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'recall_shown', + payload: { noteId: 'n1', ageDays: 14, content: 'leak' } + })).toThrow(); + }); + + it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => { + for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) { + const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } }); + expect(e.kind).toBe(kind); + } + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- telemetryEvents` +Expected: FAIL — discriminator missing recall_* kinds. + +- [ ] **Step 3: Modify telemetryEvents.ts** + +Add `RecallShownPayload` after the existing `TagVocabMissPayload` (around line 53-55): + +```typescript +const RecallShownPayload = z.object({ + noteId: z.string().min(1), + ageDays: z.number().int().nonnegative() +}).strict(); +``` + +Then in `TelemetryEventSchema` discriminatedUnion (currently ends with `tag_vocab_miss` entry), append 4 entries before `]);`: + +```typescript + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict() +]); +``` + +(Add comma after `tag_vocab_miss` line.) + +- [ ] **Step 4: Run, verify pass** + +Run: `npm test -- telemetryEvents` +Expected: PASS — 3 new cases (recall_shown, extra field reject, recall_opened/dismissed/snoozed). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts +git commit -m "$(cat <<'EOF' +feat(recall): telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 3: telemetryStats — 4 cols accumulator + summary + TelemetryService.EmitInput union 19 + +**Files:** +- Modify: `src/main/services/telemetryStats.ts` +- Modify: `src/main/services/TelemetryService.ts:18-31` (EmitInput union) +- Test: `tests/unit/telemetryStats.test.ts` + +- [ ] **Step 1: Write failing tests** + +Append to END of `describe('aggregateStats', ...)` block in `tests/unit/telemetryStats.test.ts`: + +```typescript + it('aggregates recall events with open rate + average ageDays', () => { + const events: TelemetryEvent[] = [ + e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }), + e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }), + e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }), + e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }), + e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }), + e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }), + e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }), + e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' }) + ]; + const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1'); + expect(r.md).toContain('열림율 50.0%'); + expect(r.md).toContain('회상 평균 ageDays: 25'); // (10+20+30+40)/4 + }); + + it('회상 summary shows 데이터 없음 when no recall events', () => { + const r = aggregateStats([], new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('회상 추천'); + expect(r.md).toContain('데이터 없음'); + }); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- telemetryStats` +Expected: FAIL — recall summary lines missing. + +- [ ] **Step 3: Modify telemetryStats.ts** + +Apply 5 changes: + +**3a) DailyRow interface — add 4 fields after `tag_vocab_miss: number;`:** + +```typescript +interface DailyRow { + date: string; + capture: number; + ai_succeeded: number; + ai_failed: number; + trash: number; + restore: number; + permanent_delete: number; + empty_trash: number; + expired_banner_shown: number; + expired_batch_trash: number; + ollama_unreachable: number; + ollama_recovered: number; + ollama_recheck_manual: number; + ai_retry_manual: number; + tag_vocab_hit: number; + tag_vocab_miss: number; + recall_shown: number; + recall_opened: number; + recall_dismissed: number; + recall_snoozed: number; +} +``` + +**3b) Add 5 accumulator vars after `let tagVocabMissCount = 0;`:** + +```typescript + let tagVocabHitCount = 0; + let tagVocabMissCount = 0; + let recallShownCount = 0; + let recallOpenedCount = 0; + let recallDismissedCount = 0; + let recallSnoozedCount = 0; + let recallAgeDaysSum = 0; +``` + +**3c) Update row init in for-loop — add 4 fields:** + +Find: +```typescript + ai_retry_manual: 0, + tag_vocab_hit: 0, tag_vocab_miss: 0 + }; +``` + +Replace with: +```typescript + ai_retry_manual: 0, + tag_vocab_hit: 0, tag_vocab_miss: 0, + recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0 + }; +``` + +**3d) Add 4 branches after `tag_vocab_miss` branch:** + +Find: +```typescript + } else if (ev.kind === 'tag_vocab_miss') { + row.tag_vocab_miss += 1; + tagVocabMissCount += 1; + } +``` + +Replace with: +```typescript + } else if (ev.kind === 'tag_vocab_miss') { + row.tag_vocab_miss += 1; + tagVocabMissCount += 1; + } else if (ev.kind === 'recall_shown') { + row.recall_shown += 1; + recallShownCount += 1; + recallAgeDaysSum += ev.payload.ageDays; + } else if (ev.kind === 'recall_opened') { + row.recall_opened += 1; + recallOpenedCount += 1; + } else if (ev.kind === 'recall_dismissed') { + row.recall_dismissed += 1; + recallDismissedCount += 1; + } else if (ev.kind === 'recall_snoozed') { + row.recall_snoozed += 1; + recallSnoozedCount += 1; + } +``` + +**3e) Add recall summary computation + lines.** + +After existing `const tagVocabSummary = ...`: + +```typescript + const recallSummary = recallShownCount === 0 + ? '(데이터 없음)' + : `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`; + const recallAvgAge = recallShownCount === 0 + ? '(데이터 없음)' + : `${Math.round(recallAgeDaysSum / recallShownCount)}`; +``` + +**3f) Update table header/separator — add 4 columns:** + +Find: +```typescript + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |'); + lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|'); +``` + +Replace with: +```typescript + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |'); + lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|'); +``` + +**3g) Update table row push — add 4 cells:** + +Find: +```typescript + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`); +``` + +Replace with: +```typescript + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`); +``` + +**3h) Add summary lines after existing 태그 vocab line:** + +Find: +```typescript + lines.push(`- 태그 vocab: ${tagVocabSummary}`); +``` + +Replace with: +```typescript + lines.push(`- 태그 vocab: ${tagVocabSummary}`); + lines.push(`- 회상 추천: ${recallSummary}`); + lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`); +``` + +- [ ] **Step 4: Modify TelemetryService.EmitInput union** + +In `src/main/services/TelemetryService.ts`, find the `EmitInput` union (currently 15 arms ending with `tag_vocab_miss`). Append 4 new arms before the closing `;`: + +```typescript + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } + | { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } } + | { kind: 'recall_opened'; payload: { noteId: string } } + | { kind: 'recall_dismissed'; payload: { noteId: string } } + | { kind: 'recall_snoozed'; payload: { noteId: string } }; +``` + +- [ ] **Step 5: Run, verify pass** + +Run: `npm test -- telemetryStats` +Expected: PASS — 2 new cases. + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 386 → 391 (existing TelemetryService.test.ts narrowing guards may need extension — handled in Task 4). + +If TelemetryService.test.ts narrowing guards fail at runtime (not just typecheck), include them in this task. Otherwise leave for next task. + +- [ ] **Step 6: Extend narrowing guards in TelemetryService.test.ts** + +In `tests/unit/TelemetryService.test.ts`, update the 2 narrowing guard chains (lines around 151 + 167) to also exclude `recall_opened`, `recall_dismissed`, `recall_snoozed` (recall_shown HAS noteId so doesn't need exclusion — but wait, all 4 recall events HAVE noteId so they should NOT be in the noteId-less exclusion list. Re-check.). + +Actually, all 4 recall events (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`) have `noteId` in payload. So narrowing guards do NOT need updating. + +Verify: run `npm test -- TelemetryService` → PASS expected. If pass, no change. If fail, update guards accordingly. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/services/telemetryStats.ts src/main/services/TelemetryService.ts tests/unit/telemetryStats.test.ts +git commit -m "$(cat <<'EOF' +feat(recall): telemetryStats + EmitInput — recall 누적 + 열림율 + 평균 ageDays (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 4: CaptureService — listRecallCandidate + markRecallOpened + dismissRecall + emitRecallShown + emitRecallSnoozed + +**Files:** +- Modify: `src/main/services/CaptureService.ts` +- Test: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: Write failing tests** + +Append to END of `describe('CaptureService', ...)` block in `tests/unit/CaptureService.test.ts`: + +```typescript + it('listRecallCandidate delegates to repo.findRecallCandidate', async () => { + const id = repo.create({ rawText: 'old' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + // No last_recalled_at → eligible immediately + const candidate = await service.listRecallCandidate(); + expect(candidate?.id).toBe(id); + }); + + it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + const before = repo.findById(id)!.lastRecalledAt; + expect(before).toBeNull(); + await service.markRecallOpened(id); + expect(repo.findById(id)!.lastRecalledAt).not.toBeNull(); + expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined(); + }); + + it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + expect(repo.findById(id)!.recallDismissedAt).toBeNull(); + await service.dismissRecall(id); + expect(repo.findById(id)!.recallDismissedAt).not.toBeNull(); + expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined(); + }); + + it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + // Backdate created_at to 14 days ago + db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`) + .run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id); + await service.emitRecallShown(id); + const shown = emits.find((e) => e.kind === 'recall_shown'); + expect(shown).toBeDefined(); + const payload = shown!.payload as { noteId: string; ageDays: number }; + expect(payload.noteId).toBe(id); + expect(payload.ageDays).toBeGreaterThanOrEqual(13); + expect(payload.ageDays).toBeLessThanOrEqual(15); + }); +``` + +(Confirm `emits` and `service`/`repo`/`db` are set up in the describe's `beforeEach`. If not, look at existing CaptureService tests for setup pattern and adapt.) + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- CaptureService` +Expected: FAIL — `service.listRecallCandidate is not a function` (or similar). + +- [ ] **Step 3: Modify CaptureService.ts** + +Add 5 new methods to the `CaptureService` class (place after the existing `retryAllFailed` method or wherever recall-related methods naturally cluster). Also add a private helper `computeAgeDays`: + +```typescript + /** v0.2.3 #6 — 회상 후보 1건 fetch. */ + async listRecallCandidate(): Promise { + return this.repo.findRecallCandidate(); + } + + /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */ + async markRecallOpened(noteId: string): Promise<{ note: Note }> { + const before = this.repo.findById(noteId); + if (!before) 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)! }; + } + + /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */ + 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)! }; + } + + /** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */ + async emitRecallShown(noteId: string): Promise { + 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(() => {}); + } + } + + /** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */ + async emitRecallSnoozed(noteId: string): Promise { + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'recall_snoozed', + payload: { noteId } + }).catch(() => {}); + } + } + + /** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */ + 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)); + } +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `npm test -- CaptureService` +Expected: PASS — 4 new cases. + +Run: `npm run typecheck` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts +git commit -m "$(cat <<'EOF' +feat(recall): CaptureService — 5 methods (list/open/dismiss/shown/snoozed) (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 5: IPC + preload + InboxApi types — 5 channels + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` + +- [ ] **Step 1: Modify inboxApi.ts** + +In `src/main/ipc/inboxApi.ts`, add 5 handlers. Place them near other recent handlers (look for `inbox:retryAllFailed` and add nearby): + +```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)); +``` + +- [ ] **Step 2: Modify preload/index.ts** + +Add 5 entries to the `inboxApi` object exported by preload (place near `retryAllFailed`): + +```typescript + 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), +``` + +- [ ] **Step 3: Modify shared/types.ts InboxApi** + +In `src/shared/types.ts`, add 5 method signatures to the `InboxApi` interface: + +```typescript + listRecallCandidate(): Promise; + markRecallOpened(id: string): Promise<{ note: Note }>; + dismissRecall(id: string): Promise<{ note: Note }>; + emitRecallShown(id: string): Promise; + emitRecallSnoozed(id: string): Promise; +``` + +- [ ] **Step 4: Verify** + +Run: `npm run typecheck` +Expected: 0 errors. (No new tests in this task — IPC plumbing covered by integration via Tasks 6-7.) + +Run: `npm test` +Expected: 393/393 (Task 4 added 4, total now 386+5+3+2+4=400; this task adds 0). + +(If totals differ, recount and proceed if all green.) + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts +git commit -m "$(cat <<'EOF' +feat(recall): IPC + preload + InboxApi — 5 channels (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 6: Renderer store — recallCandidate + 4 actions + +**Files:** +- Modify: `src/renderer/inbox/store.ts` +- Create: `tests/unit/store.recall.test.ts` + +- [ ] **Step 1: Write failing test (new file)** + +Create `tests/unit/store.recall.test.ts`. Look at existing `tests/unit/store.aiRetry.test.ts` for the pattern (vi.mock the inboxApi, manipulate store directly, assert state). Adapt: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Note } from '@shared/types'; + +const inboxApiMock = { + listRecallCandidate: vi.fn(), + markRecallOpened: vi.fn(), + dismissRecall: vi.fn(), + emitRecallShown: vi.fn(), + emitRecallSnoozed: vi.fn(), + // ... pad with other inboxApi methods used by loadInitial/refreshMeta if needed + listNotes: vi.fn(async () => []), + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + getTrashCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => []), + getFailedCount: vi.fn(async () => 0) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: inboxApiMock })); + +import { useInbox } from '../../src/renderer/inbox/store.js'; + +const note = (id: string): Note => ({ + id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc', + tags: [], aiStatus: 'done', aiProvider: null, aiGeneratedAt: null, aiError: null, + titleEditedByUser: false, summaryEditedByUser: false, + dueDate: null, dueDateEditedByUser: false, + userIntent: null, intentPromptedAt: null, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + deletedAt: null, lastRecalledAt: null, recallDismissedAt: null +}); + +describe('store recall actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + useInbox.setState({ + recallCandidate: null, + recallSnoozeUntilMs: null, + recallShownIds: new Set() + } as Partial>); + }); + + it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => { + useInbox.setState({ recallCandidate: note('n1') } as Partial>); + await useInbox.getState().snoozeRecall(); + const ms = useInbox.getState().recallSnoozeUntilMs; + expect(ms).not.toBeNull(); + expect(ms!).toBeGreaterThan(Date.now()); + expect(inboxApiMock.emitRecallSnoozed).toHaveBeenCalledWith('n1'); + }); + + it('openRecall calls API + emits recall_shown once per note (recallShownIds)', async () => { + inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') }); + inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null); // no next candidate + await useInbox.getState().openRecall('n1'); + expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1'); + // openRecall doesn't emit recall_shown — that's RecallBanner's job. This test just checks + // the mark/dismiss flow. + }); + + it('dismissRecallNote calls API + fetches next candidate', async () => { + inboxApiMock.dismissRecall.mockResolvedValueOnce({ note: note('n1') }); + inboxApiMock.listRecallCandidate.mockResolvedValueOnce(note('n2')); + await useInbox.getState().dismissRecallNote('n1'); + expect(inboxApiMock.dismissRecall).toHaveBeenCalledWith('n1'); + expect(useInbox.getState().recallCandidate?.id).toBe('n2'); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- store.recall` +Expected: FAIL — `useInbox.getState().snoozeRecall is not a function`. + +- [ ] **Step 3: Modify renderer store** + +In `src/renderer/inbox/store.ts`: + +**3a) Add fields to `InboxState` interface:** + +```typescript +interface InboxState { + // ... existing ... + failedCount: number; + recallCandidate: Note | null; + recallSnoozeUntilMs: number | null; + recallShownIds: Set; + // ... existing actions ... + retryAllFailed: () => Promise; + loadRecallCandidate: () => Promise; + openRecall: (id: string) => Promise; + dismissRecallNote: (id: string) => Promise; + snoozeRecall: () => Promise; +} +``` + +**3b) Initialize new state fields in `create(...)`:** + +After `failedCount: 0,`: +```typescript + failedCount: 0, + recallCandidate: null, + recallSnoozeUntilMs: null, + recallShownIds: new Set(), +``` + +**3c) Update `loadInitial` to also fetch recallCandidate:** + +Find: +```typescript + async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false }); + }, +``` + +Replace with: +```typescript + async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount(), + inboxApi.listRecallCandidate() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false }); + }, +``` + +**3d) Update `refreshMeta` similarly:** + +Find: +```typescript + async refreshMeta() { + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount() + ]); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount }); + }, +``` + +Replace with: +```typescript + async refreshMeta() { + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount(), + inboxApi.listRecallCandidate() + ]); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate }); + }, +``` + +**3e) Add 4 actions at the end (after `retryAllFailed`):** + +```typescript + async loadRecallCandidate() { + const recallCandidate = await inboxApi.listRecallCandidate(); + set({ recallCandidate }); + }, + async openRecall(id) { + await inboxApi.markRecallOpened(id); + // 다음 후보 fetch + const recallCandidate = await inboxApi.listRecallCandidate(); + set({ recallCandidate }); + }, + async dismissRecallNote(id) { + await inboxApi.dismissRecall(id); + const recallCandidate = await inboxApi.listRecallCandidate(); + set({ recallCandidate }); + }, + async snoozeRecall() { + const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + const now = Date.now(); + const kstNow = now + KST_OFFSET_MS; + const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; + const nextKstMidnight = kstMidnightFloor + 86_400_000; + set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + const candidate = get().recallCandidate; + if (candidate) { + await inboxApi.emitRecallSnoozed(candidate.id); + } + } +``` + +- [ ] **Step 4: Run tests, verify pass** + +Run: `npm test -- store.recall` +Expected: PASS — 3 new cases. + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: total +3 from previous task, all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.recall.test.ts +git commit -m "$(cat <<'EOF' +feat(recall): renderer store — recallCandidate + 4 actions (#6 v0.2.3) + +- 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) +EOF +)" +``` + +--- + +## Task 7: RecallBanner 컴포넌트 + App.tsx mount + NoteCard id + +**Files:** +- Create: `src/renderer/inbox/components/RecallBanner.tsx` +- Modify: `src/renderer/inbox/App.tsx` (import + mount + scroll handler) +- Modify: `src/renderer/inbox/components/NoteCard.tsx` (wrapper div id attr) + +- [ ] **Step 1: Create RecallBanner.tsx** + +Create `src/renderer/inbox/components/RecallBanner.tsx`: + +```typescript +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); + + // n1 fix pattern from 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)); +} +``` + +- [ ] **Step 2: Modify NoteCard.tsx — add wrapper div id** + +In `src/renderer/inbox/components/NoteCard.tsx`, locate the top-level returned `
` (or `
` / wrapper element) and add `id={\`note-${note.id}\`}` attribute. + +For example, if the current return looks like: +```typescript +return ( +
+ {/* card contents */} +
+); +``` + +Change to: +```typescript +return ( +
+ {/* card contents */} +
+); +``` + +(Find the exact element by reading the file — apply id to outermost wrapper of the card.) + +- [ ] **Step 3: Modify App.tsx — import + mount RecallBanner** + +In `src/renderer/inbox/App.tsx`: + +**3a) Add import:** + +```typescript +import { ExpiryBanner } from './components/ExpiryBanner.js'; +import { FailedBanner } from './components/FailedBanner.js'; +import { RecallBanner } from './components/RecallBanner.js'; +``` + +**3b) Mount RecallBanner after ExpiryBanner:** + +Find the existing banner stack: +```typescript + +... + + + +``` + +Add `` after ``: +```typescript + +... + + + + +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: all pass (no new tests, but no regressions). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/inbox/components/RecallBanner.tsx src/renderer/inbox/App.tsx src/renderer/inbox/components/NoteCard.tsx +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 8: Closure — strategy.md 갱신 + roadmap mark complete + final gates + +**Files:** +- Modify: `docs/strategy.md` (§2.3, §4.3, §8) +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (#6 mark complete) + +- [ ] **Step 1: Verify final gate matrix** + +Run sequentially: +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: +- typecheck: 0 errors +- 단위: 386 + 17 = **403/403** +- e2e: 1/1 + +If any failures, fix before proceeding. + +- [ ] **Step 2: Update strategy.md** + +Read `docs/strategy.md` first to understand the current §2.3, §4.3, §8 structure. Then make these targeted edits: + +**§2.3** — add "오늘 회상" surface: + +Look for §2.3 heading and add a paragraph describing RecallBanner as the formal "Capitalize 본격 진입" surface. Example phrasing (adapt to existing voice): + +``` +**오늘 회상 (RecallBanner, v0.2.3 #6):** +Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 제시한다. 사용자는 "열어보기" +(노트 카드 스크롤 + last_recalled_at 갱신), "다음에" (KST 자정까지 in-memory snooze), +"더 이상" (recall_dismissed_at 갱신, 30일 후 재추천) 중 선택. 본 surface 가 +Capitalize 단계의 첫 본격 진입점이다. +``` + +**§4.3** — F4-A/B/D 측정 인프라: + +Add a sentence noting that recall_shown / recall_opened / recall_dismissed / recall_snoozed +telemetry events provide the measurement substrate for future F4-A (잠금해제 hook) / +F4-B (ambient if-then) / F4-D (무작위 토스트) work. + +**§8** — Inbox surface stack: + +Add RecallBanner to the documented banner order: Ollama → Pending → Failed → Expiry → **Recall** (시간 민감도 낮음 순). + +Since I don't have the exact strategy.md content here, the implementer should: +1. Read the file +2. Locate the relevant sections +3. Add concise paragraphs that fit the existing voice +4. Cross-reference v0.2.3 #6 as the source + +Keep additions tight (3-5 lines per section). + +- [ ] **Step 3: Mark #6 complete in roadmap** + +In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, find the heading: +```markdown +### #6 리마인드 1 spike (7번) +``` + +Replace with: +```markdown +### #6 리마인드 1 spike (7번) ✓ 완료 +``` + +- [ ] **Step 4: Commit closure** + +```bash +git add docs/strategy.md docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +git commit -m "$(cat <<'EOF' +chore(recall): #6 closure — strategy.md 갱신 + roadmap mark + 게이트 검증 + +- 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) +EOF +)" +``` + +- [ ] **Step 5: Push branch** + +```bash +git push -u origin feat/v023-recall-spike +``` + +- [ ] **Step 6: Create PR via Gitea API (controller-side)** + +Using curl + author-token (gitea-pr CLI requires `tea` which isn't installed). Body and title go in temp files for UTF-8 safety. + +``` +PR title: feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7) +PR body: Summary + Changes + Spec/Plan refs + Test Plan +``` + +(Implementer: this is the controller's job — report DONE and let controller handle.) + +--- + +## Self-Review (executed by plan author inline) + +**1. Spec coverage:** +- ✅ Q1=A (in-memory snooze, KST 자정) → T6 store snoozeRecall +- ✅ Q2=B (ageDays = last_recalled_at ?? created_at) → T4 CaptureService.computeAgeDays +- ✅ All 6 invariants (LIMIT 1, KST 보정, 마감 제외, in-memory snooze, emit 순서, per-session 1 shown) → T1+T6+T7 +- ✅ Privacy invariant (.strict, no content leak) → T2 schema + extra field reject test +- ✅ All "Out of scope" items NOT implemented (잠금해제, F4-A/B/D, 임베딩, SM-2, 다중 후보, 영속 snooze, 사용자 정의 주기, history) +- ✅ strategy.md §2.3/§4.3/§8 갱신 → T8 step 2 + +**2. Placeholder scan:** +- ✅ No "TBD" / "TODO" / "fill in details" anywhere +- T8 step 2 references "read existing structure" — actionable since strategy.md is real existing file + +**3. Type consistency:** +- `findRecallCandidate(): Note | null` (T1) === T4 `service.listRecallCandidate(): Promise` (await wraps) === T5 IPC return shape === T6 store recallCandidate type +- `markRecallOpened(id, now): void` (T1) === T4 `service.markRecallOpened(id): Promise<{ note: Note }>` (note: T4 generates `now` internally and returns updated note) +- `RecallShownPayload { noteId, ageDays }` (T2) === T4 emit shape `{ noteId, ageDays }` === T3 stats branch +- 4 telemetry kinds consistent across T2/T3/T4 + +**4. Test count verification:** +- T1: 5 (NoteRepository) +- T2: 3 (telemetryEvents) +- T3: 2 (telemetryStats) +- T4: 4 (CaptureService) +- T6: 3 (store.recall.test.ts new file) +- Total: **17** new cases. 386 + 17 = **403** target. + +--- + +## 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 영속화 결정