From 99cdc346d20a2504fe789841e31004f1f34fe396 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 21:38:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(trash):=20zustand=20store=20=E2=80=94=20sh?= =?UTF-8?q?owTrash/trashNotes/trashCount=20+=205=20actions=20(#4=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/inbox/store.ts | 77 ++++++++++++++++++++++++----- tests/unit/store.trash.test.ts | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 tests/unit/store.trash.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index f4efcb5..dbfcd2c 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -6,6 +6,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js'; interface InboxState { notes: Note[]; + trashNotes: Note[]; + trashCount: number; + showTrash: boolean; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; @@ -17,6 +20,11 @@ interface InboxState { upsertNote: (note: Note) => void; removeNote: (id: string) => void; setTagFilter: (tag: string | null) => void; + toggleShowTrash: () => Promise; + loadTrash: () => Promise; + restoreNote: (id: string) => Promise; + permanentDeleteNote: (id: string) => Promise; + emptyTrash: () => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -26,6 +34,9 @@ const emptyContinuity: WeeklyContinuity = { export const useInbox = create((set, get) => ({ notes: [], + trashNotes: [], + trashCount: 0, + showTrash: false, continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, @@ -34,38 +45,78 @@ export const useInbox = create((set, get) => ({ tagFilter: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), - inboxApi.getTodayCount() + inboxApi.getTodayCount(), + inboxApi.getTrashCount() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), - inboxApi.getTodayCount() + inboxApi.getTodayCount(), + inboxApi.getTrashCount() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount }); }, upsertNote(note) { - const i = get().notes.findIndex((n) => n.id === note.id); - if (i >= 0) { - const next = get().notes.slice(); - next[i] = note; - set({ notes: next }); + if (note.deletedAt !== null) { + // trash 노트: notes 에서 제거 + trashNotes 에 upsert + const cleanNotes = get().notes.filter((n) => n.id !== note.id); + const ti = get().trashNotes.findIndex((n) => n.id === note.id); + const nextTrash = get().trashNotes.slice(); + if (ti >= 0) nextTrash[ti] = note; + else nextTrash.unshift(note); + set({ notes: cleanNotes, trashNotes: nextTrash, trashCount: nextTrash.length }); } else { - set({ notes: [note, ...get().notes] }); + // active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함) + const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id); + const i = get().notes.findIndex((n) => n.id === note.id); + const nextNotes = get().notes.slice(); + if (i >= 0) nextNotes[i] = note; + else nextNotes.unshift(note); + set({ notes: nextNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length }); } }, removeNote(id) { - set({ notes: get().notes.filter((n) => n.id !== id) }); + const cleanNotes = get().notes.filter((n) => n.id !== id); + const cleanTrash = get().trashNotes.filter((n) => n.id !== id); + set({ notes: cleanNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length }); }, setTagFilter(tag) { set({ tagFilter: tag }); + }, + async toggleShowTrash() { + const next = !get().showTrash; + set({ showTrash: next }); + if (next) await get().loadTrash(); + }, + async loadTrash() { + const trashNotes = await inboxApi.listTrash({ limit: 200 }); + set({ trashNotes, trashCount: trashNotes.length }); + }, + async restoreNote(id) { + await inboxApi.restoreNote(id); + // onNoteUpdated 이벤트 미수신 케이스 대비 (renderer 자가 갱신) + const note = get().trashNotes.find((n) => n.id === id); + if (note) { + get().upsertNote({ ...note, deletedAt: null }); + } + }, + async permanentDeleteNote(id) { + const r = await inboxApi.permanentDeleteNote(id); + if (r.confirmed) get().removeNote(id); + }, + async emptyTrash() { + const r = await inboxApi.emptyTrash(); + if (r.confirmed) { + set({ trashNotes: [], trashCount: 0 }); + } } })); diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts new file mode 100644 index 0000000..b6cfedf --- /dev/null +++ b/tests/unit/store.trash.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } 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 () => {}) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +const noteStub = (id: string, deletedAt: string | null = null): 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: null, dueDateEditedByUser: false, + deletedAt, lastRecalledAt: null, recallDismissedAt: null, + createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', + tags: [], media: [] +}); + +describe('useInbox — trash state (v0.2.3 #4)', () => { + 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 } + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('toggleShowTrash flips state and triggers loadTrash on enter', async () => { + mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + await useInbox.getState().toggleShowTrash(); + expect(useInbox.getState().showTrash).toBe(true); + expect(useInbox.getState().trashNotes).toHaveLength(1); + expect(mockApi.listTrash).toHaveBeenCalled(); + await useInbox.getState().toggleShowTrash(); + expect(useInbox.getState().showTrash).toBe(false); + }); + + it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + expect(useInbox.getState().notes).toHaveLength(0); + expect(useInbox.getState().trashNotes).toHaveLength(1); + }); + + it('upsertNote moves note from notes to trashNotes when trashed', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a')); + expect(useInbox.getState().notes).toHaveLength(1); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + expect(useInbox.getState().notes).toHaveLength(0); + expect(useInbox.getState().trashNotes).toHaveLength(1); + }); + + it('restoreNote calls api', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + await useInbox.getState().restoreNote('a'); + expect(mockApi.restoreNote).toHaveBeenCalledWith('a'); + }); + + it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => { + mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + await useInbox.getState().emptyTrash(); + expect(useInbox.getState().trashNotes).toHaveLength(1); + }); +});