feat(expiry): #5 만료 추천 (v0.2.3 3/7) #15

Merged
altair823 merged 12 commits from feat/v023-expiry into main 2026-05-01 15:52:40 +00:00
Owner

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

  • NoteRepository.findExpiredCandidates(now?: Date): Note[] — KST 기준 today < due_date 후보. AI 자동 + 사용자 수동 모두 (Q1=B 필터 없음). trash + pending/failed 제외.
  • NoteRepository.trashBatch(ids, deletedAt): { trashedCount } — 단일 transaction, 이미 trash 인 id silent skip (idempotent).
  • CaptureService.listExpired(now) — dedup signature count:first-3-ids 기반 expired_banner_shown 자동 emit. 같은 후보 set 중복 emit 회피.
  • CaptureService.trashExpiredBatch(ids) — atomic batch trash + expired_batch_trash 1회 emit.
  • KST util: src/main/util/kstDate.ts — todayInKstString(now) + nextKstMidnightMs(now). ContinuityService / TelemetryService 의 inline KST 패턴 재사용.
  • IPC: inbox:listExpired (no args -> Note[]), inbox:trashExpiredBatch ({ids} -> {trashedCount, confirmed}). 후자 native confirm dialog.
  • store: expiredCandidates / expiredSnoozeUntilMs + loadExpired / trashExpiredBatch / snoozeExpired. loadInitial/refreshMeta 에 listExpired 합류.
  • UI: ExpiryBanner (PendingBanner 아래) — 헤더 + 펼침/접힘 + 체크박스 리스트 + 전체선택 토글 (indeterminate) + 선택 휴지통 + 오늘 그만. 0건/snooze 시 collapse.

Decisions (mini-brainstorm)

Q 결정
Q1 due_date_edited_by_user 필터 B — 필터 없음 (AI + 수동 모두)
Q2 D-7 임박 포함 A — 만료만 (D-7 v0.2.4)
Q3 멀티선택 default C — unchecked + 전체선택 토글
Q4 배너 위치 B — PendingBanner 아래 (system → progress → actionable → filter)
Q5 0건/snooze A — collapse (PendingBanner 패턴 일치)

Telemetry

  • expired_banner_shown { candidateCount } — main dedup signature, 같은 set 중복 회피
  • expired_batch_trash { count } — trashBatch 직후 1회 (per-id trash emit 안 함)
  • stats.md: 만료 trash ratio = sum(batch_trash.count) / sum(banner_shown.candidateCount)
  • privacy invariant: zod .strict() + int().nonnegative() (body 누출 0건)

Tests

  • 26 신규 단위 (spec §8 의 16개 충족 + 10 over)
  • T1 KST util: 5
  • T2 findExpiredCandidates: 6
  • T3 trashBatch: 4
  • T4 telemetry zod + stats: 6
  • T5 CaptureService listExpired/trashExpiredBatch: 6
  • T6 store: 4

Gates

  • typecheck 0 errors
  • 단위 326/326 (27 files)
  • e2e 1/1

Test plan

  • 신규 노트 작성 + 콘솔에서 due_date 과거로 set → 배너 등장 확인
  • 펼침/접힘 토글
  • 부분 선택 → indeterminate 체크박스
  • 전체 선택 토글 → 모두 선택/해제
  • 선택 휴지통 → confirm dialog → 확인 → 후보 사라짐 + 휴지통 탭 N개 증가
  • 오늘 그만 → 배너 즉시 collapse, 자정 KST 까지 안 보임
  • 앱 재시작 시 snooze 풀림 (in-memory, 의도된 동작)
  • 휴지통 탭 전환 시 배너 미렌더 (showTrash 분기)

Refs

  • spec: docs/superpowers/specs/2026-05-01-v023-expiry-design.md
  • plan: docs/superpowers/plans/2026-05-01-v023-expiry.md
  • roadmap: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md §3 #5
  • 선행: PR #13 (#7 telemetry), PR #14 (#4 trash)

🤖 Generated with Claude Code

