From 0eb2e6282f560b0ccf6fc87d9ac0b7d9c95a9d72 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 13:11:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(recall):=20NoteRepository=20=E2=80=94=20fi?= =?UTF-8?q?ndRecallCandidate=20+=20markRecallOpened=20+=20dismissRecall=20?= =?UTF-8?q?(#6=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1 - markRecallOpened(id, now): last_recalled_at 갱신 - dismissRecall(id, now): recall_dismissed_at 갱신 - KST 보정 SQL date('now','+9 hours') - 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 37 +++++++++++++++++++ tests/unit/NoteRepository.test.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 8a3a996..8ec8a2b 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -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 | 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 통과한 것만 (한글/공백/대문자 제외). diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index ff87460..9cb2854 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -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', () => {