# 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 <note columns + JOIN tags + media> 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:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. |

CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산.

---

## 4. 상태 관리 (zustand)

```ts
// src/renderer/inbox/store.ts
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null;  // KST 자정 epoch ms
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
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`

위치: `<App.tsx>` 의 `<PendingBanner />` 아래 (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=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.

---

## 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개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합).

### 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). |
| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. |
