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 영속화 결정 diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md index 85796ae..d3d8cb3 100644 --- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md @@ -169,7 +169,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── **Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책 -### #6 리마인드 1 spike (7번) +### #6 리마인드 1 spike (7번) ✓ 완료 **In:** - `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시 diff --git a/docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md b/docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md new file mode 100644 index 0000000..d9cf019 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md @@ -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` 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 { + 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 { + 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 { + 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; +markRecallOpened(id: string): Promise<{ note: Note }>; +dismissRecall(id: string): Promise<{ note: Note }>; +emitRecallShown(id: string): Promise; +emitRecallSnoozed(id: string): Promise; +``` + +### 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; // session-local, "1 shown per note per session" + loadRecallCandidate: () => Promise; + openRecall: (id: string) => Promise; + dismissRecallNote: (id: string) => Promise; // store action 명, IPC 와 구별 + snoozeRecall: () => Promise; +} +``` + +`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` (신규) + +- 위치: `` 다음 (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` ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 호출. + +구체 구현: +- `App.tsx` 가 `useRef>(new Map())` 보유 +- 각 `` 에 `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 영속화 결정 diff --git a/docs/superpowers/strategy/strategy.md b/docs/superpowers/strategy/strategy.md index f49bea4..99c63c7 100644 --- a/docs/superpowers/strategy/strategy.md +++ b/docs/superpowers/strategy/strategy.md @@ -53,6 +53,8 @@ AI가 제목, 요약, 태그, 프로젝트 후보를 생성합니다. 다만 사 하루 또는 주간 리뷰에서 AI가 메모를 업무 산출물로 바꿔줍니다. +오늘 회상 (RecallBanner, v0.2.3 #6): Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 가장 오래된 순으로 제시합니다. 사용자는 "열어보기"(노트 카드 스크롤 + last_recalled_at 갱신), "다음에"(KST 자정까지 in-memory snooze), "더 이상"(recall_dismissed_at 갱신, 30일 후 재추천) 중 선택합니다. 본 surface 가 Capitalize 단계의 첫 본격 진입점입니다. + 예: “이번 주 결정 근거” @@ -140,6 +142,8 @@ Confluence 공유 후보 추천 직장에서의 동기와 몰입은 의미 있는 일에서 진전이 보일 때 강해집니다. Amabile와 Kramer의 “Progress Principle”은 지식 근로자의 감정·동기·창의성에 작은 진전 경험이 중요하다는 점을 강조합니다. Inkling의 주간 리포트는 “기록 수”보다 업무 진전의 증거를 보여줘야 합니다. +측정 인프라 (v0.2.3 #6): recall_shown / recall_opened / recall_dismissed / recall_snoozed 4종 telemetry 가 본 cut 으로 자리잡았습니다. 향후 F4-A (잠금해제 hook), F4-B (ambient if-then), F4-D (무작위 토스트) 항목 진입 시 본 telemetry 가 효과 측정 기반으로 확장됩니다. + 5. 스트릭은 처벌이 아니라 회복 친화적으로 설계한다 기획서에 스트릭과 뱃지가 포함되어 있는데, 이 장치는 조심해서 써야 합니다. 게임화 연구는 전반적으로 긍정적 효과를 보이지만, 효과 크기와 안정성은 맥락에 따라 다르고, 특히 동기·행동 효과는 고품질 연구만 보면 덜 안정적일 수 있습니다. 따라서 Inkling은 경쟁·압박형 게임화가 아니라 자기효능감 회복형 게임화가 맞습니다. @@ -280,6 +284,8 @@ AI 자동 정리는 Inkling의 핵심 강점입니다. 다만 사용자가 완 8. 관계성 보상: “내 메모가 동료의 시간을 아껴준다” +Inbox surface stack (v0.2.3 기준): Ollama 회복 → Pending 진행 → Failed 실패 → Expiry 마감 임박 → Recall 회상 추천. 시간 민감도 순으로 위에서 아래. RecallBanner 가 가장 가벼운 surface 로 stack 끝에 놓입니다. + 기록 습관은 개인 생산성뿐 아니라 팀 학습과도 연결됩니다. Edmondson의 심리적 안전감 연구는 팀원이 대인관계 위험을 감수하고 질문·실수·학습 행동을 할 수 있는 분위기가 팀 학습과 관련된다는 점을 제시합니다. 업무 메모를 팀 지식으로 공유하게 만들려면 “감시받는다”가 아니라 동료를 돕는다는 감각이 필요합니다. 따라서 Confluence 내보내기 UX는 이렇게 설계합니다. diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 3acb463..5579cb8 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -136,6 +136,12 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed()); ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed()); + + 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)); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 8a3a996..8ec8a2b 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -217,6 +217,43 @@ export class NoteRepository { .run(nextRunAt, lastError.slice(0, 500), noteId); } + /** + * 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); + } + /** * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 1aa490b..e67ae3e 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -12,6 +12,10 @@ export interface TelemetryEmitter { | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } | { kind: 'expired_batch_trash'; payload: { count: number } } | { kind: 'ai_retry_manual'; payload: { failedCount: number } } + | { kind: 'recall_opened'; payload: { noteId: string } } + | { kind: 'recall_dismissed'; payload: { noteId: string } } + | { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } } + | { kind: 'recall_snoozed'; payload: { noteId: string } } ): Promise; } @@ -179,4 +183,65 @@ export class CaptureService { } return { count: ids.length }; } + + /** 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 }> { + if (!this.repo.findById(noteId)) 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)); + } } diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index dc63121..cb5e0c6 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -30,7 +30,11 @@ export type EmitInput = | { kind: 'ollama_recheck_manual'; payload: Record } | { kind: 'ai_retry_manual'; payload: { failedCount: number } } | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } - | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }; + | { 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 } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 66471e7..67d194f 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -59,6 +59,11 @@ const TagVocabMissPayload = z.object({ vocabSize: z.number().int().nonnegative() }).strict(); +const RecallShownPayload = z.object({ + noteId: z.string().min(1), + ageDays: z.number().int().nonnegative() +}).strict(); + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), @@ -74,7 +79,11 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() + 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() ]); export type TelemetryEvent = z.infer; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index bdbb918..0054fe7 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -26,6 +26,10 @@ interface DailyRow { ai_retry_manual: number; tag_vocab_hit: number; tag_vocab_miss: number; + recall_shown: number; + recall_opened: number; + recall_dismissed: number; + recall_snoozed: number; } export interface StatsResult { @@ -51,6 +55,11 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let aiRetryManualFailedSum = 0; let tagVocabHitCount = 0; let tagVocabMissCount = 0; + let recallShownCount = 0; + let recallOpenedCount = 0; + let recallDismissedCount = 0; + let recallSnoozedCount = 0; + let recallAgeDaysSum = 0; for (const ev of events) { const day = kstDate(ev.ts); let row = byDay.get(day); @@ -62,7 +71,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta expired_banner_shown: 0, expired_batch_trash: 0, ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0, ai_retry_manual: 0, - tag_vocab_hit: 0, tag_vocab_miss: 0 + tag_vocab_hit: 0, tag_vocab_miss: 0, + recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0 }; byDay.set(day, row); } @@ -110,6 +120,19 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta } 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; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -130,6 +153,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta const tagVocabSummary = tagVocabTotal === 0 ? '(데이터 없음)' : `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`; + 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)}`; const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -138,10 +167,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(''); lines.push('## 일자별 카운트'); lines.push(''); - 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('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|'); + 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('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|'); for (const row of days) { - 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} |`); + 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} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -155,6 +184,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`); lines.push(`- 태그 vocab: ${tagVocabSummary}`); + lines.push(`- 회상 추천: ${recallSummary}`); + lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cb2a39..eaa043f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -39,7 +39,12 @@ const api: InklingApi = { return () => ipcRenderer.off('ollama:status', listener); }, retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'), - getFailedCount: () => ipcRenderer.invoke('inbox:failedCount') + getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'), + 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) } }; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 2fb772e..b9b8719 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -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 { + {tagFilter !== null && (
+ // id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3) +
{formatted}
{!isTrash && showIntentBanner && ( diff --git a/src/renderer/inbox/components/RecallBanner.tsx b/src/renderer/inbox/components/RecallBanner.tsx new file mode 100644 index 0000000..c0436d8 --- /dev/null +++ b/src/renderer/inbox/components/RecallBanner.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef, 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 openRecall = useInbox((s) => s.openRecall); + const dismissRecallNote = useInbox((s) => s.dismissRecallNote); + const snoozeRecall = useInbox((s) => s.snoozeRecall); + + // i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X) + // 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장. + // 컴포넌트 언마운트/리마운트 시 reset (session-local 의도). + const shownIdsRef = useRef>(new Set()); + + // 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-banner-lifetime 1회 per note) + useEffect(() => { + if (!candidate) return; + if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return; + if (shownIdsRef.current.has(candidate.id)) return; + void inboxApi.emitRecallShown(candidate.id); + shownIdsRef.current.add(candidate.id); + }, [candidate, snoozeUntilMs]); + + if (candidate === null) return null; + if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null; + + const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt); + // m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지 + const title = candidate.aiTitle?.trim() || candidate.rawText.trim().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)); +} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 92ed25c..8e7c708 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -18,6 +18,8 @@ interface InboxState { expiredCandidates: Note[]; expiredSnoozeUntilMs: number | null; failedCount: number; + recallCandidate: Note | null; + recallSnoozeUntilMs: number | null; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -33,6 +35,10 @@ interface InboxState { snoozeExpired: () => void; recheckOllama: () => Promise; retryAllFailed: () => Promise; + loadRecallCandidate: () => Promise; + openRecall: (id: string) => Promise; + dismissRecallNote: (id: string) => Promise; + snoozeRecall: () => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -54,9 +60,11 @@ export const useInbox = create((set, get) => ({ expiredCandidates: [], expiredSnoozeUntilMs: null, failedCount: 0, + recallCandidate: null, + recallSnoozeUntilMs: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), @@ -64,21 +72,23 @@ export const useInbox = create((set, get) => ({ inboxApi.getTodayCount(), inboxApi.getTrashCount(), inboxApi.listExpired(), - inboxApi.getFailedCount() + inboxApi.getFailedCount(), + inboxApi.listRecallCandidate() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([ + 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.getFailedCount(), + inboxApi.listRecallCandidate() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate }); }, upsertNote(note) { // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 @@ -185,5 +195,34 @@ export const useInbox = create((set, get) => ({ // 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관, // 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확. set({ failedCount: 0 }); + }, + async loadRecallCandidate() { + const recallCandidate = await inboxApi.listRecallCandidate(); + set({ recallCandidate }); + }, + async openRecall(id) { + await inboxApi.markRecallOpened(id); + const recallCandidate = await inboxApi.listRecallCandidate(); + set({ recallCandidate }); + }, + async dismissRecallNote(id) { + await inboxApi.dismissRecall(id); + const recallCandidate = await inboxApi.listRecallCandidate(); + // m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear + set({ recallCandidate, recallSnoozeUntilMs: null }); + }, + 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 }); + // m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시 + // snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적). + const candidate = get().recallCandidate; + if (candidate) { + await inboxApi.emitRecallSnoozed(candidate.id); + } } })); diff --git a/src/shared/types.ts b/src/shared/types.ts index 726b63d..e1dccea 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -84,6 +84,11 @@ export interface InboxApi { onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; retryAllFailed(): Promise<{ count: number }>; getFailedCount(): Promise; + listRecallCandidate(): Promise; + markRecallOpened(id: string): Promise<{ note: Note }>; + dismissRecall(id: string): Promise<{ note: Note }>; + emitRecallShown(id: string): Promise; + emitRecallSnoozed(id: string): Promise; } export interface InklingApi { diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 39005f3..51e19e8 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -373,3 +373,68 @@ describe('CaptureService.retryAllFailed', () => { expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]); }); }); + +describe('CaptureService recall methods (v0.2.3 #6)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let emits: Array<{ kind: string; payload: any }>; + let service: CaptureService; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-')); + store = new MediaStore(tmp); + emits = []; + service = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (ev) => { emits.push(ev as any); } } + }); + }); + + 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); + }); +}); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index ff87460..9cb2854 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -214,6 +214,59 @@ describe('NoteRepository', () => { expect(typeof n).toBe('number'); expect(n).toBeGreaterThanOrEqual(0); }); + + it('findRecallCandidate returns null for empty db', () => { + expect(repo.findRecallCandidate()).toBeNull(); + }); + + 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); + }); }); describe('NoteRepository.trash', () => { diff --git a/tests/unit/store.recall.test.ts b/tests/unit/store.recall.test.ts new file mode 100644 index 0000000..eb67a18 --- /dev/null +++ b/tests/unit/store.recall.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Note } from '@shared/types'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + listRecallCandidate: vi.fn(), + markRecallOpened: vi.fn(), + dismissRecall: vi.fn(), + emitRecallShown: vi.fn(), + emitRecallSnoozed: vi.fn(), + 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) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store.js'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; + +const inboxApiMock = inboxApi as unknown as { + listRecallCandidate: ReturnType; + markRecallOpened: ReturnType; + dismissRecall: ReturnType; + emitRecallShown: ReturnType; + emitRecallSnoozed: ReturnType; +}; + +const note = (id: string): Note => ({ + id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc', + tags: [], media: [], 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, + } as Parameters[0]); + }); + + it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => { + useInbox.setState({ recallCandidate: note('n1') } as Parameters[0]); + 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 + fetches next candidate', async () => { + inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') }); + inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null); + await useInbox.getState().openRecall('n1'); + expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1'); + expect(inboxApiMock.listRecallCandidate).toHaveBeenCalled(); + expect(useInbox.getState().recallCandidate).toBeNull(); + }); + + 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'); + }); +}); diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 5266346..b6e4dd4 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -306,3 +306,30 @@ describe('validateEvent — tag vocab', () => { })).toThrow(); }); }); + +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); + } + }); +}); + diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index 4fa74b5..418c1e8 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -187,4 +187,27 @@ describe('aggregateStats — tag_vocab hit/miss', () => { expect(r.md).toContain('태그 vocab'); expect(r.md).toContain('데이터 없음'); }); + + 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('데이터 없음'); + }); });