## 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 - NoteRepository.findExpiredCandidates(now?: Date): Note[] — KST 기준 today < due_date 후보. AI 자동 + 사용자 수동 모두 (Q1=B 필터 없음). trash + pending/failed 제외. - NoteRepository.trashBatch(ids, deletedAt): { trashedCount } — 단일 transaction, 이미 trash 인 id silent skip (idempotent). - CaptureService.listExpired(now) — dedup signature count:first-3-ids 기반 expired_banner_shown 자동 emit. 같은 후보 set 중복 emit 회피. - CaptureService.trashExpiredBatch(ids) — atomic batch trash + expired_batch_trash 1회 emit. - KST util: src/main/util/kstDate.ts — todayInKstString(now) + nextKstMidnightMs(now). ContinuityService / TelemetryService 의 inline KST 패턴 재사용. - IPC: inbox:listExpired (no args -> Note[]), inbox:trashExpiredBatch ({ids} -> {trashedCount, confirmed}). 후자 native confirm dialog. - store: expiredCandidates / expiredSnoozeUntilMs + loadExpired / trashExpiredBatch / snoozeExpired. loadInitial/refreshMeta 에 listExpired 합류. - UI: ExpiryBanner (PendingBanner 아래) — 헤더 + 펼침/접힘 + 체크박스 리스트 + 전체선택 토글 (indeterminate) + 선택 휴지통 + 오늘 그만. 0건/snooze 시 collapse. ## Decisions (mini-brainstorm) | Q | 결정 | |---|------| | Q1 due_date_edited_by_user 필터 | B — 필터 없음 (AI + 수동 모두) | | Q2 D-7 임박 포함 | A — 만료만 (D-7 v0.2.4) | | Q3 멀티선택 default | C — unchecked + 전체선택 토글 | | Q4 배너 위치 | B — PendingBanner 아래 (system → progress → actionable → filter) | | Q5 0건/snooze | A — collapse (PendingBanner 패턴 일치) | ## Telemetry - expired_banner_shown { candidateCount } — main dedup signature, 같은 set 중복 회피 - expired_batch_trash { count } — trashBatch 직후 1회 (per-id trash emit 안 함) - stats.md: 만료 trash ratio = sum(batch_trash.count) / sum(banner_shown.candidateCount) - privacy invariant: zod .strict() + int().nonnegative() (body 누출 0건) ## Tests - 26 신규 단위 (spec §8 의 16개 충족 + 10 over) - T1 KST util: 5 - T2 findExpiredCandidates: 6 - T3 trashBatch: 4 - T4 telemetry zod + stats: 6 - T5 CaptureService listExpired/trashExpiredBatch: 6 - T6 store: 4 ## Gates - typecheck 0 errors - 단위 326/326 (27 files) - e2e 1/1 ## Test plan - [ ] 신규 노트 작성 + 콘솔에서 due_date 과거로 set → 배너 등장 확인 - [ ] 펼침/접힘 토글 - [ ] 부분 선택 → indeterminate 체크박스 - [ ] 전체 선택 토글 → 모두 선택/해제 - [ ] 선택 휴지통 → confirm dialog → 확인 → 후보 사라짐 + 휴지통 탭 N개 증가 - [ ] 오늘 그만 → 배너 즉시 collapse, 자정 KST 까지 안 보임 - [ ] 앱 재시작 시 snooze 풀림 (in-memory, 의도된 동작) - [ ] 휴지통 탭 전환 시 배너 미렌더 (showTrash 분기) ## Refs - spec: docs/superpowers/specs/2026-05-01-v023-expiry-design.md - plan: docs/superpowers/plans/2026-05-01-v023-expiry.md - roadmap: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md §3 #5 - 선행: PR #13 (#7 telemetry), PR #14 (#4 trash) 🤖 Generated with Claude Code
altair823 added 11 commits 2026-05-01 15:30:07 +00:00
mini-brainstorm 결과 5개 결정 박힘:
- Q1=B due_date_edited_by_user 필터 없음 (AI + 수동 모두)
- Q2=A 만료만 (D-7 임박 v0.2.4)
- Q3=C unchecked default + 전체선택 토글 (데이터 안전)
- Q4=B PendingBanner 아래 (system → progress → actionable)
- Q5=A 후보 0건 / snooze 시 collapse (PendingBanner 패턴)

T1-T10 작업 순서 + 단위 ≥ 16개 + IPC 2채널 + telemetry 2이벤트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§6.2 의 expired_banner_shown signature dedup 위치를 zustand store(renderer)
→ CaptureService(main) 로 변경. 결과: 신규 IPC 채널 1개 추가 회피.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- typecheck 0 errors
- 단위 326/326 (T1~T7 누적 26 신규)
- e2e 1/1
- spec §3 IPC 채널명 inbox:trashBatch → inbox:trashExpiredBatch 보정 (의미 명확화)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 15:34:53 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

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 으로 충분.

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 명시 권장.

[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] findExpiredCandidatesas any[] 사용 — 기존 repository 의 hydrate 패턴과 일관. 신규 leak 아님. 전반 cleanup 은 v0.2.4 backlog #4~#6 영향과 합산해 검토.

[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=null reset 때문). 이는 spec §6.2 의 의도 (empty 후 다시 차오르면 다음 호출에 emit) 와 일치하므로 의도된 동작. nit: 주석에 명시되어 있으면 future maintainer 에게 친절.

[nit] dedup signature 가 candidates 가 일시 empty (모두 trash) → 다시 동일 set 차오름 시 emit 됨 (`lastExpiredShownSig=null` reset 때문). 이는 spec §6.2 의 의도 (`empty 후 다시 차오르면 다음 호출에 emit`) 와 일치하므로 의도된 동작. nit: 주석에 명시되어 있으면 future maintainer 에게 친절.
@@ -0,0 +10,4 @@
// Q5=A: 0건 / snooze 활성 시 collapse
if (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 에 메모만 충분.

[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 <ExpiryBannerInner
candidates={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 는 무의미).

[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.candidatesNote 의 narrow subset (id/aiTitle/rawText/dueDate/tags) 만 받음. 구조적 호환은 OK 이지만 Note 타입이 v0.2.4 에서 진화하면 silent drift 가능. import type { Note } from '@shared/types'candidates: Note[] 로 통일하면 store→component 흐름 한 타입 유지.

[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 하는지 확인.

[minor] `expired_banner_shown` 의 extra-field privacy invariant test 만 존재. `expired_batch_trash` 도 `.strict()` 이지만 대칭 회귀 가드 없음. 한 줄 추가 권장 — `payload: { count: 3, rawText: 'leak' }` 가 throw 하는지 확인.
altair823 added 1 commit 2026-05-01 15:48:03 +00:00
m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소).

m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant).

m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지.

m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지)

n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거)

n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해.

n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 15:50:49 +00:00
claude-reviewer-01 left a comment
Member

round 2:

round 2:
altair823 merged commit da7455b25f into main 2026-05-01 15:52:40 +00:00
altair823 deleted branch feat/v023-expiry 2026-05-01 15:52:41 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/inkling#15