fix(expiry): review round 1 — minor/nit 6건 일괄 (#5 v0.2.3)

m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소).

m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant).

m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지.

m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지)

n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거)

n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해.

n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 00:47:58 +09:00
parent 8a96d5279d
commit d672ec3afa
4 changed files with 29 additions and 9 deletions

View File

@@ -148,7 +148,7 @@ expiredCandidates.length === 0
### 5.3 confirm dialog
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', buttons=['취소','옮기기'], default=0 (취소).
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.
---

View File

@@ -125,6 +125,8 @@ export class CaptureService {
async listExpired(now: Date = new Date()): Promise<Note[]> {
const candidates = this.repo.findExpiredCandidates(now);
if (candidates.length === 0) {
// empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit.
// (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식)
this.lastExpiredShownSig = null;
return candidates;
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import type { Note } from '@shared/types';
import { useInbox } from '../store.js';
export function ExpiryBanner(): React.ReactElement | null {
@@ -6,6 +7,16 @@ export function ExpiryBanner(): React.ReactElement | null {
const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs);
const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch);
const snoozeExpired = useInbox((s) => s.snoozeExpired);
// n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거.
// 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장.
const [, setTick] = useState(0);
useEffect(() => {
if (snoozeUntilMs === null) return;
const remaining = snoozeUntilMs - Date.now();
if (remaining <= 0) return;
const t = setTimeout(() => setTick((n) => n + 1), remaining);
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// Q5=A: 0건 / snooze 활성 시 collapse
if (candidates.length === 0) return null;
@@ -13,19 +24,18 @@ export function ExpiryBanner(): React.ReactElement | null {
return <ExpiryBannerInner
candidates={candidates}
onTrash={(ids) => void trashExpiredBatch(ids)}
onTrash={(ids) => {
trashExpiredBatch(ids).catch((e) => {
// eslint-disable-next-line no-console
console.warn('trashExpiredBatch failed', e);
});
}}
onSnooze={() => snoozeExpired()}
/>;
}
interface InnerProps {
candidates: Array<{
id: string;
aiTitle: string | null;
rawText: string;
dueDate: string | null;
tags: Array<{ name: string }>
}>;
candidates: Note[];
onTrash: (ids: string[]) => void;
onSnooze: () => void;
}

View File

@@ -186,4 +186,12 @@ describe('expired_banner_shown / expired_batch_trash events', () => {
payload: { count: -1 }
})).toThrow();
});
it('rejects expired_batch_trash with extra payload field (privacy invariant)', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'expired_batch_trash',
payload: { count: 3, rawText: 'leak' }
})).toThrow();
});
});