From f4e1af83fe271ac88925e2ab61735af7c274bdae Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 13:25:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(recall):=20renderer=20store=20=E2=80=94=20?= =?UTF-8?q?recallCandidate=20+=204=20actions=20(#6=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recallCandidate, recallSnoozeUntilMs, recallShownIds (Set) state - loadInitial / refreshMeta 가 listRecallCandidate Promise.all 합류 - loadRecallCandidate / openRecall / dismissRecallNote / snoozeRecall actions - snoozeRecall: KST 다음 자정 (snoozeExpired 패턴 일관) + emitRecallSnoozed - openRecall / dismissRecallNote: API 호출 후 다음 후보 fetch - 신규 store.recall.test.ts +3 cases Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/store.ts | 50 ++++++++++++++++++--- tests/unit/store.recall.test.ts | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tests/unit/store.recall.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 92ed25c..7ff7c97 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -18,6 +18,9 @@ interface InboxState { expiredCandidates: Note[]; expiredSnoozeUntilMs: number | null; failedCount: number; + recallCandidate: Note | null; + recallSnoozeUntilMs: number | null; + recallShownIds: Set; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -33,6 +36,10 @@ interface InboxState { snoozeExpired: () => void; recheckOllama: () => Promise; retryAllFailed: () => Promise; + loadRecallCandidate: () => Promise; + openRecall: (id: string) => Promise; + dismissRecallNote: (id: string) => Promise; + snoozeRecall: () => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -54,9 +61,12 @@ export const useInbox = create((set, get) => ({ expiredCandidates: [], expiredSnoozeUntilMs: null, failedCount: 0, + recallCandidate: null, + recallSnoozeUntilMs: null, + recallShownIds: new Set(), 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 +74,23 @@ export const useInbox = create((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 +197,31 @@ export const useInbox = create((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(); + set({ recallCandidate }); + }, + 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 }); + const candidate = get().recallCandidate; + if (candidate) { + await inboxApi.emitRecallSnoozed(candidate.id); + } } })); diff --git a/tests/unit/store.recall.test.ts b/tests/unit/store.recall.test.ts new file mode 100644 index 0000000..75175e6 --- /dev/null +++ b/tests/unit/store.recall.test.ts @@ -0,0 +1,78 @@ +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; + markRecallOpened: ReturnType; + dismissRecall: ReturnType; + emitRecallShown: ReturnType; + emitRecallSnoozed: ReturnType; +}; + +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, + recallShownIds: new Set() + } as Parameters[0]); + }); + + it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => { + useInbox.setState({ recallCandidate: note('n1') } as Parameters[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'); + }); +});