From f5e43133bed0156d4082c57a996e489b3970e374 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:31:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(v0211):=20store=20=E2=80=94=20search=20+?= =?UTF-8?q?=20reviewData=20state=20+=20actions=20+=20view=20enum=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= 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 | 47 +++++++++++++++++++++++++++++-- tests/unit/store.search.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/unit/store.search.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index c8d136a..9cde8c2 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Note, WeeklyContinuity } from '@shared/types'; +import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types'; import { inboxApi } from './api.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js'; @@ -7,7 +7,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js'; // v0.2.9 Cut B Task 4 — 4탭 view enum + settings. // 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage. -export type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'; +export type InboxView = + | 'inbox' | 'completed' | 'archived' | 'trash' | 'settings' + | 'review-daily' | 'review-weekly' | 'review-monthly'; export interface InboxCounts { active: number; @@ -39,6 +41,10 @@ interface InboxState { // v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip. // 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드. ai_enabled: boolean; + // v0.2.11 Cut D — FTS5 search + review aggregate state. + searchQuery: string; + searchResults: Note[] | null; // null = 검색 안 한 상태 + reviewData: ReviewAggregate | null; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -61,6 +67,11 @@ interface InboxState { openRecall: (id: string) => Promise; dismissRecallNote: (id: string) => Promise; snoozeRecall: () => Promise; + // v0.2.11 Cut D — search + review actions. + setSearchQuery: (q: string) => void; + searchNotes: (q: string) => Promise; + clearSearch: () => void; + loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -88,6 +99,9 @@ export const useInbox = create((set, get) => ({ recallCandidate: null, recallSnoozeUntilMs: null, ai_enabled: true, + searchQuery: '', + searchResults: null, + reviewData: null, async loadInitial() { set({ loading: true }); const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ @@ -178,6 +192,10 @@ export const useInbox = create((set, get) => ({ if (view === 'completed' || view === 'archived' || view === 'trash') { void get().loadByView(view); } + // v0.2.11 Cut D — review-* view 진입 시 aggregate 로드. + if (view === 'review-daily') void get().loadReview('daily'); + if (view === 'review-weekly') void get().loadReview('weekly'); + if (view === 'review-monthly') void get().loadReview('monthly'); }, async loadByView(view) { const status = view === 'trash' ? 'trashed' : view; @@ -269,5 +287,30 @@ export const useInbox = create((set, get) => ({ if (candidate) { await inboxApi.emitRecallSnoozed(candidate.id); } + }, + // v0.2.11 Cut D — FTS5 search + review aggregate actions. + setSearchQuery(q) { + set({ searchQuery: q }); + if (q.trim().length === 0) set({ searchResults: null }); + }, + async searchNotes(q) { + if (q.trim().length === 0) { + set({ searchResults: null }); + return; + } + const view = get().view; + // 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색 + const status = view === 'completed' || view === 'archived' || view === 'trash' + ? (view === 'trash' ? 'trashed' : view) + : view === 'inbox' ? 'active' : undefined; + const r = await inboxApi.search(q, status ? { status } : {}); + set({ searchResults: r }); + }, + clearSearch() { + set({ searchQuery: '', searchResults: null }); + }, + async loadReview(period) { + const data = await inboxApi.reviewAggregate(period); + set({ reviewData: data }); } })); diff --git a/tests/unit/store.search.test.ts b/tests/unit/store.search.test.ts new file mode 100644 index 0000000..446fa50 --- /dev/null +++ b/tests/unit/store.search.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + search: vi.fn(), + reviewAggregate: vi.fn(), + listNotes: vi.fn(() => []), + getContinuity: vi.fn(() => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(() => 0), + getOllamaStatus: vi.fn(() => ({ ok: true })), + getTodayCount: vi.fn(() => 0), + getTrashCount: vi.fn(() => 0), + listExpired: vi.fn(() => []), + getFailedCount: vi.fn(() => 0), + listRecallCandidate: vi.fn(() => null), + countsByStatus: vi.fn(() => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + getSettings: vi.fn(() => ({ ai_enabled: true })), + listByStatus: vi.fn(() => []) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; + +describe('store — searchNotes', () => { + beforeEach(() => { + vi.clearAllMocks(); + useInbox.setState({ searchQuery: '', searchResults: null, view: 'inbox' }); + }); + + it('빈 query → searchResults null + IPC 미호출', async () => { + await useInbox.getState().searchNotes(' '); + expect(useInbox.getState().searchResults).toBeNull(); + expect(inboxApi.search).not.toHaveBeenCalled(); + }); + + it('keyword query → IPC 호출 + searchResults set', async () => { + (inboxApi.search as ReturnType).mockResolvedValue([{ id: 'a' }]); + await useInbox.getState().searchNotes('회의'); + expect(inboxApi.search).toHaveBeenCalledWith('회의', { status: 'active' }); + expect(useInbox.getState().searchResults).toEqual([{ id: 'a' }]); + }); + + it('clearSearch — query + results 모두 초기화', () => { + useInbox.setState({ searchQuery: '회의', searchResults: [{ id: 'a' } as never] }); + useInbox.getState().clearSearch(); + expect(useInbox.getState().searchQuery).toBe(''); + expect(useInbox.getState().searchResults).toBeNull(); + }); +});