feat(recall): CaptureService — 5 methods (list/open/dismiss/shown/snoozed) (#6 v0.2.3)

- listRecallCandidate(): repo.findRecallCandidate 위임
- markRecallOpened(id): last_recalled_at 갱신 + recall_opened emit
- dismissRecall(id): recall_dismissed_at 갱신 + recall_dismissed emit
- emitRecallShown(id): ageDays 계산 + recall_shown emit
- emitRecallSnoozed(id): recall_snoozed emit
- private computeAgeDays(note): last_recalled_at ?? created_at 기준 일수
- 단위 +4 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 13:20:44 +09:00
parent 59cfb711cd
commit 0c59ce3715
2 changed files with 131 additions and 0 deletions

View File

@@ -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,66 @@ 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 }> {
const before = this.repo.findById(noteId);
if (!before) 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));
}
}

View File

@@ -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);
});
});