From 606ac94976964b70e833508cb1a9355a864325b9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:51:51 +0900 Subject: [PATCH] feat(v029): useInbox view enum + counts + setView + listByStatus/countsByStatus IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.ts: view enum ('inbox'|'completed'|'archived'|'trash'|'settings') + counts + setView + loadByView. setShowSettings delegates to setView (mirror). - types.ts + preload + ipc/inboxApi: listByStatus + countsByStatus IPC. - NoteRepository.countByStatus 신규. - store.view.test (5) + NoteRepository.countByStatus test (1). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ipc/inboxApi.ts | 20 ++++++- src/main/repository/NoteRepository.ts | 11 ++++ src/preload/index.ts | 3 + src/renderer/inbox/store.ts | 56 ++++++++++++++--- src/shared/types.ts | 3 + tests/unit/NoteRepository.test.ts | 18 ++++++ tests/unit/store.view.test.ts | 86 +++++++++++++++++++++++++++ 7 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 tests/unit/store.view.test.ts diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 4da6e71..51c8c67 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -7,7 +7,7 @@ import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; import type { HealthChecker } from '../services/HealthChecker.js'; import type { IntentService } from '../services/IntentService.js'; -import type { Note } from '@shared/types'; +import type { Note, NoteStatus } from '@shared/types'; import type { HealthResult } from '../ai/InferenceProvider.js'; import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; import type { SettingsService } from '../services/SettingsService.js'; @@ -172,6 +172,24 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: true as const }; }); + // v0.2.9 Cut B Task 4 — status 별 노트 목록. + ipcMain.handle( + 'inbox:list-by-status', + (_e, status: NoteStatus, opts: { limit?: number } = {}) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) return [] as Note[]; + return deps.repo.listByStatus(status, opts); + } + ); + + // v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge). + ipcMain.handle('inbox:counts-by-status', () => ({ + active: deps.repo.countByStatus('active'), + completed: deps.repo.countByStatus('completed'), + archived: deps.repo.countByStatus('archived'), + trashed: deps.repo.countByStatus('trashed') + })); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index b93e6a2..adaecb1 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -456,6 +456,17 @@ export class NoteRepository { tx(); } + /** + * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용. + * tags/media hydrate 없음 (cheap path, listByStatus 와 별도). + */ + countByStatus(status: NoteStatus): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`) + .get(status) as { c: number }; + return row.c; + } + /** * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ffe824..e93fdea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -68,6 +68,9 @@ const api: InklingApi = { copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'), // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath), + // v0.2.9 Cut B Task 4 — status 별 list + counts. + listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), + countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'), } }; diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 3cc22ae..7abe2fe 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -5,12 +5,26 @@ import { nextKstMidnightMs } from '@shared/util/kstDate.js'; 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 interface InboxCounts { + active: number; + completed: number; + archived: number; + trashed: number; +} + interface InboxState { notes: Note[]; trashNotes: Note[]; trashCount: number; showTrash: boolean; showSettings: boolean; + // v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류. + view: InboxView; + counts: InboxCounts; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; @@ -28,6 +42,8 @@ interface InboxState { removeNote: (id: string) => void; setTagFilter: (tag: string | null) => void; setShowSettings: (open: boolean) => void; + setView: (view: InboxView) => void; + loadByView: (view: 'completed' | 'archived' | 'trash') => Promise; toggleShowTrash: () => Promise; loadTrash: () => Promise; restoreNote: (id: string) => Promise; @@ -55,6 +71,8 @@ export const useInbox = create((set, get) => ({ trashCount: 0, showTrash: false, showSettings: false, + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, @@ -68,7 +86,7 @@ export const useInbox = create((set, get) => ({ recallSnoozeUntilMs: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), @@ -77,12 +95,13 @@ export const useInbox = create((set, get) => ({ inboxApi.getTrashCount(), inboxApi.listExpired(), inboxApi.getFailedCount(), - inboxApi.listRecallCandidate() + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), @@ -90,9 +109,10 @@ export const useInbox = create((set, get) => ({ inboxApi.getTrashCount(), inboxApi.listExpired(), inboxApi.getFailedCount(), - inboxApi.listRecallCandidate() + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts }); }, upsertNote(note) { // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 @@ -138,7 +158,29 @@ export const useInbox = create((set, get) => ({ set({ tagFilter: tag }); }, setShowSettings(open) { - set({ showSettings: open }); + // backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신. + if (open) get().setView('settings'); + else get().setView('inbox'); + }, + setView(view) { + set({ + view, + showTrash: view === 'trash', + showSettings: view === 'settings' + }); + // settings/inbox 외 status view 면 해당 status fetch. + if (view === 'completed' || view === 'archived' || view === 'trash') { + void get().loadByView(view); + } + }, + async loadByView(view) { + const status = view === 'trash' ? 'trashed' : view; + const notes = await inboxApi.listByStatus(status, { limit: 200 }); + if (view === 'trash') { + set({ trashNotes: notes, trashCount: notes.length }); + } else { + set({ notes }); + } }, async toggleShowTrash() { const next = !get().showTrash; diff --git a/src/shared/types.ts b/src/shared/types.ts index 609f0c9..cad1cba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -135,6 +135,9 @@ export interface InboxApi { copyAppInfo(): Promise; // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>; + // v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count. + listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise; + countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>; } export interface InklingApi { diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 9c7186d..28f3b26 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -966,6 +966,24 @@ describe('NoteRepository — setStatus + listByStatus', () => { expect(note.moveReason).toBeNull(); }); + it('countByStatus returns accurate count per status', () => { + const a = repo.create({ rawText: 'a' }).id; // active + repo.create({ rawText: 'b' }); // active + const c = repo.create({ rawText: 'c' }).id; + repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); + const d = repo.create({ rawText: 'd' }).id; + repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + const e = repo.create({ rawText: 'e' }).id; + repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z')); + + expect(repo.countByStatus('active')).toBe(2); + expect(repo.countByStatus('completed')).toBe(1); + expect(repo.countByStatus('archived')).toBe(1); + expect(repo.countByStatus('trashed')).toBe(1); + // sanity — a 가 여전히 active. + expect(repo.findById(a)!.status).toBe('active'); + }); + it('restoreNote sets status=active + clears moveReason', () => { const { id } = repo.create({ rawText: 'r' }); repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z')); diff --git a/tests/unit/store.view.test.ts b/tests/unit/store.view.test.ts new file mode 100644 index 0000000..4615275 --- /dev/null +++ b/tests/unit/store.view.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Note } from '@shared/types'; + +const mockApi = { + listNotes: vi.fn(async () => [] as Note[]), + listTrash: vi.fn(async () => [] as Note[]), + listByStatus: vi.fn(async () => [] as Note[]), + countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + 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), + getFailedCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => [] as Note[]), + listRecallCandidate: vi.fn(async () => null), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + ollamaRecheck: vi.fn(async () => ({ ok: true })), + retryAllFailed: vi.fn(async () => {}), + markRecallOpened: vi.fn(async () => {}), + dismissRecall: vi.fn(async () => {}), + emitRecallSnoozed: vi.fn(async () => {}) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +describe('inbox store — view enum', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, + notes: [], trashNotes: [], trashCount: 0, + showTrash: false, showSettings: 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, + failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('initial view is inbox', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + expect(useInbox.getState().view).toBe('inbox'); + }); + + it('setView changes view', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('completed'); + expect(useInbox.getState().view).toBe('completed'); + }); + + it('counts initialized to zero per status', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 }); + }); + + it('backward-compat: showTrash mirrors view==="trash"', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('trash'); + expect(useInbox.getState().showTrash).toBe(true); + useInbox.getState().setView('inbox'); + expect(useInbox.getState().showTrash).toBe(false); + }); + + it('backward-compat: showSettings mirrors view==="settings"', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('settings'); + expect(useInbox.getState().showSettings).toBe(true); + useInbox.getState().setView('inbox'); + expect(useInbox.getState().showSettings).toBe(false); + }); +});