feat(expiry): zustand store extension — expiredCandidates + snooze (#5 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ interface InboxState {
|
||||
todayCount: number;
|
||||
loading: boolean;
|
||||
tagFilter: string | null;
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -25,6 +27,9 @@ interface InboxState {
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -43,27 +48,31 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
todayCount: 0,
|
||||
loading: false,
|
||||
tagFilter: null,
|
||||
expiredCandidates: [],
|
||||
expiredSnoozeUntilMs: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount()
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount()
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates });
|
||||
},
|
||||
upsertNote(note) {
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
@@ -136,5 +145,27 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (r.confirmed) {
|
||||
set({ trashNotes: [], trashCount: 0 });
|
||||
}
|
||||
},
|
||||
async loadExpired() {
|
||||
const expiredCandidates = await inboxApi.listExpired();
|
||||
set({ expiredCandidates });
|
||||
},
|
||||
async trashExpiredBatch(ids: string[]) {
|
||||
const r = await inboxApi.trashExpiredBatch(ids);
|
||||
if (!r.confirmed) return;
|
||||
const idSet = new Set(ids);
|
||||
set({
|
||||
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
|
||||
notes: get().notes.filter((n) => !idSet.has(n.id)),
|
||||
trashCount: get().trashCount + r.trashedCount
|
||||
});
|
||||
},
|
||||
snoozeExpired() {
|
||||
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({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
}
|
||||
}));
|
||||
|
||||
101
tests/unit/store.expired.test.ts
Normal file
101
tests/unit/store.expired.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
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),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
listExpired: vi.fn(async () => [] as Note[]),
|
||||
trashExpiredBatch: vi.fn(async (_ids: string[]) => ({ trashedCount: 0, confirmed: false }))
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
const noteStub = (id: string): Note => ({
|
||||
id, rawText: 'x',
|
||||
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: '2026-04-20', dueDateEditedByUser: false,
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
describe('useInbox — expired state (v0.2.3 #5)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('loadExpired sets expiredCandidates from inboxApi', async () => {
|
||||
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().loadExpired();
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(1);
|
||||
expect(s.expiredCandidates[0]!.id).toBe('n1');
|
||||
});
|
||||
|
||||
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
notes: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
trashCount: 5
|
||||
});
|
||||
await useInbox.getState().trashExpiredBatch(['n1', 'n2']);
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates.map((n) => n.id)).toEqual(['n3']);
|
||||
expect(s.notes.map((n) => n.id)).toEqual(['n3']);
|
||||
expect(s.trashCount).toBe(7);
|
||||
});
|
||||
|
||||
it('trashExpiredBatch does NOT mutate state when not confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 0, confirmed: false });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2')],
|
||||
notes: [noteStub('n1'), noteStub('n2')],
|
||||
trashCount: 5
|
||||
});
|
||||
await useInbox.getState().trashExpiredBatch(['n1']);
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(2);
|
||||
expect(s.notes).toHaveLength(2);
|
||||
expect(s.trashCount).toBe(5);
|
||||
});
|
||||
|
||||
it('snoozeExpired sets expiredSnoozeUntilMs to next KST midnight', async () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → next KST midnight = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC
|
||||
const fixedNow = Date.parse('2026-05-01T12:00:00Z');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().snoozeExpired();
|
||||
expect(useInbox.getState().expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user