feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3)
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 통과한 것만 (한글/공백/대문자 제외).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user