feat(expiry): #5 만료 추천 (v0.2.3 3/7) #15
Reference in New Issue
Block a user
Delete Branch "feat/v023-expiry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
v0.2.3 cut 7항목 중 3번째 — 만료 추천 ExpiryBanner.
due_date < today AND deleted_at IS NULL AND ai_status = 'done'후보를 Inbox 상단 배너로 노출, 멀티선택 (unchecked default + 전체선택 토글) → 선택 휴지통 일괄 trash, 오늘 그만 snooze (자정 KST 리셋, in-memory).선행 cut: #7 telemetry (PR #13 머지) + #4 휴지통 (PR #14 머지). 본 cut 은 둘 위에서 findExpiredCandidates repo + trashBatch repo + 2 telemetry events + IPC 2채널 + ExpiryBanner 컴포넌트.
Architecture
Decisions (mini-brainstorm)
Telemetry
Tests
Gates
Test plan
Refs
🤖 Generated with Claude Code
v0.2.3 #5 만료 추천 구현은 spec §10.1 In 항목 6개를 모두 매핑하고 Out 항목 5개를 일관되게 배제. 326/326 unit + 1/1 e2e + typecheck 0 에러로 게이트 모두 통과. KST 자정 수학, dedup signature, atomic trashBatch, optimistic store 갱신, zod .strict() privacy invariant 모두 정확. 작은 일관성/커버리지 nit 만 남음 — v0.2.4 backlog 으로 충분.
@@ -105,0 +112,4 @@const win = deps.getInboxWindow();const opts: Electron.MessageBoxOptions = {type: 'question',buttons: ['옮기기', '취소'],[minor] spec §5.3 은
buttons=['취소','옮기기'], default=0 (취소)를 명시했지만 구현은['옮기기','취소'], defaultId=1, cancelId=1(project 의 permanentDelete/emptyTrash 패턴과 일치). 결과적으로 default focus + Esc behavior 는 동일 (둘 다 취소) 이라 기능 동등하지만, visual button 순서가 spec 과 다름. 의도된 deviation 으로 보이므로 spec §5.3 의 1줄을 project 패턴으로 갱신하거나 PR description 에 deviation 명시 권장.@@ -408,0 +443,4 @@const today = todayInKstString(now);const rows = this.db.prepare(`SELECT * FROM notes[nit]
findExpiredCandidates가as any[]사용 — 기존 repository 의hydrate패턴과 일관. 신규 leak 아님. 전반 cleanup 은 v0.2.4 backlog #4~#6 영향과 합산해 검토.@@ -114,0 +128,4 @@this.lastExpiredShownSig = null;return candidates;}const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`;[nit] dedup signature 가 candidates 가 일시 empty (모두 trash) → 다시 동일 set 차오름 시 emit 됨 (
lastExpiredShownSig=nullreset 때문). 이는 spec §6.2 의 의도 (empty 후 다시 차오르면 다음 호출에 emit) 와 일치하므로 의도된 동작. nit: 주석에 명시되어 있으면 future maintainer 에게 친절.@@ -0,0 +10,4 @@// Q5=A: 0건 / snooze 활성 시 collapseif (candidates.length === 0) return null;if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;[nit] snooze 만료 (자정 KST) 가 앱이 24h+ 켜진 상태에서 즉시 반영되지 않음 —
Date.now() < snoozeUntilMs가 render 시점에만 평가되며setInterval강제 re-render 없음.refreshMeta가 다른 trigger 로 fire 되면 자연 갱신되는 구조. 매우 드문 edge case (사용자 앱 24h+ open + snooze 활성 + 자정 직후) 이므로 v0.2.4 에 메모만 충분.@@ -0,0 +13,4 @@return <ExpiryBannerInnercandidates={candidates}onTrash={(ids) => void trashExpiredBatch(ids)}[minor]
onTrash={(ids) => void trashExpiredBatch(ids)}은 Promise rejection 을 silent swallow. 현재는 IPC 측이 dialog 취소 시 정상 return 이므로 reject 경로가 없지만, 향후 IPC 가 throw 하면 사용자 피드백 0. v0.2.4 에서 inbox 전반 error toast 도입 시 함께 손볼 후보 (#4 의 다른 actions 도 동일 패턴이라 단독 fix 는 무의미).@@ -0,0 +27,4 @@tags: Array<{ name: string }>}>;onTrash: (ids: string[]) => void;onSnooze: () => void;[minor]
InnerProps.candidates가Note의 narrow subset (id/aiTitle/rawText/dueDate/tags) 만 받음. 구조적 호환은 OK 이지만Note타입이 v0.2.4 에서 진화하면 silent drift 가능.import type { Note } from '@shared/types'후candidates: Note[]로 통일하면 store→component 흐름 한 타입 유지.@@ -152,0 +175,4 @@expect(() => validateEvent({ts: '2026-05-01T00:00:00.000Z',kind: 'expired_banner_shown',payload: { candidateCount: 7, rawText: 'leak' }[minor]
expired_banner_shown의 extra-field privacy invariant test 만 존재.expired_batch_trash도.strict()이지만 대칭 회귀 가드 없음. 한 줄 추가 권장 —payload: { count: 3, rawText: 'leak' }가 throw 하는지 확인.round 2: