feat(expiry): #5 만료 추천 (v0.2.3 3/7) #15
1477
docs/superpowers/plans/2026-05-01-v023-expiry.md
Normal file
294
docs/superpowers/specs/2026-05-01-v023-expiry-design.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# v0.2.3 #5 만료 추천 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 cut 7항목 중 3번째 항목 (#5 만료 추천) 의 mini-brainstorm 결정 + design. roadmap §3 #5 의 In/Out 위에서 §8 의 미결정 3항목 + UI 위치/0건 처리 추가 결정.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #5 (In/Out), §8 (미결정 항목)
|
||||
- `docs/superpowers/specs/2026-05-01-v023-trash-design.md` (#4 trash, deleted_at 인프라)
|
||||
- `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` (due_date 컬럼 + AI 추출 흐름)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| Q | 결정 | 근거 |
|
||||
|---|------|------|
|
||||
| Q1 `due_date_edited_by_user` 필터 | **B 필터 없음** — AI 자동 + 사용자 수동 모두 후보 | 의도와 무관하게 "지나간 due_date" 는 트리아지 대상. AI 자동 가중치 차등은 v0.2.4 로. |
|
||||
| Q2 만료 임박 (D-7) | **A 만료만** (`due_date < today`) | roadmap §3 #5 Out 명시. 임박은 의미 (주의 환기 vs trash) 가 달라 분리 surface 필요. v0.2.4. |
|
||||
| Q3 멀티선택 default | **C unchecked default + "전체 선택" 토글 버튼** | 데이터 안전 우선 (v0.2.1 패턴). 일괄도 토글 한 번. |
|
||||
| Q4 배너 위치 | **B PendingBanner 아래** | system(Ollama) → progress(Pending) → actionable(Expired) → filter(tagFilter) 순. |
|
||||
| Q5 후보 0건 / snooze | **A collapse** (렌더링 생략) | PendingBanner `pendingCount===0` → null 패턴 일치. 빈 카피는 노이즈. |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 / 쿼리
|
||||
|
||||
### 2.1 NoteRepository 확장
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
|
||||
findExpiredCandidates({ today }: { today: string }): Note[];
|
||||
trashBatch(ids: string[], deletedAt: string): { trashedCount: number };
|
||||
```
|
||||
|
||||
- `today`: `'YYYY-MM-DD'` 문자열 (KST 자정 기준 오늘 날짜). caller 가 KST 기준 계산 후 주입 (테스트에서 clock injection 용이).
|
||||
- `due_date` 도 `'YYYY-MM-DD'` 저장 (slice §F1 invariant 일치).
|
||||
- `findExpiredCandidates` SQL:
|
||||
```sql
|
||||
SELECT <note columns + JOIN tags + media> FROM notes
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < ?
|
||||
AND deleted_at IS NULL
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC
|
||||
```
|
||||
- `trashBatch` 는 단일 `db.transaction()` 안에서 `repo.trash(id, deletedAt)` 반복. 이미 trash 된 id 는 silent skip (UPDATE 가 deleted_at 이 이미 set 인 row 에 영향 0건). 반환 `trashedCount` 는 실제 transition (active → trash) 발생 건수. pending_jobs 정리는 `trash()` 가 이미 처리.
|
||||
|
||||
### 2.2 KST 자정 today 계산
|
||||
|
||||
```ts
|
||||
// src/main/util/kstDate.ts (재사용 또는 신설)
|
||||
export function todayInKst(now: Date): string {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
}
|
||||
```
|
||||
|
||||
- ContinuityService 의 KST_OFFSET_MS 패턴 재사용. 신규 util 또는 ContinuityService 의 helper 추출.
|
||||
- 단위 테스트: UTC 23:30 (KST 다음날 08:30) 케이스 검증.
|
||||
|
||||
---
|
||||
|
||||
## 3. IPC
|
||||
|
||||
| 채널 | 입력 | 출력 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. |
|
||||
| `inbox:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. |
|
||||
|
||||
CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산.
|
||||
|
||||
---
|
||||
|
||||
## 4. 상태 관리 (zustand)
|
||||
|
||||
```ts
|
||||
// src/renderer/inbox/store.ts
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null; // KST 자정 epoch ms
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
```
|
||||
|
||||
### 4.1 동작 사양
|
||||
|
||||
- `loadExpired()`: IPC `inbox:listExpired` 호출 → `expiredCandidates` 갱신.
|
||||
- `loadInitial()` + `refreshMeta()` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류.
|
||||
- `trashExpiredBatch(ids)`: IPC `inbox:trashBatch` 호출 → 성공 시 `expiredCandidates` 에서 ids 제거 + `trashCount` 증가 + `notes` 에서도 제거 (낙관적 갱신, restore 와 동일 패턴 — main 은 push 안 함).
|
||||
- `snoozeExpired()`: KST 자정 epoch ms 계산해 `expiredSnoozeUntilMs` 에 set. 컴포넌트에서 `Date.now() < snoozeUntil` 체크.
|
||||
- in-memory only. 앱 재시작 시 다시 노출 (roadmap §3 #5 In 의 명시 사양).
|
||||
|
||||
### 4.2 KST 자정 epoch 계산
|
||||
|
||||
```ts
|
||||
function nextKstMidnightMs(now: number): number {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnight = Math.ceil(kstNow / 86_400_000) * 86_400_000;
|
||||
return kstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
```
|
||||
|
||||
단위 테스트: KST 23:00 호출 시 다음날 00:00 KST 반환 (1시간 후), KST 00:01 호출 시 같은 날 자정 24시간 후 (23h59m 후).
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — `ExpiryBanner`
|
||||
|
||||
위치: `<App.tsx>` 의 `<PendingBanner />` 아래 (showTrash=false 분기 안).
|
||||
|
||||
### 5.1 collapse 조건 (렌더 null)
|
||||
|
||||
```
|
||||
expiredCandidates.length === 0
|
||||
|| (expiredSnoozeUntilMs !== null && Date.now() < expiredSnoozeUntilMs)
|
||||
```
|
||||
|
||||
### 5.2 unfolded 구조
|
||||
|
||||
```
|
||||
┌─ ⏰ 오늘 기준 만료 N개 [▼ 펼침/▲ 접힘] [오늘 그만] ─┐
|
||||
│ │
|
||||
│ ☐ [전체 선택] │
|
||||
│ ☐ 노트 제목 1 · due 2026-04-20 · #회의 │
|
||||
│ ☐ 노트 제목 2 · due 2026-04-18 · #학습 │
|
||||
│ ... │
|
||||
│ │
|
||||
│ [선택 휴지통 (M개)] (M=0 시 disabled) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**헤더 (1줄)**
|
||||
- "⏰ 오늘 기준 만료 {N}개"
|
||||
- 펼침 토글 버튼 (▼/▲)
|
||||
- "오늘 그만" 버튼 — 클릭 시 `snoozeExpired()` → 배너 즉시 collapse
|
||||
|
||||
**펼침 영역**
|
||||
- 첫 노출 시 default = **펼침** (사용자가 만료 N건 + 어떤 노트인지 동시 노출).
|
||||
- 한 번 접으면 component-local useState 로 세션 동안 접힘 유지. reload 시 다시 펼침.
|
||||
- "전체 선택" 체크박스 — 모든 row 동시 toggle. partial 선택 시 indeterminate 상태 표시.
|
||||
- 노트 row: 체크박스 + 제목(truncate, max 1 line) + due_date + 태그 chip (1개, 없으면 생략).
|
||||
- row 전체 clickable — 클릭 시 체크박스 toggle (편집/펼침 액션 없음, read-only triage 모드).
|
||||
- "선택 휴지통 ({M}개)": M=0 시 disabled. 클릭 시 native confirm dialog ("선택한 {M}개를 휴지통으로 옮깁니다.\n\n복구는 휴지통 탭에서 가능합니다.") → 확인 시 `trashExpiredBatch(selectedIds)`.
|
||||
|
||||
### 5.3 confirm dialog
|
||||
|
||||
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.
|
||||
|
||||
---
|
||||
|
||||
## 6. Telemetry
|
||||
|
||||
### 6.1 신규 events
|
||||
|
||||
| event | payload | 발화 |
|
||||
|-------|---------|------|
|
||||
| `expired_banner_shown` | `{ candidateCount: number }` | `loadExpired()` 결과 `candidates.length > 0` 시. 같은 세션에 동일 후보 set 중복 emit 회피 (last shown signature 비교). |
|
||||
| `expired_batch_trash` | `{ count: number }` | `trashBatch` 성공 직후 (count = trashedCount). |
|
||||
|
||||
### 6.2 중복 emit 회피 — signature
|
||||
|
||||
`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합).
|
||||
|
||||
### 6.3 zod 스키마
|
||||
|
||||
```ts
|
||||
// src/main/services/TelemetryService.ts (TelemetryEvent discriminatedUnion 확장)
|
||||
z.object({
|
||||
kind: z.literal('expired_banner_shown'),
|
||||
payload: z.object({ candidateCount: z.number().int().nonnegative() }).strict()
|
||||
}).strict(),
|
||||
z.object({
|
||||
kind: z.literal('expired_batch_trash'),
|
||||
payload: z.object({ count: z.number().int().nonnegative() }).strict()
|
||||
}).strict(),
|
||||
```
|
||||
|
||||
### 6.4 stats.md 집계 추가
|
||||
|
||||
| 행 | 산식 |
|
||||
|----|------|
|
||||
| 만료 배너 노출 | `expired_banner_shown` count |
|
||||
| 만료 일괄 trash | `expired_batch_trash` total `count` 합 |
|
||||
| 만료 trash ratio | `sum(expired_batch_trash.count) / sum(expired_banner_shown.candidateCount)` |
|
||||
|
||||
---
|
||||
|
||||
## 7. F5 export / F6 import / 백업
|
||||
|
||||
영향 0건. #4 가 이미 `deleted_at IS NULL` 을 export/active query 에 적용. 만료 후보는 active 노트의 부분집합이므로 별도 정책 불필요.
|
||||
|
||||
**Regression guard**: 단위 테스트로 "사용자 수동 due_date 도 만료 후보" + "trash 된 만료 노트는 후보 제외" 회귀 가드 추가.
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
| 영역 | 단위 | 검증 |
|
||||
|------|------|------|
|
||||
| Repo | `findExpiredCandidates` happy path | due_date < today 만 반환, ORDER BY created_at DESC |
|
||||
| Repo | `findExpiredCandidates` AI + 수동 mix | Q1=B 회귀 가드 — 둘 다 포함 |
|
||||
| Repo | `findExpiredCandidates` deleted_at | trash 노트 제외 (#4 invariant 회귀 가드) |
|
||||
| Repo | `findExpiredCandidates` ai_status | pending/failed 제외 |
|
||||
| Repo | `findExpiredCandidates` due_date NULL | NULL 노트 제외 (NULL < string 평가 가드) |
|
||||
| Repo | `trashBatch` atomic happy | N개 모두 trash, count=N |
|
||||
| Repo | `trashBatch` 빈 배열 | count=0, no-op |
|
||||
| Repo | `trashBatch` 일부 invalid id | valid 만 trash, count = valid 수 |
|
||||
| Repo | `trashBatch` 이미 trash | 재호출 시 count=0 (idempotent) |
|
||||
| util | `todayInKst` UTC vs KST 경계 | 23:30 UTC → 다음날 KST 날짜 |
|
||||
| Service | `nextKstMidnightMs` | 자정 KST 정확 계산 |
|
||||
| Telemetry | zod parse `expired_banner_shown` | candidateCount int ≥ 0 |
|
||||
| Telemetry | zod parse `expired_batch_trash` | count int ≥ 0 |
|
||||
| Telemetry | privacy invariant | payload 에 raw_text/title 포함 시 거부 (기존 invariant 회귀 가드) |
|
||||
| Store | `loadExpired` integration | candidates set + count |
|
||||
| Store | `trashExpiredBatch` 낙관적 갱신 | candidates 제거 + trashCount 증가 + notes 제거 |
|
||||
| Store | `snoozeExpired` | snoozeUntilMs = 다음 KST 자정 epoch |
|
||||
|
||||
총 단위 ≥ 16개. e2e smoke 영향 없음 (만료 노트 fixture 추가 없이 기존 1/1 e2e 보존).
|
||||
|
||||
---
|
||||
|
||||
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
|
||||
|
||||
T1. `findExpiredCandidates` repo + 단위 5개 (TDD)
|
||||
T2. `trashBatch` repo + 단위 4개 (TDD)
|
||||
T3. `todayInKst` util + `nextKstMidnightMs` 계산 + 단위 2개
|
||||
T4. Telemetry 2 events 추가 (zod + stats.md 집계 + 단위 3개)
|
||||
T5. CaptureService 메소드 + IPC 2 채널 + 단위
|
||||
T6. zustand store 확장 + 단위 3개
|
||||
T7. `ExpiryBanner` 컴포넌트 (펼침/접힘/체크박스/전체선택/오늘그만)
|
||||
T8. App.tsx 통합 (PendingBanner 아래 mount)
|
||||
T9. confirm dialog + trashBatch 호출 path 통합
|
||||
T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure
|
||||
|
||||
---
|
||||
|
||||
## 10. roadmap In/Out 일치
|
||||
|
||||
### 10.1 roadmap §3 #5 In 처리 매트릭스
|
||||
|
||||
| roadmap 항목 | 본 design |
|
||||
|-------------|----------|
|
||||
| `findExpiredCandidates({today})` | §2.1 ✓ |
|
||||
| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | §5 ✓ |
|
||||
| IPC `inbox:listExpired`, `inbox:trashBatch` | §3 ✓ |
|
||||
| Telemetry `expired_banner_shown` `{candidateCount}` | §6.1 ✓ |
|
||||
| Telemetry `expired_batch_trash` `{count}` | §6.1 ✓ |
|
||||
| 단위 테스트 | §8 ✓ (16개) |
|
||||
|
||||
### 10.2 roadmap §3 #5 Out 유지
|
||||
|
||||
- 시스템 알림 surface — Out
|
||||
- 별 페이지 — Out
|
||||
- snooze 영속화 — Out (in-memory + 자정 KST 리셋)
|
||||
- "안 옮김" 가중치 감소 — Out
|
||||
- 만료 임박 (D-7) 추천 — Out (Q2 confirmed)
|
||||
|
||||
---
|
||||
|
||||
## 11. 위험 / 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| `due_date IS NOT NULL` 누락 시 NULL < string 평가 (SQLite 의 NULL 비교 결과 NULL → falsy) | 명시적 `WHERE due_date IS NOT NULL` + 단위 테스트 회귀 가드 |
|
||||
| 사용자가 "오늘 그만" 후 다른 만료 노트 추가 시 배너 안 뜸 (자정까지) | 의도된 동작. 자정 KST 리셋 시 다시 노출. roadmap §3 #5 In 명시. |
|
||||
| 같은 세션에 candidates 가 자주 바뀌면 (capture 등) `expired_banner_shown` 이 과다 emit | signature 비교 (§6.2) 로 회피 |
|
||||
| `trashBatch` 의 `today` 가 caller 마다 다른 시점이면 race | main 단일 진입점 (CaptureService) 에서 호출 시점 1회 계산. renderer 가 today 주입 안 함. |
|
||||
| ExpiryBanner 가 PendingBanner 사이에 끼어 layout shift | 양쪽 다 collapse 조건 명확 (count=0 → null) — shift 는 사용자 액션 결과 (예측 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 게이트 (PR 머지 조건, roadmap §3.1 일치)
|
||||
|
||||
- `npm run typecheck` 0 에러
|
||||
- `npm test` — 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상)
|
||||
- `npm run test:e2e` 1/1 통과
|
||||
- main 머지
|
||||
|
||||
머지 후:
|
||||
- roadmap `§3 #5 만료 추천 (3번)` 다음 `✓ 완료` 마커
|
||||
- `memory/project_v024_backlog.md` 에 deferred 항목 기록 (review 결과)
|
||||
|
||||
---
|
||||
|
||||
## 13. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). |
|
||||
| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. |
|
||||
@@ -107,7 +107,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]─────
|
||||
|
||||
**Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그
|
||||
|
||||
### #5 만료 추천 (3번)
|
||||
### #5 만료 추천 (3번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `NoteRepository.findExpiredCandidates({today})`:
|
||||
|
||||
@@ -102,6 +102,31 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
|
||||
|
||||
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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import { todayInKstString } from '../util/kstDate.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
|
||||
@@ -240,6 +241,32 @@ export class NoteRepository {
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
restore(id: string): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
@@ -405,6 +432,28 @@ export class NoteRepository {
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
claude-reviewer-01
commented
[nit] [nit] `findExpiredCandidates` 가 `as any[]` 사용 — 기존 repository 의 `hydrate` 패턴과 일관. 신규 leak 아님. 전반 cleanup 은 v0.2.4 backlog #4~#6 영향과 합산해 검토.
|
||||
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));
|
||||
}
|
||||
|
||||
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
@@ -8,6 +9,8 @@ export interface TelemetryEmitter {
|
||||
| { 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>;
|
||||
}
|
||||
|
||||
@@ -23,6 +26,8 @@ export interface SubmitInput {
|
||||
}
|
||||
|
||||
export class CaptureService {
|
||||
private lastExpiredShownSig: string | null = null;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
private store: MediaStore,
|
||||
@@ -111,4 +116,47 @@ export class CaptureService {
|
||||
}
|
||||
return { count: noteIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 후보 (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) {
|
||||
// empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit.
|
||||
// (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식)
|
||||
this.lastExpiredShownSig = null;
|
||||
return candidates;
|
||||
|
claude-reviewer-01
commented
[nit] dedup signature 가 candidates 가 일시 empty (모두 trash) → 다시 동일 set 차오름 시 emit 됨 ( [nit] dedup signature 가 candidates 가 일시 empty (모두 trash) → 다시 동일 set 차오름 시 emit 됨 (`lastExpiredShownSig=null` reset 때문). 이는 spec §6.2 의 의도 (`empty 후 다시 차오르면 다음 호출에 emit`) 와 일치하므로 의도된 동작. nit: 주석에 명시되어 있으면 future maintainer 에게 친절.
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ export type EmitInput =
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { kind: 'empty_trash'; payload: { count: number } };
|
||||
| { kind: 'empty_trash'; payload: { count: number } }
|
||||
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } };
|
||||
|
||||
export class TelemetryService {
|
||||
constructor(
|
||||
|
||||
@@ -28,6 +28,14 @@ const EmptyTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const ExpiredBannerShownPayload = z.object({
|
||||
candidateCount: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const ExpiredBatchTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
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(),
|
||||
@@ -35,7 +43,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
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('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()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DailyRow {
|
||||
restore: number;
|
||||
permanent_delete: number;
|
||||
empty_trash: number;
|
||||
expired_banner_shown: number;
|
||||
expired_batch_trash: number;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
@@ -34,11 +36,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
let durationN = 0;
|
||||
let trashCount = 0;
|
||||
let restoreCount = 0;
|
||||
let expiredBannerShownCandidatesSum = 0;
|
||||
let expiredBatchTrashCountSum = 0;
|
||||
for (const ev of events) {
|
||||
const day = kstDate(ev.ts);
|
||||
let row = byDay.get(day);
|
||||
if (!row) {
|
||||
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 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
|
||||
};
|
||||
byDay.set(day, row);
|
||||
}
|
||||
if (ev.kind === 'capture') row.capture += 1;
|
||||
@@ -60,6 +69,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
row.permanent_delete += 1;
|
||||
} else if (ev.kind === 'empty_trash') {
|
||||
row.empty_trash += 1;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
@@ -69,6 +84,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
const trashRecoveryRate = trashCount === 0
|
||||
? 'N/A'
|
||||
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
|
||||
const expiredTrashRatio = expiredBannerShownCandidatesSum === 0
|
||||
? 'N/A'
|
||||
: `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`;
|
||||
const lines: string[] = [];
|
||||
lines.push('# Inkling Telemetry Stats');
|
||||
lines.push('');
|
||||
@@ -77,10 +95,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push('');
|
||||
lines.push('## 일자별 카운트');
|
||||
lines.push('');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|');
|
||||
for (const row of days) {
|
||||
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} |`);
|
||||
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} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 핵심 ratio');
|
||||
@@ -88,6 +106,7 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push(`- AI 성공률: ${successRate}`);
|
||||
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
|
||||
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
|
||||
lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`);
|
||||
lines.push('');
|
||||
return { md: lines.join('\n'), eventCount };
|
||||
}
|
||||
|
||||
28
src/main/util/kstDate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ const api: InklingApi = {
|
||||
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
|
||||
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
|
||||
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
|
||||
listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
|
||||
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
|
||||
onNoteUpdated: (cb) => {
|
||||
const listener = (_e: unknown, note: Note) => cb(note);
|
||||
ipcRenderer.on('note:updated', listener);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PendingBanner } from './components/PendingBanner.js';
|
||||
import { OllamaBanner } from './components/OllamaBanner.js';
|
||||
import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -77,6 +78,7 @@ export function App(): React.ReactElement {
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
<ExpiryBanner />
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
|
||||
157
src/renderer/inbox/components/ExpiryBanner.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
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);
|
||||
// n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거.
|
||||
// 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
|
claude-reviewer-01
commented
[nit] snooze 만료 (자정 KST) 가 앱이 24h+ 켜진 상태에서 즉시 반영되지 않음 — [nit] snooze 만료 (자정 KST) 가 앱이 24h+ 켜진 상태에서 즉시 반영되지 않음 — `Date.now() < snoozeUntilMs` 가 render 시점에만 평가되며 `setInterval` 강제 re-render 없음. `refreshMeta` 가 다른 trigger 로 fire 되면 자연 갱신되는 구조. 매우 드문 edge case (사용자 앱 24h+ open + snooze 활성 + 자정 직후) 이므로 v0.2.4 에 메모만 충분.
|
||||
if (snoozeUntilMs === null) return;
|
||||
const remaining = snoozeUntilMs - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
|
claude-reviewer-01
commented
[minor] [minor] `onTrash={(ids) => void trashExpiredBatch(ids)}` 은 Promise rejection 을 silent swallow. 현재는 IPC 측이 dialog 취소 시 정상 return 이므로 reject 경로가 없지만, 향후 IPC 가 throw 하면 사용자 피드백 0. v0.2.4 에서 inbox 전반 error toast 도입 시 함께 손볼 후보 (#4 의 다른 actions 도 동일 패턴이라 단독 fix 는 무의미).
|
||||
const t = setTimeout(() => setTick((n) => n + 1), remaining);
|
||||
return () => clearTimeout(t);
|
||||
}, [snoozeUntilMs]);
|
||||
|
||||
// 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) => {
|
||||
trashExpiredBatch(ids).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('trashExpiredBatch failed', e);
|
||||
|
claude-reviewer-01
commented
[minor] [minor] `InnerProps.candidates` 가 `Note` 의 narrow subset (id/aiTitle/rawText/dueDate/tags) 만 받음. 구조적 호환은 OK 이지만 `Note` 타입이 v0.2.4 에서 진화하면 silent drift 가능. `import type { Note } from '@shared/types'` 후 `candidates: Note[]` 로 통일하면 store→component 흐름 한 타입 유지.
|
||||
});
|
||||
}}
|
||||
onSnooze={() => snoozeExpired()}
|
||||
/>;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
candidates: Note[];
|
||||
onTrash: (ids: string[]) => void;
|
||||
onSnooze: () => void;
|
||||
}
|
||||
|
||||
function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ interface InboxState {
|
||||
todayCount: number;
|
||||
loading: boolean;
|
||||
tagFilter: string | null;
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -25,6 +27,9 @@ interface InboxState {
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -43,27 +48,31 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
todayCount: 0,
|
||||
loading: false,
|
||||
tagFilter: null,
|
||||
expiredCandidates: [],
|
||||
expiredSnoozeUntilMs: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
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.getTrashCount(),
|
||||
inboxApi.listExpired()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount()
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates });
|
||||
},
|
||||
upsertNote(note) {
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
@@ -136,5 +145,27 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (r.confirmed) {
|
||||
set({ trashNotes: [], trashCount: 0 });
|
||||
}
|
||||
},
|
||||
async loadExpired() {
|
||||
const expiredCandidates = await inboxApi.listExpired();
|
||||
set({ expiredCandidates });
|
||||
},
|
||||
async trashExpiredBatch(ids: string[]) {
|
||||
const r = await inboxApi.trashExpiredBatch(ids);
|
||||
if (!r.confirmed) return;
|
||||
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 });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface InboxApi {
|
||||
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
|
||||
listTrash(opts: { limit: number }): Promise<Note[]>;
|
||||
getTrashCount(): Promise<number>;
|
||||
listExpired(): Promise<Note[]>;
|
||||
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
|
||||
onNoteUpdated(cb: (note: Note) => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,3 +208,118 @@ describe('CaptureService trash flow (v0.2.3 #4)', () => {
|
||||
expect(r.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.listExpired (dedup signature)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let calls: Array<{ kind: string; payload: any }>;
|
||||
let svc: CaptureService;
|
||||
|
||||
function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
||||
VALUES (?, ?, 'done', ?, ?, ?)`
|
||||
).run(id, id, dueDate, createdAt, createdAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
||||
store = new MediaStore(tmp);
|
||||
calls = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('emits expired_banner_shown on first call when candidates > 0', async () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r).toHaveLength(2);
|
||||
expect(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 () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
const showns = 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 () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
const showns = 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 r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r).toEqual([]);
|
||||
expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.trashExpiredBatch', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let calls: Array<{ kind: string; payload: any }>;
|
||||
let svc: CaptureService;
|
||||
|
||||
function addExpired(id: string, dueDate: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
||||
VALUES (?, ?, 'done', ?, ?, ?)`
|
||||
).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
||||
store = new MediaStore(tmp);
|
||||
calls = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => {
|
||||
addExpired('n1', '2026-04-20');
|
||||
addExpired('n2', '2026-04-22');
|
||||
const r = await svc.trashExpiredBatch(['n1', 'n2']);
|
||||
expect(r.trashedCount).toBe(2);
|
||||
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([
|
||||
expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } })
|
||||
]);
|
||||
expect(calls.filter((c) => c.kind === 'trash')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns trashedCount=0 for empty array (no emit)', async () => {
|
||||
const r = await svc.trashExpiredBatch([]);
|
||||
expect(r.trashedCount).toBe(0);
|
||||
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -448,3 +448,129 @@ describe('Active queries exclude deleted notes', () => {
|
||||
expect(repo.getPendingCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.findExpiredCandidates', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
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' });
|
||||
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'));
|
||||
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' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([past]);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
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);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,8 +146,12 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — empty_trash 같은 noteId 없는 kind 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) => e.kind === 'empty_trash' ? null : e.payload.noteId)).toEqual(['a', 'b', 'b']);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
});
|
||||
|
||||
it('skips malformed lines (silent — invariant)', async () => {
|
||||
@@ -160,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash') expect(ev.payload.noteId).toBe('a');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
|
||||
37
tests/unit/kstDate.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
101
tests/unit/store.expired.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
listExpired: vi.fn(async () => [] as Note[]),
|
||||
trashExpiredBatch: vi.fn(async (_ids: string[]) => ({ trashedCount: 0, confirmed: false }))
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
const noteStub = (id: string): Note => ({
|
||||
id, rawText: 'x',
|
||||
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: '2026-04-20', dueDateEditedByUser: false,
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
describe('useInbox — expired state (v0.2.3 #5)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('loadExpired sets expiredCandidates from inboxApi', async () => {
|
||||
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().loadExpired();
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(1);
|
||||
expect(s.expiredCandidates[0]!.id).toBe('n1');
|
||||
});
|
||||
|
||||
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
notes: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
trashCount: 5
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it('trashExpiredBatch does NOT mutate state when not confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 0, confirmed: false });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2')],
|
||||
notes: [noteStub('n1'), noteStub('n2')],
|
||||
trashCount: 5
|
||||
});
|
||||
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', async () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → next KST midnight = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC
|
||||
const fixedNow = Date.parse('2026-05-01T12:00:00Z');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().snoozeExpired();
|
||||
expect(useInbox.getState().expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z'));
|
||||
});
|
||||
});
|
||||
@@ -149,3 +149,49 @@ describe('validateEvent — trash family (v0.2.3 #4)', () => {
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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' }
|
||||
|
claude-reviewer-01
commented
[minor] [minor] `expired_banner_shown` 의 extra-field privacy invariant test 만 존재. `expired_batch_trash` 도 `.strict()` 이지만 대칭 회귀 가드 없음. 한 줄 추가 권장 — `payload: { count: 3, rawText: 'leak' }` 가 throw 하는지 확인.
|
||||
})).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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,3 +101,26 @@ describe('aggregateStats — trash family (v0.2.3 #4)', () => {
|
||||
expect(r.md).toContain('휴지통 회수율: N/A');
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
expect(r.md).toContain('expired_banner_shown');
|
||||
expect(r.md).toContain('expired_batch_trash');
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
|
||||
[minor] spec §5.3 은
buttons=['취소','옮기기'], default=0 (취소)를 명시했지만 구현은['옮기기','취소'], defaultId=1, cancelId=1(project 의 permanentDelete/emptyTrash 패턴과 일치). 결과적으로 default focus + Esc behavior 는 동일 (둘 다 취소) 이라 기능 동등하지만, visual button 순서가 spec 과 다름. 의도된 deviation 으로 보이므로 spec §5.3 의 1줄을 project 패턴으로 갱신하거나 PR description 에 deviation 명시 권장.