Files
inkling/docs/superpowers/plans/2026-05-01-v023-expiry.md
altair823 a5e6859ac9 docs(plan): v0.2.3 #5 만료 추천 구현 계획
8 task TDD 분할 + 단위 26개 (spec §8 의 16개 충족 + 6 over):
- T1 KST util (todayInKstString + nextKstMidnightMs)
- T2 NoteRepository.findExpiredCandidates
- T3 NoteRepository.trashBatch (atomic)
- T4 telemetry 2 events + stats.md 만료 trash ratio
- T5 CaptureService listExpired/trashExpiredBatch + IPC 2채널 + preload
- T6 zustand store 확장
- T7 ExpiryBanner 컴포넌트 + App.tsx mount
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:30:48 +09:00

56 KiB

#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 discriminatedUnionexpired_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) <PendingBanner /> 아래 <ExpiryBanner /> 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: 실패 테스트 작성

// 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: 구현
// 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: 커밋
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 들 그대로 사용):

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 추가:

import { todayInKstString } from '../util/kstDate.js';

countToday() 직후 위치에 메서드 추가:

/**
 * 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: 커밋
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 끝에 추가:

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.tstrash() 메서드 직후에 추가:

/**
 * 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: 커밋
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 들 그대로):

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 확장
// 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();

TelemetryEventSchemadiscriminatedUnion 배열에 두 entry 추가:

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 항 추가:

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 들 그대로 사용):

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 필드 추가:

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) 함수 상단 누적 카운터 선언 추가:

let expiredBannerShownCandidatesSum = 0;
let expiredBatchTrashCountSum = 0;

(b) byDay.get 에서 새 row 만들 때 2 카운터 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
};

(c) for-loop 안의 if-else chain 끝에 두 분기 추가:

} 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 컬럼 추가:

lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |');
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|');

(e) 표 body 행 변경:

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줄 추가 (휴지통 회수율 다음):

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: 커밋
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 헬퍼 사용):

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 가 없는 경우 파일 상단에 추가):

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<void> {
  // 동일 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.tsTelemetryEmitter interface 에 2 union 추가:

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<void>;
}

CaptureService 클래스 안에 dedup field + 2 새 메서드 추가:

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<Note[]> {
    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 가 없으면 추가):

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.tsInboxApi interface 안 (getTrashCount 다음 어디든):

export interface InboxApi {
  // ... 기존 필드들 ...
  listExpired: () => Promise<Note[]>;
  trashExpiredBatch: (ids: string[]) => Promise<{ trashedCount: number }>;
}
  • Step 6: IPC 2 채널 등록

src/main/ipc/inboxApi.tsregisterInboxApi 함수 끝쪽 (inbox:trashCount 다음) 에 2 채널 추가:

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 의 시그너처 다듬기:

trashExpiredBatch: (ids: string[]) => Promise<{ trashedCount: number; confirmed: boolean }>;
  • Step 7: preload bridge

src/preload/index.tsinbox 객체에 2 메서드 추가 (getTrashCount 다음):

listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
  • Step 8: renderer api wrapper

src/renderer/inbox/api.tswindow.inkling.inbox.* 를 wrap 하는 패턴이라면 동일 메서드 2개 노출 (기존 패턴 mirroring). 파일이 wrap 없이 직접 expose 라면 추가 불필요.

만약 inboxApi{ ...window.inkling.inbox } 형태면:

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: 커밋
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 추가, 없으면 신설:

// 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 가 없으면 위 파일을 새로 생성. 기존 모킹 패턴이 다르면 (예: inboxApiimport.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.tsInboxState interface 확장:

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<void>;
  trashExpiredBatch: (ids: string[]) => Promise<void>;
  snoozeExpired: () => void;
}

initial state 에 2 필드 추가:

expiredCandidates: [],
expiredSnoozeUntilMs: null,

loadInitial / refreshMetaPromise.allinboxApi.listExpired() 합류:

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 추가:

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 });
}

snoozeExpirednextKstMidnightMs 와 동일 알고리즘 — 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: 커밋
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 작성
// 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 <ExpiryBannerInner
    candidates={candidates}
    onTrash={(ids) => 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<boolean>(true);
  const [selected, setSelected] = useState<Set<string>>(new Set());

  // candidates 가 변하면 selected 의 stale id 정리
  useEffect(() => {
    const valid = new Set(candidates.map((c) => c.id));
    setSelected((prev) => {
      const next = new Set<string>();
      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 (
    <div style={{
      background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
      padding: '8px 12px', margin: '8px 0', fontSize: 13
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span> <b>오늘 기준 만료 {candidates.length}</b></span>
        <button
          onClick={() => setExpanded((e) => !e)}
          style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
          aria-expanded={expanded}
        >
          {expanded ? '▲ 접기' : '▼ 펼치기'}
        </button>
        <button
          onClick={onSnooze}
          style={{
            marginLeft: 'auto',
            background: 'transparent', color: '#946100',
            border: '1px solid #d99500', borderRadius: 4,
            padding: '2px 8px', fontSize: 12, cursor: 'pointer'
          }}
        >
          오늘 그만
        </button>
      </div>
      {expanded && (
        <>
          <label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '8px 0 4px', cursor: 'pointer' }}>
            <input
              type="checkbox"
              checked={allSelected}
              ref={(el) => { if (el) el.indeterminate = someSelected; }}
              onChange={toggleAll}
            />
            <span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
          </label>
          <div>
            {candidates.map((n) => (
              <label
                key={n.id}
                style={{
                  display: 'flex', alignItems: 'center', gap: 8,
                  padding: '4px 0', cursor: 'pointer'
                }}
              >
                <input
                  type="checkbox"
                  checked={selected.has(n.id)}
                  onChange={() => toggle(n.id)}
                />
                <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {n.aiTitle ?? n.rawText.slice(0, 60)}
                </span>
                <span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
                {n.tags[0] && (
                  <span style={{
                    background: '#fce8b2', color: '#946100', padding: '0 6px',
                    borderRadius: 10, fontSize: 11
                  }}>
                    #{n.tags[0].name}
                  </span>
                )}
              </label>
            ))}
          </div>
          <button
            onClick={() => onTrash(Array.from(selected))}
            disabled={selected.size === 0}
            style={{
              marginTop: 8,
              background: selected.size === 0 ? '#999' : '#a33', color: '#fff',
              border: 'none', borderRadius: 4,
              padding: '4px 12px', fontSize: 12,
              cursor: selected.size === 0 ? 'not-allowed' : 'pointer'
            }}
          >
            선택 휴지통 ({selected.size})
          </button>
        </>
      )}
    </div>
  );
}
  • Step 2: App.tsx 에 mount

src/renderer/inbox/App.tsx 에서 import 추가:

import { ExpiryBanner } from './components/ExpiryBanner.js';

<PendingBanner /> 직후 (line ~79) 에 mount:

<PendingBanner />
<ExpiryBanner />
  • 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: 수동 검증 — 개발 모드
npm run dev

수동 확인:

  • 아무 노트도 만료되지 않은 상태 → 배너 noShown.

  • 임의 노트 작성 + dev tools 콘솔에서 due_date 를 과거로 set → 만료 후보로 등장.

  • ExpiryBanner 펼침 / 접힘 / 전체 선택 / 부분 선택 / "선택 휴지통" 클릭 → confirm dialog → 확인 → 후보 사라짐 + 휴지통 탭 N건 증가.

  • "오늘 그만" → 배너 즉시 사라짐. 새로고침 (앱 재시작) 시 다시 등장.

  • Step 5: 커밋

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: 전체 게이트 검증

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번) ✓ 완료 로 변경.

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 후에 합산). 예시 형식:

## v0.2.3 #5 (2026-05-DD)

- {item} — {reviewer / round} / {decision: deferred / nit}
- ...
  • Step 4: closure 커밋
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:trashBatchinbox: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 WHEREdue_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 주석).