From a5e6859ac9cafe537a23a56ecf2def307fd3441e Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:30:48 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20v0.2.3=20#5=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 task TDD 분할 + 단위 26개 (spec §8 의 16개 충족 + 6 over): - T1 KST util (todayInKstString + nextKstMidnightMs) - T2 NoteRepository.findExpiredCandidates - T3 NoteRepository.trashBatch (atomic) - T4 telemetry 2 events + stats.md 만료 trash ratio - T5 CaptureService listExpired/trashExpiredBatch + IPC 2채널 + preload - T6 zustand store 확장 - T7 ExpiryBanner 컴포넌트 + App.tsx mount - T8 closure Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-01-v023-expiry.md | 1477 +++++++++++++++++ 1 file changed, 1477 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-v023-expiry.md diff --git a/docs/superpowers/plans/2026-05-01-v023-expiry.md b/docs/superpowers/plans/2026-05-01-v023-expiry.md new file mode 100644 index 0000000..a7c5bf4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-expiry.md @@ -0,0 +1,1477 @@ +# #5 만료 추천 (Expiry Banner) 구현 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:** v0.2.3 세 번째 항목 — `due_date < today AND deleted_at IS NULL AND ai_status = 'done'` 만료 후보를 Inbox 상단 ExpiryBanner 로 노출. 멀티선택 (unchecked default + 전체선택 토글) → "선택 휴지통" 배치 trash. "오늘 그만" snooze (자정 KST 리셋, in-memory). + +**Architecture:** main 의 `NoteRepository.findExpiredCandidates(now)` + `NoteRepository.trashBatch(ids, deletedAt)` 두 메서드. `CaptureService.listExpired()` 이 dedup signature 기반으로 `expired_banner_shown` 자동 emit. `CaptureService.trashExpiredBatch(ids)` 가 atomic trash + `expired_batch_trash` emit. KST 자정 계산은 신규 util `src/main/util/kstDate.ts` 의 두 pure function. zustand store 가 `expiredCandidates` / `expiredSnoozeUntilMs` state + 3 actions. `ExpiryBanner` 컴포넌트는 PendingBanner 아래 mount, 0건 / snooze 시 null. + +**Tech Stack:** TypeScript / electron-vite / better-sqlite3 12.9 / zod 4.3.6 / vitest 4 / React 19 / zustand 5. 신규 dep 없음. + +**선행 spec:** `docs/superpowers/specs/2026-05-01-v023-expiry-design.md` +**선행 cut:** v0.2.3 #4 trash (commit `df60c5a`) — `deleted_at` invariant + `trash()` / `countTrashed()` 인프라 위에서 동작. + +--- + +## File Structure + +| 경로 | 책임 | +|------|------| +| `src/main/util/kstDate.ts` (**new**) | `todayInKstString(now: Date): 'YYYY-MM-DD'` + `nextKstMidnightMs(now: number): number`. 두 pure function 만. | +| `src/main/repository/NoteRepository.ts` (**modify**) | `findExpiredCandidates(now?: Date): Note[]` + `trashBatch(ids: string[], deletedAt: string): { trashedCount: number }` 두 메서드. | +| `src/main/services/telemetryEvents.ts` (**modify**) | zod `discriminatedUnion` 에 `expired_banner_shown` + `expired_batch_trash` 2 새 멤버, payload `.strict()`. | +| `src/main/services/TelemetryService.ts` (**modify**) | `EmitInput` union 에 2 추가 (TS 타입만, runtime 변경 없음). | +| `src/main/services/telemetryStats.ts` (**modify**) | `DailyRow` 에 2 카운터 + 표 컬럼 + 만료 trash ratio 출력. | +| `src/main/services/CaptureService.ts` (**modify**) | `listExpired(): Note[]` (dedup-emit 통합) + `trashExpiredBatch(ids: string[]): { trashedCount: number }` + `TelemetryEmitter` interface 에 2 union 멤버 추가. | +| `src/shared/types.ts` (**modify**) | `InboxApi` 에 신규 메서드 2개 (`listExpired` / `trashExpiredBatch`). | +| `src/main/ipc/inboxApi.ts` (**modify**) | 2 신규 채널 (`inbox:listExpired` / `inbox:trashExpiredBatch`). 후자에 native confirm dialog. | +| `src/preload/index.ts` (**modify**) | 신규 2 IPC bridge. | +| `src/renderer/inbox/store.ts` (**modify**) | `expiredCandidates` / `expiredSnoozeUntilMs` state + `loadExpired` / `trashExpiredBatch` / `snoozeExpired` actions. `loadInitial` + `refreshMeta` 의 Promise.all 에 `listExpired` 합류. | +| `src/renderer/inbox/api.ts` (**modify**) | `inboxApi` 에 신규 2 메서드 wrapper. | +| `src/renderer/inbox/components/ExpiryBanner.tsx` (**new**) | 헤더 1줄 + 펼침/접힘 + 체크박스 리스트 + 전체선택 토글 + "선택 휴지통" + "오늘 그만". | +| `src/renderer/inbox/App.tsx` (**modify**) | `` 아래 `` mount (showTrash=false 분기 안). | + +테스트: +- `tests/unit/kstDate.test.ts` (**new**) — `todayInKstString` UTC↔KST 경계 + `nextKstMidnightMs` 정확 epoch. +- `tests/unit/NoteRepository.test.ts` (**modify**) — `findExpiredCandidates` 5 케이스 + `trashBatch` 4 케이스. +- `tests/unit/telemetryEvents.test.ts` (**modify**) — 2 신규 kind privacy invariant + zod parse. +- `tests/unit/telemetryStats.test.ts` (**modify**) — 2 카운터 + 만료 trash ratio. +- `tests/unit/CaptureService.test.ts` (**modify**) — `listExpired` dedup signature + `trashExpiredBatch` 동작 + 2 emit. +- `tests/unit/store.test.ts` (**modify** 또는 **new**) — store action 3개 (loadExpired / trashExpiredBatch optimistic / snoozeExpired KST 자정). + +--- + +## Task 1: KST 자정 유틸 (`todayInKstString` + `nextKstMidnightMs`) + +**Files:** +- Create: `src/main/util/kstDate.ts` +- Create: `tests/unit/kstDate.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +```typescript +// tests/unit/kstDate.test.ts +import { describe, it, expect } from 'vitest'; +import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js'; + +describe('todayInKstString', () => { + it('returns KST calendar date as YYYY-MM-DD', () => { + // 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST + expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01'); + }); + + it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => { + expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02'); + }); + + it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => { + expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02'); + }); +}); + +describe('nextKstMidnightMs', () => { + it('returns the next KST 00:00 epoch ms (UTC 12:00 → +12h to KST midnight)', () => { + // 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → 다음 KST 자정 = 2026-05-02 00:00 KST + // = 2026-05-01 15:00 UTC + const now = Date.parse('2026-05-01T12:00:00Z'); + const next = nextKstMidnightMs(now); + expect(new Date(next).toISOString()).toBe('2026-05-01T15:00:00.000Z'); + }); + + it('returns 24h-from-now-ish when called shortly after KST midnight', () => { + // 2026-05-01 15:01 UTC = 2026-05-02 00:01 KST → 다음 KST 자정 = 2026-05-03 00:00 KST + // = 2026-05-02 15:00 UTC (≈ 23h59m later) + const now = Date.parse('2026-05-01T15:01:00Z'); + const next = nextKstMidnightMs(now); + expect(new Date(next).toISOString()).toBe('2026-05-02T15:00:00.000Z'); + expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(next - now).toBeLessThan(24 * 60 * 60 * 1000); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL (모듈 없음)** + +Run: `npm test -- tests/unit/kstDate.test.ts` +Expected: FAIL — `Cannot find module '@main/util/kstDate.js'`. + +- [ ] **Step 3: 구현** + +```typescript +// src/main/util/kstDate.ts +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant. + * + * v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against + * notes.due_date (also stored as YYYY-MM-DD per slice §F1). + */ +export function todayInKstString(now: Date): string { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date( + Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()) + ).toISOString().slice(0, 10); +} + +/** + * Epoch ms of the next 00:00 KST strictly after `now`. + * + * v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze + * deadline ("오늘 그만"). + */ +export function nextKstMidnightMs(now: number): number { + const kstNow = now + KST_OFFSET_MS; + // Floor to KST midnight, then add one day. + const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; + const nextKstMidnight = kstMidnightFloor + 86_400_000; + return nextKstMidnight - KST_OFFSET_MS; +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/kstDate.test.ts` +Expected: typecheck 0 errors. 5 케이스 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/util/kstDate.ts tests/unit/kstDate.test.ts +git commit -m "feat(expiry): KST util — todayInKstString + nextKstMidnightMs (#5 v0.2.3)" +``` + +--- + +## Task 2: NoteRepository.findExpiredCandidates + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/unit/NoteRepository.test.ts` 끝에 새 describe (기존 import 들 그대로 사용): + +```typescript +describe('NoteRepository.findExpiredCandidates', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + // 헬퍼: 테스트용 done 노트 + due_date 직접 set + 옵션 (수동 vs AI) + function makeDone(opts: { + rawText: string; + dueDate: string | null; + edited?: boolean; + deletedAt?: string | null; + aiStatus?: 'pending' | 'done' | 'failed'; + }): string { + const { id } = repo.create({ rawText: opts.rawText }); + db.prepare( + `UPDATE notes + SET due_date = ?, + due_date_edited_by_user = ?, + ai_status = ?, + deleted_at = ? + WHERE id = ?` + ).run( + opts.dueDate, + opts.edited ? 1 : 0, + opts.aiStatus ?? 'done', + opts.deletedAt ?? null, + id + ); + return id; + } + + it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => { + const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + // 같은 시점 created_at 충돌 회피 위해 살짝 대기 후 b + db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a); + const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' }); + db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + // b 가 더 최신 created_at → 먼저 + expect(r.map((n) => n.id)).toEqual([b, a]); + }); + + it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => { + const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false }); + const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort()); + }); + + it('excludes trashed notes (deleted_at IS NOT NULL)', () => { + const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([a]); + }); + + it('excludes pending / failed notes (ai_status != done)', () => { + const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' }); + makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([done]); + }); + + it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => { + const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: null }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([dated]); + }); + + it('excludes notes with due_date == today (boundary, not expired)', () => { + const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' }); + makeDone({ rawText: 'b', dueDate: '2026-05-01' }); // 오늘 KST = 2026-05-01 + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([past]); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/NoteRepository.test.ts` +Expected: FAIL — `repo.findExpiredCandidates` 미정의. + +- [ ] **Step 3: 구현** + +`src/main/repository/NoteRepository.ts` 의 import 에 `todayInKstString` 추가: + +```typescript +import { todayInKstString } from '../util/kstDate.js'; +``` + +`countToday()` 직후 위치에 메서드 추가: + +```typescript +/** + * Notes whose due_date is strictly before today (KST calendar) and that are + * still active (not trashed) and AI-processed. Includes both AI-extracted and + * user-edited due_date (v0.2.3 #5 spec §1 Q1=B). + * + * Caller may inject `now` for testability; defaults to `new Date()`. + */ +findExpiredCandidates(now: Date = new Date()): Note[] { + const today = todayInKstString(now); + const rows = this.db + .prepare( + `SELECT * FROM notes + WHERE due_date IS NOT NULL + AND due_date < ? + AND deleted_at IS NULL + AND ai_status = 'done' + ORDER BY created_at DESC, id DESC` + ) + .all(today) as any[]; + return rows.map((r) => this.hydrate(r)); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/NoteRepository.test.ts` +Expected: typecheck 0 errors. 6 신규 케이스 + 기존 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3)" +``` + +--- + +## Task 3: NoteRepository.trashBatch (atomic) + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/unit/NoteRepository.test.ts` 끝에 추가: + +```typescript +describe('NoteRepository.trashBatch', () => { + let db: Database.Database; + let repo: NoteRepository; + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('atomically trashes all valid ids and returns trashedCount', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(3); + expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + // pending_jobs cleanup invariant 일관 (#4 trash() 재사용 효과) + expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c)) + .toMatchObject({ c: 0 }); + }); + + it('returns trashedCount=0 for empty array (no-op)', () => { + const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(0); + }); + + it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.trash(a, '2026-04-30T00:00:00.000Z'); + const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(0); + // deleted_at 은 첫 trash 의 timestamp 그대로 (덮어쓰기 X) + expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z'); + }); + + it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.trash(b, '2026-04-30T00:00:00.000Z'); + const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(1); + expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/NoteRepository.test.ts` +Expected: FAIL — `repo.trashBatch` 미정의. + +- [ ] **Step 3: 구현** + +`src/main/repository/NoteRepository.ts` 의 `trash()` 메서드 직후에 추가: + +```typescript +/** + * Atomically transition a batch of notes from active → trash. + * Returns the number of notes that actually transitioned (i.e. were active + * before the call). Already-trashed and unknown ids are silent skips — + * counting them would inflate `expired_batch_trash` telemetry. + * + * Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup + * invariant (§9.2 of #4 spec). + */ +trashBatch(ids: string[], deletedAt: string): { trashedCount: number } { + if (ids.length === 0) return { trashedCount: 0 }; + let trashedCount = 0; + const tx = this.db.transaction((batch: string[]) => { + for (const id of batch) { + const row = this.db + .prepare(`SELECT deleted_at FROM notes WHERE id = ?`) + .get(id) as { deleted_at: string | null } | undefined; + if (!row || row.deleted_at !== null) continue; + this.trash(id, deletedAt); + trashedCount += 1; + } + }); + tx(ids); + return { trashedCount }; +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/NoteRepository.test.ts` +Expected: typecheck 0 errors. 4 신규 케이스 + 기존 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3)" +``` + +--- + +## Task 4: Telemetry 2 events (zod + EmitInput + stats.md 집계) + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `src/main/services/telemetryStats.ts` +- Modify: `tests/unit/telemetryEvents.test.ts` +- Modify: `tests/unit/telemetryStats.test.ts` + +- [ ] **Step 1: telemetryEvents 실패 테스트** + +`tests/unit/telemetryEvents.test.ts` 끝에 추가 (기존 import 들 그대로): + +```typescript +describe('expired_banner_shown / expired_batch_trash events', () => { + it('parses valid expired_banner_shown', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_banner_shown', + payload: { candidateCount: 7 } + }); + if (ev.kind !== 'expired_banner_shown') throw new Error('discriminant'); + expect(ev.payload.candidateCount).toBe(7); + }); + + it('parses valid expired_batch_trash', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_batch_trash', + payload: { count: 3 } + }); + if (ev.kind !== 'expired_batch_trash') throw new Error('discriminant'); + expect(ev.payload.count).toBe(3); + }); + + it('rejects expired_banner_shown with extra payload field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_banner_shown', + payload: { candidateCount: 7, rawText: 'leak' } + })).toThrow(); + }); + + it('rejects expired_batch_trash with negative count', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_batch_trash', + payload: { count: -1 } + })).toThrow(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: FAIL — `expired_banner_shown` / `expired_batch_trash` discriminant 부재. + +- [ ] **Step 3: telemetryEvents.ts 확장** + +```typescript +// src/main/services/telemetryEvents.ts — 기존 schema 들 아래에 추가 +const ExpiredBannerShownPayload = z.object({ + candidateCount: z.number().int().nonnegative() +}).strict(); + +const ExpiredBatchTrashPayload = z.object({ + count: z.number().int().nonnegative() +}).strict(); +``` + +`TelemetryEventSchema` 의 `discriminatedUnion` 배열에 두 entry 추가: + +```typescript +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(), + z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict() +]); +``` + +- [ ] **Step 4: TelemetryService.EmitInput 확장** + +`src/main/services/TelemetryService.ts` line 18-25 의 `EmitInput` union 에 2 항 추가: + +```typescript +export type EmitInput = + | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } + | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } + | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'trash'; payload: { noteId: string } } + | { kind: 'restore'; payload: { noteId: string } } + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } }; +``` + +- [ ] **Step 5: 테스트 실행 — telemetryEvents PASS** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: 4 신규 케이스 + 기존 모두 PASS. + +- [ ] **Step 6: telemetryStats 실패 테스트 추가** + +`tests/unit/telemetryStats.test.ts` 끝에 추가 (기존 import 들 그대로 사용): + +```typescript +describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => { + it('counts both kinds per day and computes 만료 trash ratio', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 5 } }, + { ts: '2026-05-01T01:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 3 } }, + { ts: '2026-05-01T02:00:00.000Z', kind: 'expired_batch_trash' as const, payload: { count: 4 } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + // 일자별 표 — 2 카운터 컬럼 노출 + expect(r.md).toContain('expired_banner_shown'); + expect(r.md).toContain('expired_batch_trash'); + // 만료 trash ratio: 4 / (5 + 3) = 50.0% + expect(r.md).toMatch(/만료 trash ratio.*50\.0%/); + }); + + it('shows N/A when 만료 배너 노출 0건', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'capture' as const, payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toMatch(/만료 trash ratio.*N\/A/); + }); +}); +``` + +- [ ] **Step 7: telemetryStats 실행 — FAIL** + +Run: `npm test -- tests/unit/telemetryStats.test.ts` +Expected: FAIL — `expired_banner_shown` 카운터 + ratio 부재. + +- [ ] **Step 8: telemetryStats.ts 확장** + +`DailyRow` interface 에 2 필드 추가: + +```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; +} +``` + +`aggregateStats` 함수 안에 누적 카운터 + ratio 계산 추가. 기존 함수의 declare/loop/output 세 곳 수정: + +(a) 함수 상단 누적 카운터 선언 추가: +```typescript +let expiredBannerShownCandidatesSum = 0; +let expiredBatchTrashCountSum = 0; +``` + +(b) `byDay.get` 에서 새 row 만들 때 2 카운터 0 으로 초기화: +```typescript +row = { + date: day, + capture: 0, ai_succeeded: 0, ai_failed: 0, + trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0, + expired_banner_shown: 0, expired_batch_trash: 0 +}; +``` + +(c) for-loop 안의 if-else chain 끝에 두 분기 추가: +```typescript +} else if (ev.kind === 'expired_banner_shown') { + row.expired_banner_shown += 1; + expiredBannerShownCandidatesSum += ev.payload.candidateCount; +} else if (ev.kind === 'expired_batch_trash') { + row.expired_batch_trash += 1; + expiredBatchTrashCountSum += ev.payload.count; +} +``` + +(d) 표 헤더 행에 2 컬럼 추가: +```typescript +lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |'); +lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|'); +``` + +(e) 표 body 행 변경: +```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} |`); +``` + +(f) "핵심 ratio" 섹션에 ratio 1줄 추가 (`휴지통 회수율` 다음): +```typescript +const expiredTrashRatio = expiredBannerShownCandidatesSum === 0 + ? 'N/A' + : `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`; +// ... +lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`); +``` + +- [ ] **Step 9: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts` +Expected: typecheck 0 errors. 신규 + 기존 모두 PASS. + +- [ ] **Step 10: 커밋** + +```bash +git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts +git commit -m "feat(expiry): telemetry 2 events — expired_banner_shown / expired_batch_trash (#5 v0.2.3)" +``` + +--- + +## Task 5: CaptureService.listExpired + trashExpiredBatch + IPC 2 channels + preload bridge + +**Files:** +- Modify: `src/main/services/CaptureService.ts` +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: CaptureService 실패 테스트 추가** + +`tests/unit/CaptureService.test.ts` 끝에 새 describe (기존 setup 패턴 차용 — 기존 파일의 setup 헬퍼 사용): + +```typescript +describe('CaptureService.listExpired (dedup signature)', () => { + it('emits expired_banner_shown on first call when candidates > 0', async () => { + const { svc, repo, telemetry } = setupServiceWithExpired([ + { id: 'n1', dueDate: '2026-04-20' }, + { id: 'n2', dueDate: '2026-04-22' } + ]); + const now = new Date('2026-05-01T12:00:00Z'); + const r = await svc.listExpired(now); + expect(r).toHaveLength(2); + expect(telemetry.calls).toContainEqual( + expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } }) + ); + }); + + it('does NOT re-emit on second call with identical candidate set (dedup)', async () => { + const { svc, telemetry } = setupServiceWithExpired([ + { id: 'n1', dueDate: '2026-04-20' }, + { id: 'n2', dueDate: '2026-04-22' } + ]); + const now = new Date('2026-05-01T12:00:00Z'); + await svc.listExpired(now); + await svc.listExpired(now); + const showns = telemetry.calls.filter((c) => c.kind === 'expired_banner_shown'); + expect(showns).toHaveLength(1); + }); + + it('re-emits when candidate set changes (count or first-3-ids)', async () => { + const { svc, repo, telemetry } = setupServiceWithExpired([ + { id: 'n1', dueDate: '2026-04-20' }, + { id: 'n2', dueDate: '2026-04-22' } + ]); + const now = new Date('2026-05-01T12:00:00Z'); + await svc.listExpired(now); + // 새 만료 노트 1건 추가 + await addExpired(repo, { id: 'n3', dueDate: '2026-04-23' }); + await svc.listExpired(now); + const showns = telemetry.calls.filter((c) => c.kind === 'expired_banner_shown'); + expect(showns).toHaveLength(2); + expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 }); + }); + + it('does NOT emit when candidates is empty', async () => { + const { svc, telemetry } = setupServiceWithExpired([]); + const now = new Date('2026-05-01T12:00:00Z'); + const r = await svc.listExpired(now); + expect(r).toEqual([]); + expect(telemetry.calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]); + }); +}); + +describe('CaptureService.trashExpiredBatch', () => { + it('emits expired_batch_trash with trashedCount + per-id trash emits', async () => { + const { svc, repo, telemetry } = setupServiceWithExpired([ + { id: 'n1', dueDate: '2026-04-20' }, + { id: 'n2', dueDate: '2026-04-22' } + ]); + const r = await svc.trashExpiredBatch(['n1', 'n2']); + expect(r.trashedCount).toBe(2); + // 1 개의 batch summary emit + expect(telemetry.calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([ + expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } }) + ]); + // per-id trash emit 은 발화 안 함 — listExpired path 와 batch path 의 세분 통계 분리 + expect(telemetry.calls.filter((c) => c.kind === 'trash')).toEqual([]); + }); + + it('returns trashedCount=0 for empty array (no emit)', async () => { + const { svc, telemetry } = setupServiceWithExpired([]); + const r = await svc.trashExpiredBatch([]); + expect(r.trashedCount).toBe(0); + expect(telemetry.calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]); + }); +}); +``` + +테스트 헬퍼 (기존 `tests/unit/CaptureService.test.ts` 의 helper 가 없는 경우 파일 상단에 추가): + +```typescript +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; +import { CaptureService } from '@main/services/CaptureService.js'; +import type { TelemetryEmitter } from '@main/services/CaptureService.js'; +import type { MediaStore } from '@main/services/MediaStore.js'; + +interface RecordingTelemetry extends TelemetryEmitter { + calls: Array<{ kind: string; payload: any }>; +} + +function makeRecordingTelemetry(): RecordingTelemetry { + const calls: Array<{ kind: string; payload: any }> = []; + return { + calls, + async emit(input: any) { calls.push(input); } + }; +} + +function makeMediaStoreStub(): MediaStore { + return { + saveImage: async () => ({ relPath: '', mime: '', bytes: 0 }), + deleteNoteDirectory: async () => {} + } as unknown as MediaStore; +} + +interface ExpiredFixture { id: string; dueDate: string; } + +function setupServiceWithExpired(fixtures: ExpiredFixture[]): { + svc: CaptureService; + repo: NoteRepository; + telemetry: RecordingTelemetry; + db: Database.Database; +} { + const db = new Database(':memory:'); + runMigrations(db); + const repo = new NoteRepository(db); + const telemetry = makeRecordingTelemetry(); + const svc = new CaptureService(repo, makeMediaStoreStub(), { + enqueue: async () => {}, + celebrate: () => {}, + telemetry + }); + for (const f of fixtures) { + repo.create({ rawText: f.id }); // raw_text 로 fixture id 사용 (간단 식별) + // 위 create 가 uuidv7 id 발급 — fixture.id 와 다름. 명시적으로 INSERT 사용: + } + // ↑ 위 create 패턴은 UUID 발급 — 테스트에서는 dueDate 직접 set 위해 raw INSERT 패턴 사용 + for (const f of fixtures) { + db.prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ).run(f.id, f.id, f.dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z'); + } + return { svc, repo, telemetry, db }; +} + +async function addExpired(repo: NoteRepository, f: ExpiredFixture): Promise { + // 동일 INSERT 패턴 + (repo as any).db + .prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ) + .run(f.id, f.id, f.dueDate, '2026-04-30T11:00:00Z', '2026-04-30T11:00:00Z'); +} +``` + +위 helper 는 기존 helper 패턴이 다르면 그대로 추가 가능. 기존 setup helper 가 있다면 (e.g. `setupCaptureService`) 변형해서 fixture 주입 옵션만 더하는 식으로 reuse. + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/CaptureService.test.ts` +Expected: FAIL — `svc.listExpired` / `svc.trashExpiredBatch` 미정의. + +- [ ] **Step 3: CaptureService 확장** + +`src/main/services/CaptureService.ts` 의 `TelemetryEmitter` interface 에 2 union 추가: + +```typescript +export interface TelemetryEmitter { + emit(input: + | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } + | { kind: 'trash'; payload: { noteId: string } } + | { kind: 'restore'; payload: { noteId: string } } + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } } + ): Promise; +} +``` + +`CaptureService` 클래스 안에 dedup field + 2 새 메서드 추가: + +```typescript +export class CaptureService { + // 기존 ctor 유지 + + // v0.2.3 #5 — expired_banner_shown 의 중복 emit 회피용 signature. + private lastExpiredShownSig: string | null = null; + + // ... 기존 메서드들 ... + + /** + * 만료 후보 (due_date < today KST, active, ai_status=done) 조회. + * candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit. + * v0.2.3 #5 spec §6.2 — dedup 위치 main 통합. + */ + async listExpired(now: Date = new Date()): Promise { + const candidates = this.repo.findExpiredCandidates(now); + if (candidates.length === 0) { + // signature reset 도 함께 — empty 후 다시 차오르면 다음 호출에 emit + this.lastExpiredShownSig = null; + return candidates; + } + const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`; + if (sig !== this.lastExpiredShownSig) { + this.lastExpiredShownSig = sig; + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'expired_banner_shown', + payload: { candidateCount: candidates.length } + }).catch(() => {}); + } + } + return candidates; + } + + /** + * 만료 후보 일괄 trash. 빈 배열은 즉시 no-op. + * 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 — + * stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계). + */ + async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> { + if (ids.length === 0) return { trashedCount: 0 }; + const r = this.repo.trashBatch(ids, new Date().toISOString()); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'expired_batch_trash', + payload: { count: r.trashedCount } + }).catch(() => {}); + } + return r; + } +} +``` + +추가 import (file 상단에 `Note` 가 없으면 추가): + +```typescript +import type { Note } from '@shared/types'; +``` + +- [ ] **Step 4: 테스트 실행 — CaptureService PASS** + +Run: `npm test -- tests/unit/CaptureService.test.ts` +Expected: 6 신규 케이스 + 기존 모두 PASS. + +- [ ] **Step 5: shared/types.ts InboxApi 확장** + +`src/shared/types.ts` 의 `InboxApi` interface 안 (`getTrashCount` 다음 어디든): + +```typescript +export interface InboxApi { + // ... 기존 필드들 ... + listExpired: () => Promise; + trashExpiredBatch: (ids: string[]) => Promise<{ trashedCount: number }>; +} +``` + +- [ ] **Step 6: IPC 2 채널 등록** + +`src/main/ipc/inboxApi.ts` 의 `registerInboxApi` 함수 끝쪽 (`inbox:trashCount` 다음) 에 2 채널 추가: + +```typescript +ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired()); + +ipcMain.handle( + 'inbox:trashExpiredBatch', + async (_e, payload: { ids: string[] }) => { + if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false }; + const win = deps.getInboxWindow(); + const opts: Electron.MessageBoxOptions = { + type: 'question', + buttons: ['옮기기', '취소'], + defaultId: 1, + cancelId: 1, + title: 'Inkling', + message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`, + detail: '복구는 휴지통 탭에서 가능합니다.' + }; + const r = win + ? await dialog.showMessageBox(win, opts) + : await dialog.showMessageBox(opts); + if (r.response !== 0) return { trashedCount: 0, confirmed: false }; + const result = await deps.capture.trashExpiredBatch(payload.ids); + return { trashedCount: result.trashedCount, confirmed: true }; + } +); +``` + +`InboxApi.trashExpiredBatch` 타입에 `confirmed: boolean` 도 노출하기 위해 `src/shared/types.ts` 의 시그너처 다듬기: + +```typescript +trashExpiredBatch: (ids: string[]) => Promise<{ trashedCount: number; confirmed: boolean }>; +``` + +- [ ] **Step 7: preload bridge** + +`src/preload/index.ts` 의 `inbox` 객체에 2 메서드 추가 (`getTrashCount` 다음): + +```typescript +listExpired: () => ipcRenderer.invoke('inbox:listExpired'), +trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }), +``` + +- [ ] **Step 8: renderer api wrapper** + +`src/renderer/inbox/api.ts` 가 `window.inkling.inbox.*` 를 wrap 하는 패턴이라면 동일 메서드 2개 노출 (기존 패턴 mirroring). 파일이 wrap 없이 직접 expose 라면 추가 불필요. + +만약 `inboxApi` 가 `{ ...window.inkling.inbox }` 형태면: +```typescript +listExpired: () => window.inkling.inbox.listExpired(), +trashExpiredBatch: (ids: string[]) => window.inkling.inbox.trashExpiredBatch(ids), +``` + +존재 여부에 따라 step 8 은 zero-line edit 일 수도 있음 — 기존 `restoreNote` 등의 존재 패턴을 모방. + +- [ ] **Step 9: typecheck + 전체 단위** + +Run: `npm run typecheck && npm test` +Expected: typecheck 0 errors. 단위 모두 PASS (신규 + 기존). + +- [ ] **Step 10: 커밋** + +```bash +git add src/main/services/CaptureService.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/renderer/inbox/api.ts tests/unit/CaptureService.test.ts +git commit -m "feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3)" +``` + +--- + +## Task 6: zustand store 확장 (expiredCandidates / snoozeUntilMs / 3 actions) + +**Files:** +- Modify: `src/renderer/inbox/store.ts` +- Modify: `tests/unit/store.test.ts` (없으면 새로 만들고 setup helper 작성) + +- [ ] **Step 1: 실패 테스트 추가** + +기존 `tests/unit/store.test.ts` 가 있으면 `describe('useInbox — expired')` block 추가, 없으면 신설: + +```typescript +// tests/unit/store.test.ts (신규 또는 확장) +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useInbox } from '@renderer/inbox/store.js'; + +// inboxApi 모킹: 테스트마다 통제된 응답 set +vi.mock('@renderer/inbox/api.js', () => { + const calls: string[] = []; + let expiredResp: any = []; + let trashBatchResp: any = { trashedCount: 0, confirmed: false }; + return { + inboxApi: { + listNotes: async () => [], + getContinuity: async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }), + getPendingCount: async () => 0, + getOllamaStatus: async () => ({ ok: true }), + getTodayCount: async () => 0, + getTrashCount: async () => 0, + listExpired: async () => { calls.push('listExpired'); return expiredResp; }, + trashExpiredBatch: async (ids: string[]) => { calls.push(`trashBatch:${ids.join(',')}`); return trashBatchResp; }, + onNoteUpdated: () => () => {}, + restoreNote: async () => {}, + permanentDeleteNote: async () => ({ confirmed: true }), + emptyTrash: async () => ({ confirmed: true, count: 0 }), + listTrash: async () => [] + }, + __setExpiredResp: (r: any) => { expiredResp = r; }, + __setTrashBatchResp: (r: any) => { trashBatchResp = r; }, + __calls: calls + }; +}); + +describe('useInbox — expired (v0.2.3 #5)', () => { + beforeEach(() => { + useInbox.setState({ + notes: [], + trashNotes: [], + trashCount: 0, + showTrash: false, + expiredCandidates: [], + expiredSnoozeUntilMs: null, + pendingCount: 0, + todayCount: 0, + ollamaStatus: { ok: true }, + tagFilter: null, + loading: false + } as any, true); + }); + + it('loadExpired sets expiredCandidates from inboxApi', async () => { + const mod = await import('@renderer/inbox/api.js') as any; + mod.__setExpiredResp([{ id: 'n1', rawText: 'x', dueDate: '2026-04-20' }]); + await useInbox.getState().loadExpired(); + expect(useInbox.getState().expiredCandidates).toHaveLength(1); + expect(useInbox.getState().expiredCandidates[0]!.id).toBe('n1'); + }); + + it('trashExpiredBatch removes ids from expiredCandidates and increments trashCount when confirmed', async () => { + const mod = await import('@renderer/inbox/api.js') as any; + mod.__setTrashBatchResp({ trashedCount: 2, confirmed: true }); + useInbox.setState({ + expiredCandidates: [ + { id: 'n1' } as any, + { id: 'n2' } as any, + { id: 'n3' } as any + ], + notes: [ + { id: 'n1' } as any, + { id: 'n2' } as any, + { id: 'n3' } as any + ], + trashCount: 5 + } as any); + await useInbox.getState().trashExpiredBatch(['n1', 'n2']); + const s = useInbox.getState(); + expect(s.expiredCandidates.map((n) => n.id)).toEqual(['n3']); + expect(s.notes.map((n) => n.id)).toEqual(['n3']); + expect(s.trashCount).toBe(7); // 5 + 2 + }); + + it('trashExpiredBatch does NOT mutate state when not confirmed (cancel)', async () => { + const mod = await import('@renderer/inbox/api.js') as any; + mod.__setTrashBatchResp({ trashedCount: 0, confirmed: false }); + useInbox.setState({ + expiredCandidates: [{ id: 'n1' } as any, { id: 'n2' } as any], + notes: [{ id: 'n1' } as any, { id: 'n2' } as any], + trashCount: 5 + } as any); + await useInbox.getState().trashExpiredBatch(['n1']); + const s = useInbox.getState(); + expect(s.expiredCandidates).toHaveLength(2); + expect(s.notes).toHaveLength(2); + expect(s.trashCount).toBe(5); + }); + + it('snoozeExpired sets expiredSnoozeUntilMs to next KST midnight', () => { + // 시점 hardcode — Date.now() 를 spy 처리 + const fixedNow = Date.parse('2026-05-01T12:00:00Z'); + vi.spyOn(Date, 'now').mockReturnValue(fixedNow); + useInbox.getState().snoozeExpired(); + const s = useInbox.getState(); + // 다음 KST 자정 = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC + expect(s.expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z')); + vi.restoreAllMocks(); + }); +}); +``` + +만약 `tests/unit/store.test.ts` 가 없으면 위 파일을 새로 생성. 기존 모킹 패턴이 다르면 (예: `inboxApi` 가 `import.meta` 기반) 그 패턴에 맞춰 모킹 형태만 변환 — 단위 검증 대상은 동일. + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/store.test.ts` +Expected: FAIL — `expiredCandidates` / `loadExpired` / `trashExpiredBatch` / `snoozeExpired` 미정의. + +- [ ] **Step 3: store.ts 확장** + +`src/renderer/inbox/store.ts` 의 `InboxState` interface 확장: + +```typescript +import { nextKstMidnightMs } from '@main/util/kstDate.js'; +// ↑ 만약 main/util import 가 renderer 에서 막히면 (vite alias 미허용), nextKstMidnightMs 를 +// `src/shared/util/kstDate.ts` 로 이동시키거나 store 안에 직접 inline 한다. +// 기존 ContinuityService 의 KST_OFFSET_MS inline 패턴 따라 inline 도 OK. + +interface InboxState { + // ... 기존 필드들 ... + expiredCandidates: Note[]; + expiredSnoozeUntilMs: number | null; + loadExpired: () => Promise; + trashExpiredBatch: (ids: string[]) => Promise; + snoozeExpired: () => void; +} +``` + +initial state 에 2 필드 추가: + +```typescript +expiredCandidates: [], +expiredSnoozeUntilMs: null, +``` + +`loadInitial` / `refreshMeta` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류: + +```typescript +async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, loading: false }); +}, +async refreshMeta() { + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired() + ]); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates }); +}, +``` + +3 새 actions 추가: + +```typescript +async loadExpired() { + const expiredCandidates = await inboxApi.listExpired(); + set({ expiredCandidates }); +}, +async trashExpiredBatch(ids: string[]) { + const r = await inboxApi.trashExpiredBatch(ids); + if (!r.confirmed) return; + // 낙관적 갱신: candidates / notes 에서 ids 제거, trashCount 증가 + const idSet = new Set(ids); + set({ + expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)), + notes: get().notes.filter((n) => !idSet.has(n.id)), + trashCount: get().trashCount + r.trashedCount + }); +}, +snoozeExpired() { + 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({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); +} +``` + +`snoozeExpired` 는 `nextKstMidnightMs` 와 동일 알고리즘 — main util import 가 renderer 에서 동작하면 import 후 `nextKstMidnightMs(Date.now())` 한 줄로 압축. vite tsconfig path alias 가 main/* 를 renderer 에서 거부할 수 있음 — 거부 시 inline 유지 (위 코드). + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/store.test.ts` +Expected: 4 신규 케이스 + 기존 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.test.ts +git commit -m "feat(expiry): zustand store extension — expiredCandidates + snooze (#5 v0.2.3)" +``` + +--- + +## Task 7: ExpiryBanner 컴포넌트 + App.tsx mount + +**Files:** +- Create: `src/renderer/inbox/components/ExpiryBanner.tsx` +- Modify: `src/renderer/inbox/App.tsx` + +이 task 는 visual integration 위주 — 기존 PendingBanner / OllamaBanner 패턴 따름. 단위 테스트 대신 typecheck + e2e smoke 가 게이트. + +- [ ] **Step 1: ExpiryBanner.tsx 작성** + +```tsx +// src/renderer/inbox/components/ExpiryBanner.tsx +import React, { useEffect, useState } from 'react'; +import { useInbox } from '../store.js'; + +export function ExpiryBanner(): React.ReactElement | null { + const candidates = useInbox((s) => s.expiredCandidates); + const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs); + const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch); + const snoozeExpired = useInbox((s) => s.snoozeExpired); + + // Q5=A: 0건 / snooze 활성 시 collapse + if (candidates.length === 0) return null; + if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null; + + return void trashExpiredBatch(ids)} + onSnooze={() => snoozeExpired()} + />; +} + +interface InnerProps { + candidates: Array<{ id: string; aiTitle: string | null; rawText: string; dueDate: string | null; tags: Array<{ name: string }> }>; + onTrash: (ids: string[]) => void; + onSnooze: () => void; +} + +function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement { + // 펼침 default = true (첫 노출 시 사용자가 즉시 N건 + 노트 보게) + const [expanded, setExpanded] = useState(true); + const [selected, setSelected] = useState>(new Set()); + + // candidates 가 변하면 selected 의 stale id 정리 + useEffect(() => { + const valid = new Set(candidates.map((c) => c.id)); + setSelected((prev) => { + const next = new Set(); + for (const id of prev) if (valid.has(id)) next.add(id); + return next; + }); + }, [candidates]); + + const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id)); + const someSelected = selected.size > 0 && !allSelected; + + function toggleAll() { + if (allSelected) setSelected(new Set()); + else setSelected(new Set(candidates.map((c) => c.id))); + } + + function toggle(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+
+ 오늘 기준 만료 {candidates.length}개 + + +
+ {expanded && ( + <> + +
+ {candidates.map((n) => ( + + ))} +
+ + + )} +
+ ); +} +``` + +- [ ] **Step 2: App.tsx 에 mount** + +`src/renderer/inbox/App.tsx` 에서 import 추가: + +```typescript +import { ExpiryBanner } from './components/ExpiryBanner.js'; +``` + +`` 직후 (line ~79) 에 mount: + +```tsx + + +``` + +- [ ] **Step 3: typecheck + 전체 단위 + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: typecheck 0 errors, 신규 16+ 단위 + 기존 PASS, e2e 1/1 PASS. + +- [ ] **Step 4: 수동 검증 — 개발 모드** + +```bash +npm run dev +``` + +수동 확인: +- 아무 노트도 만료되지 않은 상태 → 배너 noShown. +- 임의 노트 작성 + dev tools 콘솔에서 due_date 를 과거로 set → 만료 후보로 등장. +- ExpiryBanner 펼침 / 접힘 / 전체 선택 / 부분 선택 / "선택 휴지통" 클릭 → confirm dialog → 확인 → 후보 사라짐 + 휴지통 탭 N건 증가. +- "오늘 그만" → 배너 즉시 사라짐. 새로고침 (앱 재시작) 시 다시 등장. + +- [ ] **Step 5: 커밋** + +```bash +git add src/renderer/inbox/components/ExpiryBanner.tsx src/renderer/inbox/App.tsx +git commit -m "feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3)" +``` + +--- + +## Task 8: Closure (gates + roadmap mark + memory backlog) + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (#5 ✓ 마커) +- Modify: `memory/project_v024_backlog.md` (review 결과 반영) + +- [ ] **Step 1: 전체 게이트 검증** + +```bash +npm run typecheck # 0 errors +npm test # 단위 모두 PASS +npm run test:e2e # 1/1 PASS +``` + +- [ ] **Step 2: roadmap §3 #5 ✓ 마커** + +`docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` 의 `### #5 만료 추천 (3번)` 헤더를 `### #5 만료 추천 (3번) ✓ 완료` 로 변경. + +```bash +git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +``` + +- [ ] **Step 3: PR review 결과 deferred 항목 → memory backlog** + +`memory/project_v024_backlog.md` 에 #5 review 라운드의 deferred items 추가 (각 라운드 review 후에 합산). 예시 형식: + +```markdown +## v0.2.3 #5 (2026-05-DD) + +- {item} — {reviewer / round} / {decision: deferred / nit} +- ... +``` + +- [ ] **Step 4: closure 커밋** + +```bash +git add memory/project_v024_backlog.md +git commit -m "chore(expiry): #5 closure — gates verified + roadmap mark complete" +``` + +- [ ] **Step 5: PR 작성 + 머지** + +PR title: `feat(expiry): #5 만료 추천 (v0.2.3 3/7)` +PR body: spec/plan/roadmap 링크 + 작업 요약 + 게이트 결과 + 단위 N개. + +머지 후: +- 로컬 main fast-forward +- `feat/v023-expiry` 브랜치 정리 (local + remote) +- v0.2.3 진행: 7항목 중 3/7 완료. 다음 #1 ollama 회복. + +--- + +## Self-Review (작성 후 점검) + +### Spec coverage 매트릭스 + +| spec §10.1 항목 | 본 plan task | +|----------------|------------| +| `findExpiredCandidates({today})` | T2 | +| `trashBatch` (Inbox 상단 만료 배너 path) | T3 | +| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | T7 | +| IPC `inbox:listExpired`, `inbox:trashBatch` (`inbox:trashExpiredBatch`) | T5 | +| Telemetry `expired_banner_shown` `{candidateCount}` | T4 + T5 (CaptureService dedup) | +| Telemetry `expired_batch_trash` `{count}` | T4 + T5 (CaptureService trashExpiredBatch) | +| 단위 테스트 ≥ 16개 | T1(2) + T2(6) + T3(4) + T4(4) + T5(6) + T6(4) = 26 | + +> 주: spec §3 IPC 채널명을 `inbox:trashBatch` → `inbox:trashExpiredBatch` 로 변경 (의미 명확화 — `trash` 가 단건 IPC 채널명과 충돌 가능성 회피). spec §3 한 줄 갱신 필요시 closure 단계에 반영. + +### 일관성 + +- T1 의 `todayInKstString` / `nextKstMidnightMs` → T2 (repo) / T6 (store) 에서 일관 사용. +- T3 의 `trashBatch` → T5 의 `trashExpiredBatch` 가 호출. +- T4 의 zod 2 schema → T5 의 emit 호출이 100% 일치 payload shape. +- T5 의 IPC `{ trashedCount, confirmed }` 반환 → T6 의 store action 의 `r.confirmed` 가드와 일치. +- T7 의 `ExpiryBanner` 의 candidates 필드 (id / aiTitle / rawText / dueDate / tags[].name) 가 `Note` 타입의 부분집합 — type 호환. + +### Out 항목 일관 처리 + +- D-7 임박 → 본 plan 어디에도 등장 안 함. ✓ +- snooze 영속화 → store 의 `expiredSnoozeUntilMs` 는 in-memory only. ✓ +- 시스템 알림 surface → 0건. ✓ +- AI 가중치 차등 → SQL `WHERE` 에 `due_date_edited_by_user` 절 무관. ✓ + +### Self-review 후 수정 (placeholder/contradiction/ambiguity) + +- `inbox:trashBatch` (spec) vs `inbox:trashExpiredBatch` (plan) 채널명 차이 — closure 단계 (T8) 에서 spec §3 갱신 1줄 동봉. +- T6 의 `nextKstMidnightMs` import 가 renderer 에서 막히면 inline fallback 명시 (Step 3 주석).