feat(recall): renderer store — recallCandidate + 4 actions (#6 v0.2.3)
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ interface InboxState {
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null;
|
||||
failedCount: number;
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
recallShownIds: Set<string>;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -33,6 +36,10 @@ interface InboxState {
|
||||
snoozeExpired: () => void;
|
||||
recheckOllama: () => Promise<void>;
|
||||
retryAllFailed: () => Promise<void>;
|
||||
loadRecallCandidate: () => Promise<void>;
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>;
|
||||
snoozeRecall: () => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -54,9 +61,12 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
expiredCandidates: [],
|
||||
expiredSnoozeUntilMs: null,
|
||||
failedCount: 0,
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
recallShownIds: new Set<string>(),
|
||||
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<InboxState>((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<InboxState>((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);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
78
tests/unit/store.recall.test.ts
Normal file
78
tests/unit/store.recall.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
markRecallOpened: ReturnType<typeof vi.fn>;
|
||||
dismissRecall: ReturnType<typeof vi.fn>;
|
||||
emitRecallShown: ReturnType<typeof vi.fn>;
|
||||
emitRecallSnoozed: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<string>()
|
||||
} as Parameters<typeof useInbox.setState>[0]);
|
||||
});
|
||||
|
||||
it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => {
|
||||
useInbox.setState({ recallCandidate: note('n1') } as Parameters<typeof useInbox.setState>[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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user