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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user