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>
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 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) |
<PendingBanner /> 아래 <ExpiryBanner /> mount (showTrash=false 분기 안). |
테스트:
tests/unit/kstDate.test.ts(new) —todayInKstStringUTC↔KST 경계 +nextKstMidnightMs정확 epoch.tests/unit/NoteRepository.test.ts(modify) —findExpiredCandidates5 케이스 +trashBatch4 케이스.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) —listExpireddedup 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.ts 의 trash() 메서드 직후에 추가:
/**
* 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();
TelemetryEventSchema 의 discriminatedUnion 배열에 두 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.ts 의 TelemetryEmitter 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.ts 의 InboxApi interface 안 (getTrashCount 다음 어디든):
export interface InboxApi {
// ... 기존 필드들 ...
listExpired: () => Promise<Note[]>;
trashExpiredBatch: (ids: string[]) => Promise<{ trashedCount: number }>;
}
- Step 6: IPC 2 채널 등록
src/main/ipc/inboxApi.ts 의 registerInboxApi 함수 끝쪽 (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.ts 의 inbox 객체에 2 메서드 추가 (getTrashCount 다음):
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 } 형태면:
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 가 없으면 위 파일을 새로 생성. 기존 모킹 패턴이 다르면 (예: 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 확장:
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 / refreshMeta 의 Promise.all 에 inboxApi.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 });
}
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: 커밋
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: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) vsinbox:trashExpiredBatch(plan) 채널명 차이 — closure 단계 (T8) 에서 spec §3 갱신 1줄 동봉.- T6 의
nextKstMidnightMsimport 가 renderer 에서 막히면 inline fallback 명시 (Step 3 주석).