From c6cd897d8207b66ed4e9862b8742bc5a736d31e3 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 15:18:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20BatchMoveModal=20+=20'AI=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=98=EA=B8=B0'=20=EB=B2=84=ED=8A=BC=20(d?= =?UTF-8?q?efault=20notebook=20=EC=9D=BC=EA=B4=84=20=EB=B6=84=EB=A5=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/inbox/App.tsx | 22 +++ .../inbox/components/BatchMoveModal.tsx | 98 ++++++++++++ src/renderer/inbox/store.ts | 31 +++- tests/unit/BatchMoveModal.test.tsx | 143 ++++++++++++++++++ 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/renderer/inbox/components/BatchMoveModal.tsx create mode 100644 tests/unit/BatchMoveModal.test.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index da7d069..50c12c7 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -18,6 +18,7 @@ import { SearchBox } from './components/SearchBox.js'; import { ReviewView } from './components/ReviewView.js'; import { Sidebar } from './components/Sidebar.js'; import { PromotionBanner } from './components/PromotionBanner.js'; +import { BatchMoveModal } from './components/BatchMoveModal.js'; import type { InboxView } from './store.js'; // QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl. @@ -37,6 +38,10 @@ export function App(): React.ReactElement { const counts = useInbox((s) => s.counts); const setView = useInbox((s) => s.setView); const searchResults = useInbox((s) => s.searchResults); + const selectedNotebookId = useInbox((s) => s.selectedNotebookId); + const notebooks = useInbox((s) => s.notebooks); + const runBatchClassify = useInbox((s) => s.runBatchClassify); + const batchClassifying = useInbox((s) => s.batchClassifying); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); // v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시. const [showOnboarding, setShowOnboarding] = useState(null); @@ -90,6 +95,9 @@ export function App(): React.ReactElement { // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); + // v0.4 T5 — default notebook(첫 번째) 선택 시 "AI 정리하기" 버튼 노출 조건. + const isDefaultNotebook = notebooks.length > 0 && selectedNotebookId === notebooks[0]?.id; + if (showOnboarding === null) return <>; if (showOnboarding) return setShowOnboarding(false)} />; @@ -150,6 +158,19 @@ export function App(): React.ReactElement { ))} + {view === 'inbox' && isDefaultNotebook && notebooks.length > 1 && ( + + )} toggle(a.noteId)} + /> + + {findNote(a.noteId)} + + → {a.notebookName} + + ))} + + + )} +
+ + +
+ + + ); +} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 900106c..3d55d87 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types'; +import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity, BatchClassifyResult } from '@shared/types'; import { inboxApi, notebookApi } from './api.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js'; @@ -56,6 +56,9 @@ interface InboxState { sidebarWidth: number; // v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록). promotionCandidates: PromotionCandidate[]; + // v0.4 T5 — AI batch classify state. + batchClassifyResult: BatchClassifyResult | null; + batchClassifying: boolean; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -98,6 +101,10 @@ interface InboxState { acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise; snoozePromotion: () => Promise; dismissPromotion: (tag: string) => Promise; + // v0.4 T5 — AI batch classify actions. + runBatchClassify: () => Promise; + clearBatchClassify: () => void; + acceptBatchAssignments: (accepted: Array<{ noteId: string; notebookId: string }>) => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -133,6 +140,8 @@ export const useInbox = create((set, get) => ({ sidebarVisible: true, sidebarWidth: 240, promotionCandidates: [], + batchClassifyResult: null, + batchClassifying: false, async loadInitial() { // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset. set({ loading: true }); @@ -545,5 +554,25 @@ export const useInbox = create((set, get) => ({ async dismissPromotion(tag) { await inboxApi.addPromotionDismissedTag(tag); set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) }); + }, + // v0.4 T5 — AI batch classify actions. + async runBatchClassify() { + set({ batchClassifying: true }); + try { + const r = await inboxApi.batchClassifyDefault(); + set({ batchClassifyResult: r, batchClassifying: false }); + } catch (e) { + console.error('[inbox] batchClassify failed', e); + set({ batchClassifying: false }); + } + }, + clearBatchClassify() { + set({ batchClassifyResult: null }); + }, + async acceptBatchAssignments(accepted) { + await Promise.all(accepted.map((a) => notebookApi.moveNote(a.noteId, a.notebookId))); + set({ batchClassifyResult: null }); + await get().refreshMeta(); + await get().loadByView(get().view as 'inbox' | 'completed' | 'trash'); } })); diff --git a/tests/unit/BatchMoveModal.test.tsx b/tests/unit/BatchMoveModal.test.tsx new file mode 100644 index 0000000..145d0b5 --- /dev/null +++ b/tests/unit/BatchMoveModal.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; + +const { mockMoveNote, mockBatchClassifyDefault } = vi.hoisted(() => ({ + mockMoveNote: vi.fn(async () => ({ ok: true as const })), + mockBatchClassifyDefault: vi.fn(async () => ({ assignments: [] })) +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + batchClassifyDefault: mockBatchClassifyDefault, + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })), + getSettings: vi.fn(async () => ({ ai_enabled: true })), + 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), + listRecallCandidate: vi.fn(async () => null), + listByStatus: vi.fn(async () => []) + }, + notebookApi: { + moveNote: mockMoveNote, + list: vi.fn(async () => []) + } +})); + +import { BatchMoveModal } from '../../src/renderer/inbox/components/BatchMoveModal'; +import { useInbox } from '../../src/renderer/inbox/store'; + +const baseNote = { + id: 'n1', + rawText: '테스트 노트 내용', + aiTitle: 'AI 제목', + aiSummary: null, + aiStatus: 'done' as const, + aiError: null, + aiProvider: null, + aiGeneratedAt: null, + titleEditedByUser: false, + summaryEditedByUser: false, + userIntent: null, + intentPromptedAt: null, + dueDate: null, + dueDateEditedByUser: false, + deletedAt: null, + lastRecalledAt: null, + recallDismissedAt: null, + status: 'active' as const, + statusChangedAt: null, + moveReason: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + tags: [], + media: [], + notebookId: 'nb-default' +}; + +describe('BatchMoveModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + useInbox.setState({ + batchClassifyResult: null, + batchClassifying: false, + notes: [], + notebooks: [] + } as Partial>); + }); + + it('batchClassifyResult null 시 null 반환', () => { + useInbox.setState({ batchClassifyResult: null } as Partial>); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('actionable 0건 시 "찾지 못했어요" 메시지 표시', () => { + useInbox.setState({ + batchClassifyResult: { assignments: [{ noteId: 'n1', notebookId: null, notebookName: null }] } + } as Partial>); + render(); + expect(screen.getByText(/찾지 못했어요/)).toBeInTheDocument(); + }); + + it('actionable 노트 표시 + checkbox 기본 체크', () => { + useInbox.setState({ + batchClassifyResult: { + assignments: [ + { noteId: 'n1', notebookId: 'nb1', notebookName: '업무' } + ] + }, + notes: [{ ...baseNote, id: 'n1', aiTitle: 'AI 제목' }] + } as Partial>); + render(); + expect(screen.getByText('AI 제목')).toBeInTheDocument(); + expect(screen.getByText(/→ 업무/)).toBeInTheDocument(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('checkbox toggle 시 selectedIds 변경 (체크 해제 후 확인 버튼 비활성)', () => { + useInbox.setState({ + batchClassifyResult: { + assignments: [ + { noteId: 'n1', notebookId: 'nb1', notebookName: '업무' } + ] + }, + notes: [{ ...baseNote, id: 'n1' }] + } as Partial>); + render(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(false); + const confirmBtn = screen.getByRole('button', { name: /건 이동/ }); + expect(confirmBtn).toBeDisabled(); + }); + + it('확인 클릭 → acceptBatchAssignments 호출 후 modal 닫힘', async () => { + const mockAccept = vi.fn(async () => {}); + useInbox.setState({ + batchClassifyResult: { + assignments: [ + { noteId: 'n1', notebookId: 'nb1', notebookName: '업무' } + ] + }, + notes: [{ ...baseNote, id: 'n1' }], + acceptBatchAssignments: mockAccept + } as Partial>); + render(); + const confirmBtn = screen.getByRole('button', { name: /건 이동/ }); + fireEvent.click(confirmBtn); + await waitFor(() => { + expect(mockAccept).toHaveBeenCalledWith([{ noteId: 'n1', notebookId: 'nb1' }]); + }); + }); +});