m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소). m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant). m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지. m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지) n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거) n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해. n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
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 확장
// 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 일치).findExpiredCandidatesSQL: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 DESCtrashBatch는 단일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 계산
// 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)
// 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(): IPCinbox:listExpired호출 →expiredCandidates갱신.loadInitial()+refreshMeta()의Promise.all에inboxApi.listExpired()합류.trashExpiredBatch(ids): IPCinbox: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 계산
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 스키마
// 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 typecheck0 에러npm test— 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상)npm run test:e2e1/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 단계 단순화. |