From 4c2769fd8233c2b79c5504e42a6448e8672dd551 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:22:38 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs(spec):=20v0.2.3=20#5=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=B6=94=EC=B2=9C=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../specs/2026-05-01-v023-expiry-design.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-v023-expiry-design.md diff --git a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md new file mode 100644 index 0000000..9deeacc --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md @@ -0,0 +1,293 @@ +# v0.2.3 #5 만료 추천 설계 + +**작성일:** 2026-05-01 +**저자:** 김태현 (dlsrks0734@gmail.com) +**문서 성격:** v0.2.3 cut 7항목 중 3번째 항목 (#5 만료 추천) 의 mini-brainstorm 결정 + design. roadmap §3 #5 의 In/Out 위에서 §8 의 미결정 3항목 + UI 위치/0건 처리 추가 결정. + +**선행 문서:** +- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #5 (In/Out), §8 (미결정 항목) +- `docs/superpowers/specs/2026-05-01-v023-trash-design.md` (#4 trash, deleted_at 인프라) +- `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` (due_date 컬럼 + AI 추출 흐름) + +--- + +## 1. 결정 요약 + +| Q | 결정 | 근거 | +|---|------|------| +| Q1 `due_date_edited_by_user` 필터 | **B 필터 없음** — AI 자동 + 사용자 수동 모두 후보 | 의도와 무관하게 "지나간 due_date" 는 트리아지 대상. AI 자동 가중치 차등은 v0.2.4 로. | +| Q2 만료 임박 (D-7) | **A 만료만** (`due_date < today`) | roadmap §3 #5 Out 명시. 임박은 의미 (주의 환기 vs trash) 가 달라 분리 surface 필요. v0.2.4. | +| Q3 멀티선택 default | **C unchecked default + "전체 선택" 토글 버튼** | 데이터 안전 우선 (v0.2.1 패턴). 일괄도 토글 한 번. | +| Q4 배너 위치 | **B PendingBanner 아래** | system(Ollama) → progress(Pending) → actionable(Expired) → filter(tagFilter) 순. | +| Q5 후보 0건 / snooze | **A collapse** (렌더링 생략) | PendingBanner `pendingCount===0` → null 패턴 일치. 빈 카피는 노이즈. | + +--- + +## 2. 데이터 / 쿼리 + +### 2.1 NoteRepository 확장 + +```ts +// src/main/repository/NoteRepository.ts + +findExpiredCandidates({ today }: { today: string }): Note[]; +trashBatch(ids: string[], deletedAt: string): { trashedCount: number }; +``` + +- `today`: `'YYYY-MM-DD'` 문자열 (KST 자정 기준 오늘 날짜). caller 가 KST 기준 계산 후 주입 (테스트에서 clock injection 용이). +- `due_date` 도 `'YYYY-MM-DD'` 저장 (slice §F1 invariant 일치). +- `findExpiredCandidates` SQL: + ```sql + 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 + ``` +- `trashBatch` 는 단일 `db.transaction()` 안에서 `repo.trash(id, deletedAt)` 반복. 이미 trash 된 id 는 silent skip (UPDATE 가 deleted_at 이 이미 set 인 row 에 영향 0건). 반환 `trashedCount` 는 실제 transition (active → trash) 발생 건수. pending_jobs 정리는 `trash()` 가 이미 처리. + +### 2.2 KST 자정 today 계산 + +```ts +// src/main/util/kstDate.ts (재사용 또는 신설) +export function todayInKst(now: Date): string { + const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + const kst = new Date(now.getTime() + KST_OFFSET_MS); + return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD' +} +``` + +- ContinuityService 의 KST_OFFSET_MS 패턴 재사용. 신규 util 또는 ContinuityService 의 helper 추출. +- 단위 테스트: UTC 23:30 (KST 다음날 08:30) 케이스 검증. + +--- + +## 3. IPC + +| 채널 | 입력 | 출력 | 설명 | +|------|------|------|------| +| `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. | +| `inbox:trashBatch` | `{ ids: string[] }` | `{ trashedCount: number }` | atomic batch trash. ids 빈 배열 시 즉시 `{ trashedCount: 0 }`. | + +CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산. + +--- + +## 4. 상태 관리 (zustand) + +```ts +// src/renderer/inbox/store.ts +expiredCandidates: Note[]; +expiredSnoozeUntilMs: number | null; // KST 자정 epoch ms +loadExpired: () => Promise; +trashExpiredBatch: (ids: string[]) => Promise; +snoozeExpired: () => void; +``` + +### 4.1 동작 사양 + +- `loadExpired()`: IPC `inbox:listExpired` 호출 → `expiredCandidates` 갱신. +- `loadInitial()` + `refreshMeta()` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류. +- `trashExpiredBatch(ids)`: IPC `inbox:trashBatch` 호출 → 성공 시 `expiredCandidates` 에서 ids 제거 + `trashCount` 증가 + `notes` 에서도 제거 (낙관적 갱신, restore 와 동일 패턴 — main 은 push 안 함). +- `snoozeExpired()`: KST 자정 epoch ms 계산해 `expiredSnoozeUntilMs` 에 set. 컴포넌트에서 `Date.now() < snoozeUntil` 체크. +- in-memory only. 앱 재시작 시 다시 노출 (roadmap §3 #5 In 의 명시 사양). + +### 4.2 KST 자정 epoch 계산 + +```ts +function nextKstMidnightMs(now: number): number { + const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + const kstNow = now + KST_OFFSET_MS; + const kstMidnight = Math.ceil(kstNow / 86_400_000) * 86_400_000; + return kstMidnight - KST_OFFSET_MS; +} +``` + +단위 테스트: KST 23:00 호출 시 다음날 00:00 KST 반환 (1시간 후), KST 00:01 호출 시 같은 날 자정 24시간 후 (23h59m 후). + +--- + +## 5. UI — `ExpiryBanner` + +위치: `` 의 `` 아래 (showTrash=false 분기 안). + +### 5.1 collapse 조건 (렌더 null) + +``` +expiredCandidates.length === 0 + || (expiredSnoozeUntilMs !== null && Date.now() < expiredSnoozeUntilMs) +``` + +### 5.2 unfolded 구조 + +``` +┌─ ⏰ 오늘 기준 만료 N개 [▼ 펼침/▲ 접힘] [오늘 그만] ─┐ +│ │ +│ ☐ [전체 선택] │ +│ ☐ 노트 제목 1 · due 2026-04-20 · #회의 │ +│ ☐ 노트 제목 2 · due 2026-04-18 · #학습 │ +│ ... │ +│ │ +│ [선택 휴지통 (M개)] (M=0 시 disabled) │ +└──────────────────────────────────────────────────────────┘ +``` + +**헤더 (1줄)** +- "⏰ 오늘 기준 만료 {N}개" +- 펼침 토글 버튼 (▼/▲) +- "오늘 그만" 버튼 — 클릭 시 `snoozeExpired()` → 배너 즉시 collapse + +**펼침 영역** +- 첫 노출 시 default = **펼침** (사용자가 만료 N건 + 어떤 노트인지 동시 노출). +- 한 번 접으면 component-local useState 로 세션 동안 접힘 유지. reload 시 다시 펼침. +- "전체 선택" 체크박스 — 모든 row 동시 toggle. partial 선택 시 indeterminate 상태 표시. +- 노트 row: 체크박스 + 제목(truncate, max 1 line) + due_date + 태그 chip (1개, 없으면 생략). +- row 전체 clickable — 클릭 시 체크박스 toggle (편집/펼침 액션 없음, read-only triage 모드). +- "선택 휴지통 ({M}개)": M=0 시 disabled. 클릭 시 native confirm dialog ("선택한 {M}개를 휴지통으로 옮깁니다.\n\n복구는 휴지통 탭에서 가능합니다.") → 확인 시 `trashExpiredBatch(selectedIds)`. + +### 5.3 confirm dialog + +`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', buttons=['취소','옮기기'], default=0 (취소). + +--- + +## 6. Telemetry + +### 6.1 신규 events + +| event | payload | 발화 | +|-------|---------|------| +| `expired_banner_shown` | `{ candidateCount: number }` | `loadExpired()` 결과 `candidates.length > 0` 시. 같은 세션에 동일 후보 set 중복 emit 회피 (last shown signature 비교). | +| `expired_batch_trash` | `{ count: number }` | `trashBatch` 성공 직후 (count = trashedCount). | + +### 6.2 중복 emit 회피 — signature + +`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). zustand store 의 `lastExpiredShownSig: string | null` 비교. 같으면 emit skip. + +### 6.3 zod 스키마 + +```ts +// src/main/services/TelemetryService.ts (TelemetryEvent discriminatedUnion 확장) +z.object({ + kind: z.literal('expired_banner_shown'), + payload: z.object({ candidateCount: z.number().int().nonnegative() }).strict() +}).strict(), +z.object({ + kind: z.literal('expired_batch_trash'), + payload: z.object({ count: z.number().int().nonnegative() }).strict() +}).strict(), +``` + +### 6.4 stats.md 집계 추가 + +| 행 | 산식 | +|----|------| +| 만료 배너 노출 | `expired_banner_shown` count | +| 만료 일괄 trash | `expired_batch_trash` total `count` 합 | +| 만료 trash ratio | `sum(expired_batch_trash.count) / sum(expired_banner_shown.candidateCount)` | + +--- + +## 7. F5 export / F6 import / 백업 + +영향 0건. #4 가 이미 `deleted_at IS NULL` 을 export/active query 에 적용. 만료 후보는 active 노트의 부분집합이므로 별도 정책 불필요. + +**Regression guard**: 단위 테스트로 "사용자 수동 due_date 도 만료 후보" + "trash 된 만료 노트는 후보 제외" 회귀 가드 추가. + +--- + +## 8. 테스트 + +| 영역 | 단위 | 검증 | +|------|------|------| +| Repo | `findExpiredCandidates` happy path | due_date < today 만 반환, ORDER BY created_at DESC | +| Repo | `findExpiredCandidates` AI + 수동 mix | Q1=B 회귀 가드 — 둘 다 포함 | +| Repo | `findExpiredCandidates` deleted_at | trash 노트 제외 (#4 invariant 회귀 가드) | +| Repo | `findExpiredCandidates` ai_status | pending/failed 제외 | +| Repo | `findExpiredCandidates` due_date NULL | NULL 노트 제외 (NULL < string 평가 가드) | +| Repo | `trashBatch` atomic happy | N개 모두 trash, count=N | +| Repo | `trashBatch` 빈 배열 | count=0, no-op | +| Repo | `trashBatch` 일부 invalid id | valid 만 trash, count = valid 수 | +| Repo | `trashBatch` 이미 trash | 재호출 시 count=0 (idempotent) | +| util | `todayInKst` UTC vs KST 경계 | 23:30 UTC → 다음날 KST 날짜 | +| Service | `nextKstMidnightMs` | 자정 KST 정확 계산 | +| Telemetry | zod parse `expired_banner_shown` | candidateCount int ≥ 0 | +| Telemetry | zod parse `expired_batch_trash` | count int ≥ 0 | +| Telemetry | privacy invariant | payload 에 raw_text/title 포함 시 거부 (기존 invariant 회귀 가드) | +| Store | `loadExpired` integration | candidates set + count | +| Store | `trashExpiredBatch` 낙관적 갱신 | candidates 제거 + trashCount 증가 + notes 제거 | +| Store | `snoozeExpired` | snoozeUntilMs = 다음 KST 자정 epoch | + +총 단위 ≥ 16개. e2e smoke 영향 없음 (만료 노트 fixture 추가 없이 기존 1/1 e2e 보존). + +--- + +## 9. 작업 순서 (writing-plans 시 task 분할 가이드) + +T1. `findExpiredCandidates` repo + 단위 5개 (TDD) +T2. `trashBatch` repo + 단위 4개 (TDD) +T3. `todayInKst` util + `nextKstMidnightMs` 계산 + 단위 2개 +T4. Telemetry 2 events 추가 (zod + stats.md 집계 + 단위 3개) +T5. CaptureService 메소드 + IPC 2 채널 + 단위 +T6. zustand store 확장 + 단위 3개 +T7. `ExpiryBanner` 컴포넌트 (펼침/접힘/체크박스/전체선택/오늘그만) +T8. App.tsx 통합 (PendingBanner 아래 mount) +T9. confirm dialog + trashBatch 호출 path 통합 +T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure + +--- + +## 10. roadmap In/Out 일치 + +### 10.1 roadmap §3 #5 In 처리 매트릭스 + +| roadmap 항목 | 본 design | +|-------------|----------| +| `findExpiredCandidates({today})` | §2.1 ✓ | +| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | §5 ✓ | +| IPC `inbox:listExpired`, `inbox:trashBatch` | §3 ✓ | +| Telemetry `expired_banner_shown` `{candidateCount}` | §6.1 ✓ | +| Telemetry `expired_batch_trash` `{count}` | §6.1 ✓ | +| 단위 테스트 | §8 ✓ (16개) | + +### 10.2 roadmap §3 #5 Out 유지 + +- 시스템 알림 surface — Out +- 별 페이지 — Out +- snooze 영속화 — Out (in-memory + 자정 KST 리셋) +- "안 옮김" 가중치 감소 — Out +- 만료 임박 (D-7) 추천 — Out (Q2 confirmed) + +--- + +## 11. 위험 / 완화 + +| 위험 | 완화 | +|------|------| +| `due_date IS NOT NULL` 누락 시 NULL < string 평가 (SQLite 의 NULL 비교 결과 NULL → falsy) | 명시적 `WHERE due_date IS NOT NULL` + 단위 테스트 회귀 가드 | +| 사용자가 "오늘 그만" 후 다른 만료 노트 추가 시 배너 안 뜸 (자정까지) | 의도된 동작. 자정 KST 리셋 시 다시 노출. roadmap §3 #5 In 명시. | +| 같은 세션에 candidates 가 자주 바뀌면 (capture 등) `expired_banner_shown` 이 과다 emit | signature 비교 (§6.2) 로 회피 | +| `trashBatch` 의 `today` 가 caller 마다 다른 시점이면 race | main 단일 진입점 (CaptureService) 에서 호출 시점 1회 계산. renderer 가 today 주입 안 함. | +| ExpiryBanner 가 PendingBanner 사이에 끼어 layout shift | 양쪽 다 collapse 조건 명확 (count=0 → null) — shift 는 사용자 액션 결과 (예측 가능) | + +--- + +## 12. 게이트 (PR 머지 조건, roadmap §3.1 일치) + +- `npm run typecheck` 0 에러 +- `npm test` — 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상) +- `npm run test:e2e` 1/1 통과 +- main 머지 + +머지 후: +- roadmap `§3 #5 만료 추천 (3번)` 다음 `✓ 완료` 마커 +- `memory/project_v024_backlog.md` 에 deferred 항목 기록 (review 결과) + +--- + +## 13. 변경 이력 + +| 일자 | 변경 | +|------|------| +| 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). | -- 2.49.1 From c45e613b319acda6af229eaf2a40c23bc98a62f4 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:25:12 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs(spec):=20#5=20expiry=20=E2=80=94=20m?= =?UTF-8?q?ove=20dedup=20to=20main,=20keep=20IPC=20at=202=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.2 의 expired_banner_shown signature dedup 위치를 zustand store(renderer) → CaptureService(main) 로 변경. 결과: 신규 IPC 채널 1개 추가 회피. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/superpowers/specs/2026-05-01-v023-expiry-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md index 9deeacc..290c3d8 100644 --- a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md @@ -163,7 +163,7 @@ expiredCandidates.length === 0 ### 6.2 중복 emit 회피 — signature -`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). zustand store 의 `lastExpiredShownSig: string | null` 비교. 같으면 emit skip. +`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합). ### 6.3 zod 스키마 @@ -291,3 +291,4 @@ T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure | 일자 | 변경 | |------|------| | 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). | +| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. | -- 2.49.1 From a5e6859ac9cafe537a23a56ecf2def307fd3441e Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:30:48 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs(plan):=20v0.2.3=20#5=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=B6=94=EC=B2=9C=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=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 주석). -- 2.49.1 From 0a9dab4a7f5ba94bb56777143d537481e90b8eb8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:53:20 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(expiry):=20KST=20util=20=E2=80=94=20?= =?UTF-8?q?todayInKstString=20+=20nextKstMidnightMs=20(#5=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/util/kstDate.ts | 28 ++++++++++++++++++++++++++++ tests/unit/kstDate.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/main/util/kstDate.ts create mode 100644 tests/unit/kstDate.test.ts diff --git a/src/main/util/kstDate.ts b/src/main/util/kstDate.ts new file mode 100644 index 0000000..8e8e776 --- /dev/null +++ b/src/main/util/kstDate.ts @@ -0,0 +1,28 @@ +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; +} diff --git a/tests/unit/kstDate.test.ts b/tests/unit/kstDate.test.ts new file mode 100644 index 0000000..657829c --- /dev/null +++ b/tests/unit/kstDate.test.ts @@ -0,0 +1,37 @@ +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); + }); +}); -- 2.49.1 From 00423fb235c9d8a5caa3fe4b5f576196da7454a2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:57:53 +0900 Subject: [PATCH 05/12] feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3) --- src/main/repository/NoteRepository.ts | 23 ++++++++ tests/unit/NoteRepository.test.ts | 81 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index f7b74b1..4884139 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,6 +1,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; +import { todayInKstString } from '../util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -405,6 +406,28 @@ export class NoteRepository { return row.c; } + /** + * 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)); + } + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { const rows = this.db .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 6262ec0..9720181 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -448,3 +448,84 @@ describe('Active queries exclude deleted notes', () => { expect(repo.getPendingCount()).toBe(1); }); }); + +describe('NoteRepository.findExpiredCandidates', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + 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' }); + 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')); + 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' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([past]); + }); +}); -- 2.49.1 From fec80361ddcdba9e160542125fecfde3ba3de3fd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:01:03 +0900 Subject: [PATCH 06/12] feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3) --- src/main/repository/NoteRepository.ts | 26 ++++++++++++++++ tests/unit/NoteRepository.test.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 4884139..37bb549 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -241,6 +241,32 @@ export class NoteRepository { tx(); } + /** + * 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 }; + } + restore(id: string): void { const now = new Date().toISOString(); this.db diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 9720181..ba9d753 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -529,3 +529,48 @@ describe('NoteRepository.findExpiredCandidates', () => { expect(r.map((n) => n.id)).toEqual([past]); }); }); + +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'); + 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); + 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'); + }); +}); -- 2.49.1 From f76ca06d9e302e2594d82213073207c78fe7be1f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:08:44 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(expiry):=20telemetry=202=20events=20?= =?UTF-8?q?=E2=80=94=20expired=5Fbanner=5Fshown=20/=20expired=5Fbatch=5Ftr?= =?UTF-8?q?ash=20(#5=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/TelemetryService.ts | 4 ++- src/main/services/telemetryEvents.ts | 12 ++++++++- src/main/services/telemetryStats.ts | 27 ++++++++++++++++--- tests/unit/TelemetryService.test.ts | 10 ++++--- tests/unit/telemetryEvents.test.ts | 38 +++++++++++++++++++++++++++ tests/unit/telemetryStats.test.ts | 23 ++++++++++++++++ 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 936a80e..6fb4600 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -22,7 +22,9 @@ export type EmitInput = | { kind: 'trash'; payload: { noteId: string } } | { kind: 'restore'; payload: { noteId: string } } | { kind: 'permanent_delete'; payload: { noteId: string } } - | { kind: 'empty_trash'; payload: { count: number } }; + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 0d1bcf9..378f4ee 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -28,6 +28,14 @@ const EmptyTrashPayload = z.object({ count: z.number().int().nonnegative() }).strict(); +const ExpiredBannerShownPayload = z.object({ + candidateCount: z.number().int().nonnegative() +}).strict(); + +const ExpiredBatchTrashPayload = z.object({ + count: 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(), @@ -35,7 +43,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ 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('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() ]); export type TelemetryEvent = z.infer; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 0fbf768..c5dedc4 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -18,6 +18,8 @@ interface DailyRow { restore: number; permanent_delete: number; empty_trash: number; + expired_banner_shown: number; + expired_batch_trash: number; } export interface StatsResult { @@ -34,11 +36,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let durationN = 0; let trashCount = 0; let restoreCount = 0; + let expiredBannerShownCandidatesSum = 0; + let expiredBatchTrashCountSum = 0; for (const ev of events) { const day = kstDate(ev.ts); let row = byDay.get(day); if (!row) { - row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0 }; + 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 + }; byDay.set(day, row); } if (ev.kind === 'capture') row.capture += 1; @@ -60,6 +69,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta row.permanent_delete += 1; } else if (ev.kind === 'empty_trash') { row.empty_trash += 1; + } 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; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -69,6 +84,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta const trashRecoveryRate = trashCount === 0 ? 'N/A' : `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`; + const expiredTrashRatio = expiredBannerShownCandidatesSum === 0 + ? 'N/A' + : `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`; const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -77,10 +95,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 |'); - lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|'); + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |'); + 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} |`); + 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} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -88,6 +106,7 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- AI 성공률: ${successRate}`); lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`); lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`); + lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 5a3198c..8b7d1c8 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -146,8 +146,12 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(3); - // discriminant narrowing — empty_trash 같은 noteId 없는 kind 가 섞이면 명시적으로 실패 - expect(events.map((e) => e.kind === 'empty_trash' ? null : e.payload.noteId)).toEqual(['a', 'b', 'b']); + // discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패 + expect(events.map((e) => + (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash') + ? null + : e.payload.noteId + )).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { @@ -160,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(1); const ev = events[0]!; expect(ev.kind).toBe('capture'); - if (ev.kind !== 'empty_trash') expect(ev.payload.noteId).toBe('a'); + if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a'); }); it('returns [] when dir missing', async () => { diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 0b6ba41..f1a0187 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -149,3 +149,41 @@ describe('validateEvent — trash family (v0.2.3 #4)', () => { })).toThrow(); }); }); + +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(); + }); +}); diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index ab16994..181b0d7 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -101,3 +101,26 @@ describe('aggregateStats — trash family (v0.2.3 #4)', () => { expect(r.md).toContain('휴지통 회수율: N/A'); }); }); + +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')); + expect(r.md).toContain('expired_banner_shown'); + expect(r.md).toContain('expired_batch_trash'); + // 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/); + }); +}); -- 2.49.1 From 749235f65da57bb2c66557a1f3df104c05e6e939 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:13:49 +0900 Subject: [PATCH 08/12] feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3) --- src/main/ipc/inboxApi.ts | 25 ++++++ src/main/services/CaptureService.ts | 46 +++++++++++ src/preload/index.ts | 2 + src/shared/types.ts | 2 + tests/unit/CaptureService.test.ts | 115 ++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+) diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index c83e73e..475d38e 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -102,6 +102,31 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ); ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed()); + + 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 }; + } + ); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 63e0b76..8563f3f 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -1,5 +1,6 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { MediaStore } from './MediaStore.js'; +import type { Note } from '@shared/types'; export interface TelemetryEmitter { emit(input: @@ -8,6 +9,8 @@ export interface TelemetryEmitter { | { 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; } @@ -23,6 +26,8 @@ export interface SubmitInput { } export class CaptureService { + private lastExpiredShownSig: string | null = null; + constructor( private repo: NoteRepository, private store: MediaStore, @@ -111,4 +116,45 @@ export class CaptureService { } return { count: noteIds.length }; } + + /** + * 만료 후보 (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) { + 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; + } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7111851..7a35600 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,8 @@ const api: InklingApi = { emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'), listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts), getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'), + listExpired: () => ipcRenderer.invoke('inbox:listExpired'), + trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }), onNoteUpdated: (cb) => { const listener = (_e: unknown, note: Note) => cb(note); ipcRenderer.on('note:updated', listener); diff --git a/src/shared/types.ts b/src/shared/types.ts index e0b0179..33cca70 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -77,6 +77,8 @@ export interface InboxApi { emptyTrash(): Promise<{ confirmed: boolean; count: number }>; listTrash(opts: { limit: number }): Promise; getTrashCount(): Promise; + listExpired(): Promise; + trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>; onNoteUpdated(cb: (note: Note) => void): () => void; } diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index fefcd0a..3760d53 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -208,3 +208,118 @@ describe('CaptureService trash flow (v0.2.3 #4)', () => { expect(r.count).toBe(0); }); }); + +describe('CaptureService.listExpired (dedup signature)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let svc: CaptureService; + + function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void { + db.prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ).run(id, id, dueDate, createdAt, createdAt); + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('emits expired_banner_shown on first call when candidates > 0', async () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + expect(r).toHaveLength(2); + expect(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 () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + const showns = 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 () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + const showns = 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 r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + expect(r).toEqual([]); + expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]); + }); +}); + +describe('CaptureService.trashExpiredBatch', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let svc: CaptureService; + + function addExpired(id: string, dueDate: string): void { + db.prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z'); + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => { + addExpired('n1', '2026-04-20'); + addExpired('n2', '2026-04-22'); + const r = await svc.trashExpiredBatch(['n1', 'n2']); + expect(r.trashedCount).toBe(2); + expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([ + expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } }) + ]); + expect(calls.filter((c) => c.kind === 'trash')).toEqual([]); + }); + + it('returns trashedCount=0 for empty array (no emit)', async () => { + const r = await svc.trashExpiredBatch([]); + expect(r.trashedCount).toBe(0); + expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]); + }); +}); -- 2.49.1 From b7205597dbbe6ac51e5f45546a41509d02519aef Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:18:11 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat(expiry):=20zustand=20store=20extensi?= =?UTF-8?q?on=20=E2=80=94=20expiredCandidates=20+=20snooze=20(#5=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/inbox/store.ts | 43 +++++++++++-- tests/unit/store.expired.test.ts | 101 +++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/unit/store.expired.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 2c5e359..c27ed76 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -15,6 +15,8 @@ interface InboxState { todayCount: number; loading: boolean; tagFilter: string | null; + expiredCandidates: Note[]; + expiredSnoozeUntilMs: number | null; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -25,6 +27,9 @@ interface InboxState { restoreNote: (id: string) => Promise; permanentDeleteNote: (id: string) => Promise; emptyTrash: () => Promise; + loadExpired: () => Promise; + trashExpiredBatch: (ids: string[]) => Promise; + snoozeExpired: () => void; } const emptyContinuity: WeeklyContinuity = { @@ -43,27 +48,31 @@ export const useInbox = create((set, get) => ({ todayCount: 0, loading: false, tagFilter: null, + expiredCandidates: [], + expiredSnoozeUntilMs: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ + 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.getTrashCount(), + inboxApi.listExpired() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), inboxApi.getTodayCount(), - inboxApi.getTrashCount() + inboxApi.getTrashCount(), + inboxApi.listExpired() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates }); }, upsertNote(note) { // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 @@ -136,5 +145,27 @@ export const useInbox = create((set, get) => ({ if (r.confirmed) { set({ trashNotes: [], trashCount: 0 }); } + }, + async loadExpired() { + const expiredCandidates = await inboxApi.listExpired(); + set({ expiredCandidates }); + }, + async trashExpiredBatch(ids: string[]) { + const r = await inboxApi.trashExpiredBatch(ids); + if (!r.confirmed) return; + 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 }); } })); diff --git a/tests/unit/store.expired.test.ts b/tests/unit/store.expired.test.ts new file mode 100644 index 0000000..f94e0ec --- /dev/null +++ b/tests/unit/store.expired.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Note } from '@shared/types'; + +const mockApi = { + listNotes: vi.fn(async () => [] as Note[]), + listTrash: vi.fn(async () => [] as Note[]), + getTrashCount: vi.fn(async () => 0), + 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), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + deleteNote: vi.fn(async () => {}), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + listExpired: vi.fn(async () => [] as Note[]), + trashExpiredBatch: vi.fn(async (_ids: string[]) => ({ trashedCount: 0, confirmed: false })) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +const noteStub = (id: string): Note => ({ + id, rawText: 'x', + aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null, + aiProvider: null, aiGeneratedAt: null, + titleEditedByUser: false, summaryEditedByUser: false, + userIntent: null, intentPromptedAt: null, + dueDate: '2026-04-20', dueDateEditedByUser: false, + deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', + tags: [], media: [] +}); + +describe('useInbox — expired state (v0.2.3 #5)', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + notes: [], trashNotes: [], trashCount: 0, showTrash: false, + loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, + ollamaStatus: { ok: true }, + continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, + expiredCandidates: [], expiredSnoozeUntilMs: null + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + afterEach(() => { vi.restoreAllMocks(); }); + + it('loadExpired sets expiredCandidates from inboxApi', async () => { + mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + await useInbox.getState().loadExpired(); + const s = useInbox.getState(); + expect(s.expiredCandidates).toHaveLength(1); + expect(s.expiredCandidates[0]!.id).toBe('n1'); + }); + + it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => { + mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + expiredCandidates: [noteStub('n1'), noteStub('n2'), noteStub('n3')], + notes: [noteStub('n1'), noteStub('n2'), noteStub('n3')], + trashCount: 5 + }); + 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); + }); + + it('trashExpiredBatch does NOT mutate state when not confirmed', async () => { + mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 0, confirmed: false }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + expiredCandidates: [noteStub('n1'), noteStub('n2')], + notes: [noteStub('n1'), noteStub('n2')], + trashCount: 5 + }); + 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', async () => { + // 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → next KST midnight = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC + const fixedNow = Date.parse('2026-05-01T12:00:00Z'); + vi.spyOn(Date, 'now').mockReturnValue(fixedNow); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().snoozeExpired(); + expect(useInbox.getState().expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z')); + }); +}); -- 2.49.1 From 7cbbd4dc97e0187a3cbdc43dd6a5e2ee0e4af556 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:22:38 +0900 Subject: [PATCH 10/12] feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3) --- src/renderer/inbox/App.tsx | 2 + .../inbox/components/ExpiryBanner.tsx | 147 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/renderer/inbox/components/ExpiryBanner.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 2451227..ae1cdcb 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -9,6 +9,7 @@ import { PendingBanner } from './components/PendingBanner.js'; import { OllamaBanner } from './components/OllamaBanner.js'; import { RecoveryToast } from './components/RecoveryToast.js'; import { TagUndoToast } from './components/TagUndoToast.js'; +import { ExpiryBanner } from './components/ExpiryBanner.js'; export function App(): React.ReactElement { const { @@ -77,6 +78,7 @@ export function App(): React.ReactElement { onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }} /> + {tagFilter !== null && (
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 { + 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) => ( + + ))} +
+ + + )} +
+ ); +} -- 2.49.1 From 8a96d5279dc0042d2268a2b74e876dfed1bbc2a4 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:25:15 +0900 Subject: [PATCH 11/12] =?UTF-8?q?chore(expiry):=20#5=20closure=20=E2=80=94?= =?UTF-8?q?=20gates=20verified=20+=20roadmap=20mark=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/superpowers/specs/2026-05-01-v023-expiry-design.md | 2 +- .../specs/2026-05-01-v023-feedback-roadmap-design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md index 290c3d8..8049ff6 100644 --- a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md @@ -68,7 +68,7 @@ export function todayInKst(now: Date): string { | 채널 | 입력 | 출력 | 설명 | |------|------|------|------| | `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. | -| `inbox:trashBatch` | `{ ids: string[] }` | `{ trashedCount: number }` | atomic batch trash. ids 빈 배열 시 즉시 `{ trashedCount: 0 }`. | +| `inbox:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. | CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산. 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 c19b18a..e992cbd 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 @@ -107,7 +107,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── **Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그 -### #5 만료 추천 (3번) +### #5 만료 추천 (3번) ✓ 완료 **In:** - `NoteRepository.findExpiredCandidates({today})`: -- 2.49.1 From d672ec3afad453cd145c49947a9996edfb644246 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:47:58 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix(expiry):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20minor/nit=206=EA=B1=B4=20=EC=9D=BC=EA=B4=84=20(#5?= =?UTF-8?q?=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../specs/2026-05-01-v023-expiry-design.md | 2 +- src/main/services/CaptureService.ts | 2 ++ .../inbox/components/ExpiryBanner.tsx | 26 +++++++++++++------ tests/unit/telemetryEvents.test.ts | 8 ++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md index 8049ff6..7347370 100644 --- a/docs/superpowers/specs/2026-05-01-v023-expiry-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-expiry-design.md @@ -148,7 +148,7 @@ expiredCandidates.length === 0 ### 5.3 confirm dialog -`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', buttons=['취소','옮기기'], default=0 (취소). +`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리. --- diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 8563f3f..f79ddd7 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -125,6 +125,8 @@ export class CaptureService { async listExpired(now: Date = new Date()): Promise { const candidates = this.repo.findExpiredCandidates(now); if (candidates.length === 0) { + // empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit. + // (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식) this.lastExpiredShownSig = null; return candidates; } diff --git a/src/renderer/inbox/components/ExpiryBanner.tsx b/src/renderer/inbox/components/ExpiryBanner.tsx index 9dcd3b6..bed77be 100644 --- a/src/renderer/inbox/components/ExpiryBanner.tsx +++ b/src/renderer/inbox/components/ExpiryBanner.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import type { Note } from '@shared/types'; import { useInbox } from '../store.js'; export function ExpiryBanner(): React.ReactElement | null { @@ -6,6 +7,16 @@ export function ExpiryBanner(): React.ReactElement | null { const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs); const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch); const snoozeExpired = useInbox((s) => s.snoozeExpired); + // n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거. + // 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장. + 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]); // Q5=A: 0건 / snooze 활성 시 collapse if (candidates.length === 0) return null; @@ -13,19 +24,18 @@ export function ExpiryBanner(): React.ReactElement | null { return void trashExpiredBatch(ids)} + onTrash={(ids) => { + trashExpiredBatch(ids).catch((e) => { + // eslint-disable-next-line no-console + console.warn('trashExpiredBatch failed', e); + }); + }} onSnooze={() => snoozeExpired()} />; } interface InnerProps { - candidates: Array<{ - id: string; - aiTitle: string | null; - rawText: string; - dueDate: string | null; - tags: Array<{ name: string }> - }>; + candidates: Note[]; onTrash: (ids: string[]) => void; onSnooze: () => void; } diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index f1a0187..12020a6 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -186,4 +186,12 @@ describe('expired_banner_shown / expired_batch_trash events', () => { payload: { count: -1 } })).toThrow(); }); + + it('rejects expired_batch_trash with extra payload field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_batch_trash', + payload: { count: 3, rawText: 'leak' } + })).toThrow(); + }); }); -- 2.49.1