Files
inkling/docs/superpowers/specs/2026-05-01-v023-expiry-design.md
altair823 d672ec3afa fix(expiry): review round 1 — minor/nit 6건 일괄 (#5 v0.2.3)
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>
2026-05-02 00:47:58 +09:00

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 일치).
  • findExpiredCandidates 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 계산

// 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(): IPC inbox:listExpired 호출 → expiredCandidates 갱신.
  • loadInitial() + refreshMeta()Promise.allinboxApi.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 계산

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) 로 회피
trashBatchtoday 가 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 단계 단순화.