Merge pull request 'feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7 final)' (#19) from feat/v023-recall-spike into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
1343
docs/superpowers/plans/2026-05-02-v023-recall-spike.md
Normal file
1343
docs/superpowers/plans/2026-05-02-v023-recall-spike.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,7 +169,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]─────
|
||||
|
||||
**Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책
|
||||
|
||||
### #6 리마인드 1 spike (7번)
|
||||
### #6 리마인드 1 spike (7번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시
|
||||
|
||||
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# v0.2.3 #6 리마인드 1 spike — Design Spec
|
||||
|
||||
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #6 (7번째 / 마지막 cut)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (`RecallBanner`) 도입. 7일 이상 안 본 노트 중 가장 오래된 1건을 제시하여 사용자가 자기 기록을 재방문할 기회 제공. 4종 telemetry (`recall_shown` / `recall_opened` / `recall_dismissed` / `recall_snoozed`) 로 효과 측정 인프라 마련.
|
||||
|
||||
## 2. Decisions (mini-brainstorm 합의)
|
||||
|
||||
| # | 질문 | 선택 | 이유 |
|
||||
|---|---|---|---|
|
||||
| Q1 | 다음에 snooze 영속화 | **A** in-memory | `expiredSnoozeUntilMs` 패턴 일관, schema migration v4 회피, dogfood telemetry 보고 v0.2.4 영속화 결정 |
|
||||
| Q2 | `ageDays` 의미 | **B** `last_recalled_at ?? created_at` 기준 | algo 의 "7일 안 본 노트" trigger 와 동일 axis, 재추천 분포 측정 가치 |
|
||||
|
||||
자명 결정 (질문 없이 패턴 따름):
|
||||
- Banner 위치: `ExpiryBanner` 다음 (stack 끝, 시간 민감도 가장 낮음)
|
||||
- 0건 시: `null` return (`ExpiryBanner` 패턴)
|
||||
- Snooze duration: KST 다음 자정 (`snoozeExpired` 패턴)
|
||||
- "열어보기" 동작: `scrollIntoView` (NoteCard 항상 expanded — expand 동작 X)
|
||||
|
||||
## 3. Architecture & data flow
|
||||
|
||||
```
|
||||
Inbox 마운트 시:
|
||||
loadInitial() → recallCandidate fetch (별도 fetch, 단일 노트 또는 null)
|
||||
|
||||
RecallBanner render (recallCandidate !== null && !snoozed):
|
||||
┌─ "오늘 회상해볼 노트" + 노트 제목 + (N일 전)
|
||||
├─ [열어보기] → scrollIntoView(noteCardRef) + markRecallOpened(id)
|
||||
│ → telemetry: recall_opened
|
||||
├─ [다음에] → store.snoozeRecall() (KST 다음 자정까지 in-memory)
|
||||
│ → telemetry: recall_snoozed
|
||||
└─ [더 이상] → dismissRecall(id) (DB: recall_dismissed_at = now)
|
||||
→ telemetry: recall_dismissed
|
||||
|
||||
Banner 첫 렌더 시 자동 emit: recall_shown { noteId, ageDays }
|
||||
|
||||
다음 fetch 트리거:
|
||||
- markRecallOpened / dismissRecall 후 store 가 자동 다음 후보 fetch
|
||||
- refreshMeta (focus / inbox:noteUpdated) 도 fetch
|
||||
```
|
||||
|
||||
### 3.1 Invariants
|
||||
|
||||
1. **단일 후보 fetch** — `LIMIT 1` + `ORDER BY created_at ASC` (가장 오래된 1건)
|
||||
2. **KST 보정** — SQL 의 `date('now')` 자리 모두 `date('now','+9 hours')`
|
||||
3. **마감 임박 노트 제외** — `due_date < today` 인 노트는 ExpiryBanner 영역 (#5) 이라 회상 후보에서 빠짐
|
||||
4. **Snooze in-memory** — `recallSnoozeUntilMs` store 변수, KST 다음 자정 (ExpiryBanner 패턴)
|
||||
5. **emit 순서** — `recall_shown` (banner 첫 렌더) → `recall_opened/dismissed/snoozed` (사용자 액션)
|
||||
6. **Snooze 시 `recall_shown` 1회만** — 같은 후보가 다시 보여도 `recall_shown` 재emit 안 함 (notes 1건당 session 1 shown — `recallShownIds: Set<string>` in-memory)
|
||||
|
||||
## 4. Components
|
||||
|
||||
### 4.1 `NoteRepository`
|
||||
|
||||
#### `findRecallCandidate(): Note | null`
|
||||
|
||||
```sql
|
||||
SELECT * FROM notes
|
||||
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
|
||||
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
|
||||
AND ai_status = 'done'
|
||||
AND deleted_at IS NULL
|
||||
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
기존 `hydrate(row)` 사용 (이미 `last_recalled_at` / `recall_dismissed_at` 매핑 있음).
|
||||
|
||||
#### `markRecallOpened(id: string, now: string): void`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
#### `dismissRecall(id: string, now: string): void`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
### 4.2 `CaptureService` (5 신규 메서드)
|
||||
|
||||
```typescript
|
||||
async listRecallCandidate(): Promise<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
}
|
||||
|
||||
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) throw new Error(`note not found: ${noteId}`);
|
||||
this.repo.markRecallOpened(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_opened',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
async dismissRecall(noteId: string): Promise<{ note: Note }> {
|
||||
this.repo.dismissRecall(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_dismissed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
async emitRecallShown(noteId: string): Promise<void> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) return;
|
||||
const ageDays = this.computeAgeDays(note);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId, ageDays }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emitRecallSnoozed(noteId: string): Promise<void> {
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_snoozed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private computeAgeDays(note: Note): number {
|
||||
const ref = note.lastRecalledAt ?? note.createdAt;
|
||||
const refMs = new Date(ref).getTime();
|
||||
const nowMs = Date.now();
|
||||
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 IPC (5 신규 channels)
|
||||
|
||||
```typescript
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
```
|
||||
|
||||
### 4.4 Preload + InboxApi shared type
|
||||
|
||||
```typescript
|
||||
// preload/index.ts
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||||
```
|
||||
|
||||
```typescript
|
||||
// shared/types.ts InboxApi
|
||||
listRecallCandidate(): Promise<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
```
|
||||
|
||||
### 4.5 `telemetryEvents.ts` zod
|
||||
|
||||
```typescript
|
||||
const RecallShownPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
ageDays: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
// recall_opened / recall_dismissed / recall_snoozed → 기존 NoteIdPayload 재사용
|
||||
```
|
||||
|
||||
union 15 → **19** (recall_shown + recall_opened + recall_dismissed + recall_snoozed).
|
||||
|
||||
### 4.6 `telemetryStats.ts`
|
||||
|
||||
- DailyRow +4 cols (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`)
|
||||
- accumulators: `recallShownCount`, `recallOpenedCount`, `recallDismissedCount`, `recallSnoozedCount`, `recallAgeDaysSum`
|
||||
- summary lines:
|
||||
```
|
||||
- 회상 추천: shown {N} / opened {O} / dismissed {D} / snoozed {S} (열림율 {O/N}%)
|
||||
- 회상 평균 ageDays: {avg}
|
||||
```
|
||||
N=0 시 `(데이터 없음)`
|
||||
|
||||
### 4.7 `TelemetryService.EmitInput` union 15 → 19
|
||||
|
||||
### 4.8 Renderer store (`src/renderer/inbox/store.ts`)
|
||||
|
||||
```typescript
|
||||
interface InboxState {
|
||||
// ... existing ...
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
recallShownIds: Set<string>; // session-local, "1 shown per note per session"
|
||||
loadRecallCandidate: () => Promise<void>;
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>; // store action 명, IPC 와 구별
|
||||
snoozeRecall: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`refreshMeta` + `loadInitial` 가 `loadRecallCandidate` 도 호출.
|
||||
|
||||
`openRecall(id)`:
|
||||
- `inboxApi.markRecallOpened(id)` → DB 갱신
|
||||
- `loadRecallCandidate()` → 다음 후보 fetch
|
||||
- (스크롤은 RecallBanner 컴포넌트가 자체 처리)
|
||||
|
||||
`dismissRecallNote(id)`:
|
||||
- `inboxApi.dismissRecall(id)` → DB 갱신
|
||||
- `loadRecallCandidate()` → 다음 후보 fetch
|
||||
|
||||
`snoozeRecall()`:
|
||||
- `recallSnoozeUntilMs = nextKstMidnight()` (`snoozeExpired` 패턴)
|
||||
- 현재 candidate noteId 기준 `inboxApi.emitRecallSnoozed(id)`
|
||||
|
||||
### 4.9 RecallBanner 컴포넌트
|
||||
|
||||
**파일**: `src/renderer/inbox/components/RecallBanner.tsx` (신규)
|
||||
|
||||
- 위치: `<ExpiryBanner />` 다음 (App.tsx)
|
||||
- 첫 렌더 시 `useEffect` 가 `recallShownIds` 체크 후 미emit 시 `inboxApi.emitRecallShown(id)` 호출 + Set 에 추가
|
||||
- Banner UI: 노트 제목 + ageDays + 3개 버튼 (열어보기 / 다음에 / 더 이상)
|
||||
- `null` return: candidate=null OR snoozed (Date.now < snoozeUntilMs)
|
||||
- snoozeUntilMs 만료 시 setTimeout re-render 트리거 (ExpiryBanner 패턴)
|
||||
|
||||
### 4.10 NoteCard ref 시스템 (scroll target)
|
||||
|
||||
App.tsx 가 `noteRefs: Map<noteId, HTMLDivElement | null>` ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 호출.
|
||||
|
||||
구체 구현:
|
||||
- `App.tsx` 가 `useRef<Map<string, HTMLDivElement | null>>(new Map())` 보유
|
||||
- 각 `<NoteCard>` 에 `ref={(el) => { noteRefs.current.set(note.id, el); }}` 전달 (NoteCard 가 ref forwardRef 지원 필요)
|
||||
- RecallBanner 가 `noteRefs` prop 으로 받아 사용
|
||||
|
||||
**대안 (단순)**: `document.getElementById(\`note-${id}\`)` — App.tsx 의 NoteCard 가 `id={\`note-${note.id}\`}` 만 추가하면 됨. **이 spike 에선 이 단순 방식 채택** (ref 시스템 복잡도 회피).
|
||||
|
||||
## 5. Privacy invariant
|
||||
|
||||
- `recall_shown.payload`: `{ noteId, ageDays }` — noteId 기존 패턴, ageDays 정수
|
||||
- `recall_opened/dismissed/snoozed.payload`: `{ noteId }` — `NoteIdPayload` 재사용
|
||||
- `.strict()` zod 가드 + extra field 거부 테스트
|
||||
|
||||
## 6. Tests (≥17개)
|
||||
|
||||
### NoteRepository.test.ts (5)
|
||||
1. 빈 db → null
|
||||
2. last_recalled_at 5일 전 노트 제외 (7일 이내)
|
||||
3. last_recalled_at 8일 전 노트 후보 (7일 초과)
|
||||
4. recall_dismissed_at 25일 전 제외, 35일 전 후보
|
||||
5. deleted_at / ai_status='pending' / due_date < today 모두 제외
|
||||
|
||||
### CaptureService.test.ts (4)
|
||||
6. listRecallCandidate → repo.findRecallCandidate
|
||||
7. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
|
||||
8. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
|
||||
9. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)
|
||||
|
||||
### telemetryEvents.test.ts (3)
|
||||
10. recall_shown valid parse (noteId + ageDays)
|
||||
11. recall_shown extra field 거부 (privacy)
|
||||
12. recall_opened/dismissed/snoozed valid parse (noteId only)
|
||||
|
||||
### telemetryStats.test.ts (2)
|
||||
13. shown/opened/dismissed/snoozed 누적 + 열림율 계산
|
||||
14. 평균 ageDays 계산
|
||||
|
||||
### store.recall.test.ts (신규, 3)
|
||||
15. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
|
||||
16. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
|
||||
17. dismissRecallNote → 후보 다시 fetch
|
||||
|
||||
총 신규 단위 **17개**. 기존 단위 386 + 17 = **403** 예상.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
(roadmap §3 #6 + 본 cut 결정)
|
||||
|
||||
- 잠금해제 hook (F4-A, strategy.md)
|
||||
- 무작위 토스트 (F4-D)
|
||||
- ambient if-then (F4-B)
|
||||
- 임베딩 유사도 추천 (#3 vocab 후속)
|
||||
- spaced repetition (Leitner / SM-2)
|
||||
- 다중 후보 추천 (현재 `LIMIT 1` only)
|
||||
- snooze 영속화 (Q1=A in-memory)
|
||||
- 사용자 정의 회상 주기 (7일 hardcoded)
|
||||
- "회상 history" 보기 (last_recalled_at 만 저장, 이전 history X)
|
||||
- RecallBanner 컴포넌트 단위 테스트 (Inkling 패턴: store 단위만 테스트)
|
||||
|
||||
## 8. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 386 → 403 (+17), 모두 통과
|
||||
- e2e 1/1
|
||||
- 새 SQL: 복합 조건 — `idx_notes_ai_status` + `idx_notes_created_at` 활용. 별도 인덱스 불필요.
|
||||
|
||||
## 9. `strategy.md` 갱신 (별도 task)
|
||||
|
||||
roadmap §3 #6 In 절: §2.3 / §4.3 / §8 갱신:
|
||||
- Capitalize 본격 진입 (회상 surface 도입)
|
||||
- "오늘 회상" surface 정의
|
||||
- F4-A/B/D deferred 항목의 측정 인프라 마련 명시 (recall_* telemetry 가 그 기반)
|
||||
|
||||
## 10. Roadmap relation
|
||||
|
||||
- v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
|
||||
- 머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프
|
||||
- v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정
|
||||
@@ -53,6 +53,8 @@ AI가 제목, 요약, 태그, 프로젝트 후보를 생성합니다. 다만 사
|
||||
|
||||
하루 또는 주간 리뷰에서 AI가 메모를 업무 산출물로 바꿔줍니다.
|
||||
|
||||
오늘 회상 (RecallBanner, v0.2.3 #6): Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 가장 오래된 순으로 제시합니다. 사용자는 "열어보기"(노트 카드 스크롤 + last_recalled_at 갱신), "다음에"(KST 자정까지 in-memory snooze), "더 이상"(recall_dismissed_at 갱신, 30일 후 재추천) 중 선택합니다. 본 surface 가 Capitalize 단계의 첫 본격 진입점입니다.
|
||||
|
||||
예:
|
||||
|
||||
“이번 주 결정 근거”
|
||||
@@ -140,6 +142,8 @@ Confluence 공유 후보 추천
|
||||
|
||||
직장에서의 동기와 몰입은 의미 있는 일에서 진전이 보일 때 강해집니다. Amabile와 Kramer의 “Progress Principle”은 지식 근로자의 감정·동기·창의성에 작은 진전 경험이 중요하다는 점을 강조합니다. Inkling의 주간 리포트는 “기록 수”보다 업무 진전의 증거를 보여줘야 합니다.
|
||||
|
||||
측정 인프라 (v0.2.3 #6): recall_shown / recall_opened / recall_dismissed / recall_snoozed 4종 telemetry 가 본 cut 으로 자리잡았습니다. 향후 F4-A (잠금해제 hook), F4-B (ambient if-then), F4-D (무작위 토스트) 항목 진입 시 본 telemetry 가 효과 측정 기반으로 확장됩니다.
|
||||
|
||||
5. 스트릭은 처벌이 아니라 회복 친화적으로 설계한다
|
||||
|
||||
기획서에 스트릭과 뱃지가 포함되어 있는데, 이 장치는 조심해서 써야 합니다. 게임화 연구는 전반적으로 긍정적 효과를 보이지만, 효과 크기와 안정성은 맥락에 따라 다르고, 특히 동기·행동 효과는 고품질 연구만 보면 덜 안정적일 수 있습니다. 따라서 Inkling은 경쟁·압박형 게임화가 아니라 자기효능감 회복형 게임화가 맞습니다.
|
||||
@@ -280,6 +284,8 @@ AI 자동 정리는 Inkling의 핵심 강점입니다. 다만 사용자가 완
|
||||
|
||||
8. 관계성 보상: “내 메모가 동료의 시간을 아껴준다”
|
||||
|
||||
Inbox surface stack (v0.2.3 기준): Ollama 회복 → Pending 진행 → Failed 실패 → Expiry 마감 임박 → Recall 회상 추천. 시간 민감도 순으로 위에서 아래. RecallBanner 가 가장 가벼운 surface 로 stack 끝에 놓입니다.
|
||||
|
||||
기록 습관은 개인 생산성뿐 아니라 팀 학습과도 연결됩니다. Edmondson의 심리적 안전감 연구는 팀원이 대인관계 위험을 감수하고 질문·실수·학습 행동을 할 수 있는 분위기가 팀 학습과 관련된다는 점을 제시합니다. 업무 메모를 팀 지식으로 공유하게 만들려면 “감시받는다”가 아니라 동료를 돕는다는 감각이 필요합니다.
|
||||
|
||||
따라서 Confluence 내보내기 UX는 이렇게 설계합니다.
|
||||
|
||||
@@ -136,6 +136,12 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
|
||||
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
|
||||
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
|
||||
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -217,6 +217,43 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
|
||||
* - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
|
||||
* - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
|
||||
* - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
|
||||
* KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
|
||||
*/
|
||||
findRecallCandidate(): Note | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
|
||||
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
|
||||
AND ai_status = 'done'
|
||||
AND deleted_at IS NULL
|
||||
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined;
|
||||
return row ? this.hydrate(row) : null;
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
|
||||
markRecallOpened(id: string, now: string): void {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(now, now, id);
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
|
||||
dismissRecall(id: string, now: string): void {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(now, now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
|
||||
* source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface TelemetryEmitter {
|
||||
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } }
|
||||
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
|
||||
| { kind: 'recall_opened'; payload: { noteId: string } }
|
||||
| { kind: 'recall_dismissed'; payload: { noteId: string } }
|
||||
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
|
||||
| { kind: 'recall_snoozed'; payload: { noteId: string } }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -179,4 +183,65 @@ export class CaptureService {
|
||||
}
|
||||
return { count: ids.length };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
|
||||
async listRecallCandidate(): Promise<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
|
||||
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
|
||||
if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`);
|
||||
this.repo.markRecallOpened(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_opened',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */
|
||||
async dismissRecall(noteId: string): Promise<{ note: Note }> {
|
||||
this.repo.dismissRecall(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_dismissed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */
|
||||
async emitRecallShown(noteId: string): Promise<void> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) return;
|
||||
const ageDays = this.computeAgeDays(note);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId, ageDays }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */
|
||||
async emitRecallSnoozed(noteId: string): Promise<void> {
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_snoozed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */
|
||||
private computeAgeDays(note: Note): number {
|
||||
const ref = note.lastRecalledAt ?? note.createdAt;
|
||||
const refMs = new Date(ref).getTime();
|
||||
const nowMs = Date.now();
|
||||
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ export type EmitInput =
|
||||
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
|
||||
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } };
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
|
||||
| { kind: 'recall_opened'; payload: { noteId: string } }
|
||||
| { kind: 'recall_dismissed'; payload: { noteId: string } }
|
||||
| { kind: 'recall_snoozed'; payload: { noteId: string } };
|
||||
|
||||
export class TelemetryService {
|
||||
constructor(
|
||||
|
||||
@@ -59,6 +59,11 @@ const TagVocabMissPayload = z.object({
|
||||
vocabSize: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const RecallShownPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
ageDays: 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(),
|
||||
@@ -74,7 +79,11 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict()
|
||||
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -26,6 +26,10 @@ interface DailyRow {
|
||||
ai_retry_manual: number;
|
||||
tag_vocab_hit: number;
|
||||
tag_vocab_miss: number;
|
||||
recall_shown: number;
|
||||
recall_opened: number;
|
||||
recall_dismissed: number;
|
||||
recall_snoozed: number;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
@@ -51,6 +55,11 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
let aiRetryManualFailedSum = 0;
|
||||
let tagVocabHitCount = 0;
|
||||
let tagVocabMissCount = 0;
|
||||
let recallShownCount = 0;
|
||||
let recallOpenedCount = 0;
|
||||
let recallDismissedCount = 0;
|
||||
let recallSnoozedCount = 0;
|
||||
let recallAgeDaysSum = 0;
|
||||
for (const ev of events) {
|
||||
const day = kstDate(ev.ts);
|
||||
let row = byDay.get(day);
|
||||
@@ -62,7 +71,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
expired_banner_shown: 0, expired_batch_trash: 0,
|
||||
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
|
||||
ai_retry_manual: 0,
|
||||
tag_vocab_hit: 0, tag_vocab_miss: 0
|
||||
tag_vocab_hit: 0, tag_vocab_miss: 0,
|
||||
recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0
|
||||
};
|
||||
byDay.set(day, row);
|
||||
}
|
||||
@@ -110,6 +120,19 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'tag_vocab_miss') {
|
||||
row.tag_vocab_miss += 1;
|
||||
tagVocabMissCount += 1;
|
||||
} else if (ev.kind === 'recall_shown') {
|
||||
row.recall_shown += 1;
|
||||
recallShownCount += 1;
|
||||
recallAgeDaysSum += ev.payload.ageDays;
|
||||
} else if (ev.kind === 'recall_opened') {
|
||||
row.recall_opened += 1;
|
||||
recallOpenedCount += 1;
|
||||
} else if (ev.kind === 'recall_dismissed') {
|
||||
row.recall_dismissed += 1;
|
||||
recallDismissedCount += 1;
|
||||
} else if (ev.kind === 'recall_snoozed') {
|
||||
row.recall_snoozed += 1;
|
||||
recallSnoozedCount += 1;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
@@ -130,6 +153,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
const tagVocabSummary = tagVocabTotal === 0
|
||||
? '(데이터 없음)'
|
||||
: `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`;
|
||||
const recallSummary = recallShownCount === 0
|
||||
? '(데이터 없음)'
|
||||
: `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`;
|
||||
const recallAvgAge = recallShownCount === 0
|
||||
? '(데이터 없음)'
|
||||
: `${Math.round(recallAgeDaysSum / recallShownCount)}`;
|
||||
const lines: string[] = [];
|
||||
lines.push('# Inkling Telemetry Stats');
|
||||
lines.push('');
|
||||
@@ -138,10 +167,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 | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |');
|
||||
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} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`);
|
||||
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} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 핵심 ratio');
|
||||
@@ -155,6 +184,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`);
|
||||
lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`);
|
||||
lines.push(`- 태그 vocab: ${tagVocabSummary}`);
|
||||
lines.push(`- 회상 추천: ${recallSummary}`);
|
||||
lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`);
|
||||
lines.push('');
|
||||
return { md: lines.join('\n'), eventCount };
|
||||
}
|
||||
|
||||
@@ -39,7 +39,12 @@ const api: InklingApi = {
|
||||
return () => ipcRenderer.off('ollama:status', listener);
|
||||
},
|
||||
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
|
||||
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount')
|
||||
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -86,6 +87,7 @@ export function App(): React.ReactElement {
|
||||
<PendingBanner />
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
|
||||
@@ -184,7 +184,8 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
|
||||
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
|
||||
|
||||
{!isTrash && showIntentBanner && (
|
||||
|
||||
100
src/renderer/inbox/components/RecallBanner.tsx
Normal file
100
src/renderer/inbox/components/RecallBanner.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
export function RecallBanner(): React.ReactElement | null {
|
||||
const candidate = useInbox((s) => s.recallCandidate);
|
||||
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
|
||||
const openRecall = useInbox((s) => s.openRecall);
|
||||
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
|
||||
const snoozeRecall = useInbox((s) => s.snoozeRecall);
|
||||
|
||||
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
|
||||
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
|
||||
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
|
||||
const shownIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
|
||||
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]);
|
||||
|
||||
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
|
||||
useEffect(() => {
|
||||
if (!candidate) return;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
|
||||
if (shownIdsRef.current.has(candidate.id)) return;
|
||||
void inboxApi.emitRecallShown(candidate.id);
|
||||
shownIdsRef.current.add(candidate.id);
|
||||
}, [candidate, snoozeUntilMs]);
|
||||
|
||||
if (candidate === null) return null;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
|
||||
|
||||
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
|
||||
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
|
||||
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
|
||||
|
||||
function onOpen() {
|
||||
void openRecall(candidate!.id);
|
||||
const el = document.getElementById(`note-${candidate!.id}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays}일 전</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
background: '#4a7ec0', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
열어보기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void snoozeRecall()}
|
||||
style={{
|
||||
background: 'transparent', color: '#4a7ec0',
|
||||
border: '1px solid #4a7ec0', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
다음에
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void dismissRecallNote(candidate.id)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent', color: '#888',
|
||||
border: 'none', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
더 이상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeAgeDays(refIso: string): number {
|
||||
const refMs = new Date(refIso).getTime();
|
||||
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
|
||||
}
|
||||
@@ -18,6 +18,8 @@ interface InboxState {
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null;
|
||||
failedCount: number;
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -33,6 +35,10 @@ interface InboxState {
|
||||
snoozeExpired: () => void;
|
||||
recheckOllama: () => Promise<void>;
|
||||
retryAllFailed: () => Promise<void>;
|
||||
loadRecallCandidate: () => Promise<void>;
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>;
|
||||
snoozeRecall: () => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -54,9 +60,11 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
expiredCandidates: [],
|
||||
expiredSnoozeUntilMs: null,
|
||||
failedCount: 0,
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
@@ -64,21 +72,23 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount()
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount()
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
|
||||
},
|
||||
upsertNote(note) {
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
@@ -185,5 +195,34 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
|
||||
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
|
||||
set({ failedCount: 0 });
|
||||
},
|
||||
async loadRecallCandidate() {
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
set({ recallCandidate });
|
||||
},
|
||||
async openRecall(id) {
|
||||
await inboxApi.markRecallOpened(id);
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
set({ recallCandidate });
|
||||
},
|
||||
async dismissRecallNote(id) {
|
||||
await inboxApi.dismissRecall(id);
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
|
||||
set({ recallCandidate, recallSnoozeUntilMs: null });
|
||||
},
|
||||
async snoozeRecall() {
|
||||
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({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
|
||||
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
||||
const candidate = get().recallCandidate;
|
||||
if (candidate) {
|
||||
await inboxApi.emitRecallSnoozed(candidate.id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -84,6 +84,11 @@ export interface InboxApi {
|
||||
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
|
||||
retryAllFailed(): Promise<{ count: number }>;
|
||||
getFailedCount(): Promise<number>;
|
||||
listRecallCandidate(): Promise<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -373,3 +373,68 @@ describe('CaptureService.retryAllFailed', () => {
|
||||
expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService recall methods (v0.2.3 #6)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let emits: Array<{ kind: string; payload: any }>;
|
||||
let service: CaptureService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-'));
|
||||
store = new MediaStore(tmp);
|
||||
emits = [];
|
||||
service = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { emits.push(ev as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
|
||||
const id = repo.create({ rawText: 'old' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// No last_recalled_at → eligible immediately
|
||||
const candidate = await service.listRecallCandidate();
|
||||
expect(candidate?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const before = repo.findById(id)!.lastRecalledAt;
|
||||
expect(before).toBeNull();
|
||||
await service.markRecallOpened(id);
|
||||
expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
|
||||
expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
|
||||
});
|
||||
|
||||
it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
expect(repo.findById(id)!.recallDismissedAt).toBeNull();
|
||||
await service.dismissRecall(id);
|
||||
expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
|
||||
expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
|
||||
});
|
||||
|
||||
it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// Backdate created_at to 14 days ago
|
||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
|
||||
.run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
|
||||
await service.emitRecallShown(id);
|
||||
const shown = emits.find((e) => e.kind === 'recall_shown');
|
||||
expect(shown).toBeDefined();
|
||||
const payload = shown!.payload as { noteId: string; ageDays: number };
|
||||
expect(payload.noteId).toBe(id);
|
||||
expect(payload.ageDays).toBeGreaterThanOrEqual(13);
|
||||
expect(payload.ageDays).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,59 @@ describe('NoteRepository', () => {
|
||||
expect(typeof n).toBe('number');
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('findRecallCandidate returns null for empty db', () => {
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
});
|
||||
|
||||
it('findRecallCandidate excludes notes recalled within 7 days', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// 5일 전 본 노트 → 제외
|
||||
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
|
||||
repo.markRecallOpened(id, fiveDaysAgo);
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
});
|
||||
|
||||
it('findRecallCandidate includes notes recalled 8+ days ago', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
|
||||
repo.markRecallOpened(id, eightDaysAgo);
|
||||
expect(repo.findRecallCandidate()?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
|
||||
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
|
||||
repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨
|
||||
repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능
|
||||
const candidate = repo.findRecallCandidate();
|
||||
expect(candidate?.id).toBe(b);
|
||||
});
|
||||
|
||||
it('findRecallCandidate excludes deleted/pending/imminent due', () => {
|
||||
const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
|
||||
// (a) deleted
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(a, new Date().toISOString());
|
||||
// (b) pending (no AI)
|
||||
repo.create({ rawText: 'b' });
|
||||
// (c) due_date 어제
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
// (d) due_date today 는 OK (>=today 통과)
|
||||
const d = repo.create({ rawText: 'd' }).id;
|
||||
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()?.id).toBe(d);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
|
||||
77
tests/unit/store.recall.test.ts
Normal file
77
tests/unit/store.recall.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listRecallCandidate: vi.fn(),
|
||||
markRecallOpened: vi.fn(),
|
||||
dismissRecall: vi.fn(),
|
||||
emitRecallShown: vi.fn(),
|
||||
emitRecallSnoozed: vi.fn(),
|
||||
listNotes: vi.fn(async () => []),
|
||||
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),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0)
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store.js';
|
||||
import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
|
||||
const inboxApiMock = inboxApi as unknown as {
|
||||
listRecallCandidate: ReturnType<typeof vi.fn>;
|
||||
markRecallOpened: ReturnType<typeof vi.fn>;
|
||||
dismissRecall: ReturnType<typeof vi.fn>;
|
||||
emitRecallShown: ReturnType<typeof vi.fn>;
|
||||
emitRecallSnoozed: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const note = (id: string): Note => ({
|
||||
id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc',
|
||||
tags: [], media: [], aiStatus: 'done', aiProvider: null, aiGeneratedAt: null, aiError: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
|
||||
});
|
||||
|
||||
describe('store recall actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useInbox.setState({
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
} as Parameters<typeof useInbox.setState>[0]);
|
||||
});
|
||||
|
||||
it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => {
|
||||
useInbox.setState({ recallCandidate: note('n1') } as Parameters<typeof useInbox.setState>[0]);
|
||||
await useInbox.getState().snoozeRecall();
|
||||
const ms = useInbox.getState().recallSnoozeUntilMs;
|
||||
expect(ms).not.toBeNull();
|
||||
expect(ms!).toBeGreaterThan(Date.now());
|
||||
expect(inboxApiMock.emitRecallSnoozed).toHaveBeenCalledWith('n1');
|
||||
});
|
||||
|
||||
it('openRecall calls API + fetches next candidate', async () => {
|
||||
inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') });
|
||||
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null);
|
||||
await useInbox.getState().openRecall('n1');
|
||||
expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1');
|
||||
expect(inboxApiMock.listRecallCandidate).toHaveBeenCalled();
|
||||
expect(useInbox.getState().recallCandidate).toBeNull();
|
||||
});
|
||||
|
||||
it('dismissRecallNote calls API + fetches next candidate', async () => {
|
||||
inboxApiMock.dismissRecall.mockResolvedValueOnce({ note: note('n1') });
|
||||
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(note('n2'));
|
||||
await useInbox.getState().dismissRecallNote('n1');
|
||||
expect(inboxApiMock.dismissRecall).toHaveBeenCalledWith('n1');
|
||||
expect(useInbox.getState().recallCandidate?.id).toBe('n2');
|
||||
});
|
||||
});
|
||||
@@ -306,3 +306,30 @@ describe('validateEvent — tag vocab', () => {
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — recall', () => {
|
||||
it('accepts recall_shown event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId: 'n1', ageDays: 14 }
|
||||
});
|
||||
expect(e.kind).toBe('recall_shown');
|
||||
});
|
||||
|
||||
it('rejects recall_shown with extra field (privacy)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
|
||||
for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
|
||||
const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
|
||||
expect(e.kind).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -187,4 +187,27 @@ describe('aggregateStats — tag_vocab hit/miss', () => {
|
||||
expect(r.md).toContain('태그 vocab');
|
||||
expect(r.md).toContain('데이터 없음');
|
||||
});
|
||||
|
||||
it('aggregates recall events with open rate + average ageDays', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }),
|
||||
e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }),
|
||||
e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }),
|
||||
e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }),
|
||||
e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }),
|
||||
e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }),
|
||||
e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }),
|
||||
e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1');
|
||||
expect(r.md).toContain('열림율 50.0%');
|
||||
expect(r.md).toContain('회상 평균 ageDays: 25'); // (10+20+30+40)/4
|
||||
});
|
||||
|
||||
it('회상 summary shows 데이터 없음 when no recall events', () => {
|
||||
const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('회상 추천');
|
||||
expect(r.md).toContain('데이터 없음');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user