diff --git a/docs/superpowers/plans/2026-05-09-v029-cut-b.md b/docs/superpowers/plans/2026-05-09-v029-cut-b.md new file mode 100644 index 0000000..17e901f --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-v029-cut-b.md @@ -0,0 +1,1867 @@ +# v0.2.9 Cut B Implementation Plan — status 4분기 + 사유 + Ollama-less + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** F17 (status 4분기 + AI 자동 분류) + F18 (자유 텍스트 사유) + F23 (Ollama-less 모드 onboarding wizard + 설정 토글 + raw-only NoteCard fallback) — 데이터 모델 정비 cut. + +**Architecture:** notes 테이블에 `status` 컬럼 (4분기: active/completed/archived/trashed) + `move_reason` 자유 텍스트 + `status_changed_at` 추가. ai_status enum 에 `disabled` 신규 값 (application-level zod 검증). useInbox `view` enum 4탭 확장. NoteCard 액션 메뉴 + 사유 입력 modal + AI 자동 분류 버튼 (사유 + raw_text → AI prompt → recommended status + rationale → 사용자 confirm). 첫 launch onboarding wizard (3 옵션: AI 사용 / 원문만 / 나중에) + 설정 페이지 "AI 자동 처리 사용" 토글 + ai_enabled false 시 capture path skip pending_jobs + Banner 비활성. + +**Tech Stack:** Electron 41 + React 19 + better-sqlite3 12.9 + zod 4.3.6 + zustand 5 + +**선행 spec:** [docs/superpowers/specs/2026-05-09-v029-cut-b-design.md](docs/superpowers/specs/2026-05-09-v029-cut-b-design.md) + +--- + +## File Structure + +### 신규 파일 + +| 경로 | 책임 | +|---|---| +| `src/main/db/migrations/m004.ts` | status 컬럼 + status_changed_at + move_reason 추가 + 기존 deleted_at != NULL → status='trashed' migrate | +| `src/renderer/inbox/components/MoveStatusModal.tsx` | 사유 입력 modal + 4 status 버튼 + AI 자동 분류 버튼 | +| `src/renderer/inbox/components/OnboardingWizard.tsx` | 첫 launch 3 옵션 onboarding | +| `src/main/ai/classifyStatus.ts` | AiWorker.classifyStatus — reason + raw_text + summary → AI prompt → { recommended, rationale } | +| 위 컴포넌트들의 `.test.ts(x)` | 단위 테스트 (vitest + RTL) | + +### 수정 파일 + +| 경로 | 변경 | +|---|---| +| `src/main/db/index.ts` | m004 migration 등록 | +| `src/shared/types.ts` | `NoteStatus` enum + `Note.status/statusChangedAt/moveReason` field + `InboxApi.setStatus` / `classifyStatus` 시그니처 + `AiStatus` 에 'disabled' enum 추가 | +| `src/main/repository/NoteRepository.ts` | `setStatus(id, status, reason)` / `listByStatus(status, opts)` 메서드 추가, 기존 `restoreNote` → `setStatus(id, 'active', null)` 재구현 | +| `src/main/services/CaptureService.ts` | `ai_enabled` settings 조회 → false 시 ai_status='disabled' + pending_jobs skip | +| `src/main/services/SettingsService.ts` | settings 스키마에 `ai_enabled: boolean (default true)` + `onboarding_completed: boolean (default false)` 추가 | +| `src/main/ai/AiWorker.ts` | `classifyStatus` 메서드 추가 (현 generate 와 별도 path) | +| `src/main/health/HealthChecker.ts` | ai_enabled=false 시 polling 중단 | +| `src/main/ipc/inboxApi.ts` | `inbox:set-status` + `ai:classify-status` IPC 핸들러 | +| `src/main/ipc/settingsApi.ts` | settings 에 ai_enabled / onboarding_completed 토글 IPC | +| `src/preload/index.ts` | 신규 채널 화이트리스트 | +| `src/renderer/inbox/api.ts` | wrapper (setStatus / classifyStatus / setAiEnabled / setOnboardingCompleted) | +| `src/renderer/inbox/store.ts` | view enum 확장 (`inbox`/`completed`/`archived`/`trash`/`settings`) + count fields per status | +| `src/renderer/inbox/App.tsx` | 헤더 4탭 + Onboarding wizard 첫 launch 분기 | +| `src/renderer/inbox/components/NoteCard.tsx` | 액션 메뉴 (완료/보관/휴지통) + ai_status='disabled' fallback (title = raw_text 첫 줄) | +| `src/renderer/inbox/components/OllamaBanner.tsx` + `FailedBanner.tsx` | ai_enabled=false 시 render skip | +| `src/renderer/inbox/components/settings/AiProviderSection.tsx` | 상단 "AI 자동 처리 사용" 토글 + disabled 메모 N건 처리 버튼 | + +--- + +## Phase 개요 + +``` +Phase 1: m004 schema + repo (Task 1, 2) +Phase 2: ai_status 'disabled' + capture skip (Task 3) +Phase 3: 4탭 UI 인프라 (Task 4, 5) +Phase 4: 사유 입력 modal (Task 6, 7, 8) +Phase 5: AI 자동 분류 (Task 9, 10) +Phase 6: Onboarding wizard (Task 11, 12) +Phase 7: AI off NoteCard fallback + Banner 비활성 (Task 13, 14) +Phase 8: 설정 페이지 토글 + disabled 메모 처리 (Task 15, 16) +Phase 9: verification + version bump (Task 17) +``` + +--- + +## Phase 1: m004 schema + NoteRepository + +### Task 1: m004 마이그레이션 — status 컬럼 추가 + +**Files:** + +- Create: `src/main/db/migrations/m004.ts` +- Modify: `src/main/db/index.ts` (migration 등록) +- Test: `tests/unit/m004-migration.test.ts` + +- [ ] **Step 1: failing test** + +```ts +// tests/unit/m004-migration.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { applyM004 } from '../../src/main/db/migrations/m004'; + +describe('m004 migration — status column', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + // m003 baseline (notes 테이블 with deleted_at) 가정 — 기존 마이그레이션 적용 + db.exec(` + CREATE TABLE notes ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + title TEXT, + summary TEXT, + tags_csv TEXT, + ai_status TEXT NOT NULL DEFAULT 'pending', + ai_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); + INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at) + VALUES ('a', 't1', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL), + ('b', 't2', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z'); + `); + }); + + it('adds status / status_changed_at / move_reason columns', () => { + applyM004(db); + const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>; + const names = cols.map(c => c.name); + expect(names).toContain('status'); + expect(names).toContain('status_changed_at'); + expect(names).toContain('move_reason'); + }); + + it('default status="active" for non-deleted notes', () => { + applyM004(db); + const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string }; + expect(a.status).toBe('active'); + }); + + it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => { + applyM004(db); + const b = db.prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`).get('b') as { status: string; status_changed_at: string }; + expect(b.status).toBe('trashed'); + expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z'); + }); + + it('move_reason NULL by default', () => { + applyM004(db); + const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as { move_reason: string | null }; + expect(a.move_reason).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npm run rebuild:node +npx vitest run tests/unit/m004-migration.test.ts +``` + +Expected: `Cannot find module '../../src/main/db/migrations/m004'`. + +- [ ] **Step 3: m004.ts 작성** + +```ts +// src/main/db/migrations/m004.ts +import type Database from 'better-sqlite3'; + +export function applyM004(db: Database.Database): void { + db.exec(` + ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE notes ADD COLUMN status_changed_at TEXT; + ALTER TABLE notes ADD COLUMN move_reason TEXT; + `); + // 기존 deleted_at != NULL → status='trashed' + status_changed_at = deleted_at + db.prepare(` + UPDATE notes SET status='trashed', status_changed_at=deleted_at + WHERE deleted_at IS NOT NULL + `).run(); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/m004-migration.test.ts +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: src/main/db/index.ts 의 migration registry 갱신** + +기존 `applyM003` 다음에 `applyM004` 추가. 마이그레이션 호출 chain (주로 `runMigrations()` 함수 안 또는 비슷한 곳) 에 m004 등록. 기존 패턴 따름. + +- [ ] **Step 6: typecheck + 전체 회귀** + +```bash +npm run typecheck +npx vitest run +``` + +Expected: 0 errors + 472 → 476 (+4). + +- [ ] **Step 7: commit** + +```bash +git add src/main/db/migrations/m004.ts src/main/db/index.ts tests/unit/m004-migration.test.ts +git commit -m "feat(v029): m004 마이그레이션 — status/status_changed_at/move_reason 컬럼" +``` + +--- + +### Task 2: NoteRepository.setStatus + listByStatus + restoreNote 재구현 + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `src/shared/types.ts` (NoteStatus + Note 필드) +- Modify: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: failing test** + +```ts +// tests/unit/NoteRepository.test.ts 안 (기존 describe 블록에 추가) +describe('NoteRepository — setStatus + listByStatus', () => { + // (기존 setup helper 활용) + + it('setStatus updates status + reason + status_changed_at', () => { + const noteId = repo.create({ rawText: 'test', now: new Date('2026-05-09T00:00:00Z') }).id; + repo.setStatus(noteId, 'completed', '결재 끝', new Date('2026-05-10T00:00:00Z')); + const note = repo.getById(noteId); + expect(note?.status).toBe('completed'); + expect(note?.moveReason).toBe('결재 끝'); + expect(note?.statusChangedAt).toBe('2026-05-10T00:00:00Z'); + }); + + it('listByStatus filters correctly', () => { + repo.create({ rawText: 'a', now: new Date() }); + const idB = repo.create({ rawText: 'b', now: new Date() }).id; + repo.setStatus(idB, 'archived', null, new Date()); + + const active = repo.listByStatus('active', { limit: 10 }); + const archived = repo.listByStatus('archived', { limit: 10 }); + expect(active.map(n => n.rawText)).toContain('a'); + expect(archived.map(n => n.rawText)).toContain('b'); + }); + + it('restoreNote sets status=active with null reason', () => { + const id = repo.create({ rawText: 'r', now: new Date() }).id; + repo.setStatus(id, 'trashed', null, new Date()); + repo.restoreNote(id); + const note = repo.getById(id); + expect(note?.status).toBe('active'); + expect(note?.moveReason).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run tests/unit/NoteRepository.test.ts +``` + +Expected: setStatus / listByStatus 미존재 → fail. + +- [ ] **Step 3: types.ts 갱신** + +```ts +// src/shared/types.ts +export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; + +export interface Note { + // ... 기존 필드 + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; +} +``` + +- [ ] **Step 4: NoteRepository 메서드 추가** + +```ts +// src/main/repository/NoteRepository.ts +setStatus(id: string, status: NoteStatus, reason: string | null, now: Date = new Date()): void { + const tx = this.db.transaction(() => { + this.db.prepare(` + UPDATE notes + SET status=?, move_reason=?, status_changed_at=?, updated_at=? + WHERE id=? + `).run(status, reason, now.toISOString(), now.toISOString(), id); + // status='trashed' 시 deleted_at 도 동기화 (backward compat — 기존 코드 가 deleted_at 참조) + if (status === 'trashed') { + this.db.prepare(`UPDATE notes SET deleted_at=? WHERE id=?`).run(now.toISOString(), id); + } else { + this.db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=?`).run(id); + } + }); + tx(); +} + +listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { + const limit = opts.limit ?? 200; + const rows = this.db.prepare(` + SELECT * FROM notes WHERE status=? + ORDER BY status_changed_at DESC, created_at DESC + LIMIT ? + `).all(status, limit); + return rows.map(r => this.hydrate(r as Record)); +} +``` + +`restoreNote` 재구현 — 기존 `repo.restoreNote(id)` 본문을 `setStatus(id, 'active', null)` 로 교체: + +```ts +restoreNote(id: string): void { + this.setStatus(id, 'active', null); + // 기존 m003 의 ai_status='failed' → 'pending' 재투입 로직 보존 + const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id=?`).get(id) as { ai_status: string } | undefined; + if (before?.ai_status === 'failed') { + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`).run(now, id); + this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now); + } else if (before?.ai_status === 'pending') { + const now = new Date().toISOString(); + this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now); + } +} +``` + +`hydrate` 메서드도 status / status_changed_at / move_reason 매핑 추가: + +```ts +private hydrate(row: Record): Note { + return { + // ... 기존 + status: (row.status as NoteStatus) ?? 'active', + statusChangedAt: (row.status_changed_at as string | null) ?? null, + moveReason: (row.move_reason as string | null) ?? null + }; +} +``` + +- [ ] **Step 5: 테스트 + typecheck + 회귀** + +```bash +npx vitest run tests/unit/NoteRepository.test.ts +npm run typecheck +npx vitest run +``` + +Expected: 신규 3 test pass + 회귀 476 → 479. + +- [ ] **Step 6: commit** + +```bash +git add src/main/repository/NoteRepository.ts src/shared/types.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(v029): NoteRepository.setStatus + listByStatus + restoreNote 재구현" +``` + +--- + +## Phase 2: ai_status='disabled' + capture skip + +### Task 3: ai_status 'disabled' enum + CaptureService aiEnabled 분기 + +**Files:** + +- Modify: `src/main/services/telemetryEvents.ts` (또는 ai_status enum 위치) — 'disabled' 추가 +- Modify: `src/main/services/SettingsService.ts` — `ai_enabled` field schema +- Modify: `src/main/services/CaptureService.ts` — aiEnabled 분기 +- Modify: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: ai_status enum 'disabled' 추가** + +`src/shared/types.ts` 또는 ai_status 정의 위치 (현재 v0.2.6 의 `AiFailedReason` 통합 commit 위치 참조): + +```ts +// 기존 AiStatus enum 또는 zod schema: +export const AiStatusSchema = z.enum(['pending', 'processing', 'complete', 'failed', 'disabled']); +export type AiStatus = z.infer; +``` + +만약 ai_status 가 application-level 검증만이고 SQLite CHECK 부재 (spec §5-1) — application zod 추가만으로 충분. + +- [ ] **Step 2: SettingsService schema 갱신** + +```ts +// src/main/services/SettingsService.ts (또는 schema 정의 위치) +export const SettingsSchema = z.object({ + // ... 기존 + ai_enabled: z.boolean().default(true), + onboarding_completed: z.boolean().default(false) +}); +export type Settings = z.infer; +``` + +기존 SettingsService 의 get/set 메서드 가 새 field 자동 인식. zod default 가 fallback. + +- [ ] **Step 3: failing test (CaptureService 분기)** + +```ts +// tests/unit/CaptureService.test.ts 추가 +describe('CaptureService — ai_enabled false', () => { + it('skips pending_jobs enqueue when ai_enabled=false', async () => { + const settings = makeMockSettings({ ai_enabled: false }); + const enqueue = vi.fn(async () => {}); + const svc = new CaptureService({ repo, settings, enqueue, /* 기타 deps */ }); + const r = await svc.create({ rawText: 'test' }); + expect(enqueue).not.toHaveBeenCalled(); + const note = repo.getById(r.id); + expect(note?.aiStatus).toBe('disabled'); + }); + + it('enqueues normally when ai_enabled=true (default)', async () => { + const settings = makeMockSettings({ ai_enabled: true }); + const enqueue = vi.fn(async () => {}); + const svc = new CaptureService({ repo, settings, enqueue }); + const r = await svc.create({ rawText: 'test' }); + expect(enqueue).toHaveBeenCalledWith(r.id); + }); +}); +``` + +- [ ] **Step 4: 테스트 실행 → fail** + +```bash +npx vitest run tests/unit/CaptureService.test.ts +``` + +- [ ] **Step 5: CaptureService 갱신** + +```ts +// src/main/services/CaptureService.ts +async create(input: { rawText: string; images?: ... }): Promise<{ id: string }> { + const aiEnabled = await this.deps.settings.get('ai_enabled', true); + const initialAiStatus = aiEnabled ? 'pending' : 'disabled'; + const note = this.deps.repo.insert({ ...input, aiStatus: initialAiStatus }); + if (aiEnabled) { + await this.deps.enqueue(note.id); + } + return { id: note.id }; +} +``` + +(deps 인터페이스에 `settings: SettingsService` 추가 — main process 가 inject. NoteRepository.insert 의 `aiStatus` 인자 가 신규 — repo 도 갱신.) + +NoteRepository.insert 의 INSERT 문 — `ai_status` 가 input 으로 받도록 갱신: + +```ts +insert(input: { ..., aiStatus?: AiStatus }): Note { + const ai_status = input.aiStatus ?? 'pending'; + this.db.prepare(`INSERT INTO notes (..., ai_status, ...) VALUES (..., ?, ...)`).run(..., ai_status, ...); +} +``` + +- [ ] **Step 6: 테스트 + typecheck + 회귀** + +```bash +npx vitest run +npm run typecheck +``` + +Expected: 신규 2 test + 기존 회귀 479 → 481. + +- [ ] **Step 7: commit** + +```bash +git add src/shared/types.ts src/main/services/SettingsService.ts src/main/services/CaptureService.ts src/main/repository/NoteRepository.ts tests/unit/CaptureService.test.ts +git commit -m "feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)" +``` + +--- + +## Phase 3: 4탭 UI 인프라 + +### Task 4: useInbox view enum + count fields per status + +**Files:** + +- Modify: `src/renderer/inbox/store.ts` +- Modify: `tests/unit/store.showSettings.test.ts` (또는 store.test.ts 신규) + +- [ ] **Step 1: failing test** + +```ts +// tests/unit/store.view.test.ts (신규) +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInbox } from '../../src/renderer/inbox/store'; + +describe('inbox store — view enum', () => { + beforeEach(() => { + useInbox.setState({ + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 } + }); + }); + + it('initial view is inbox', () => { + expect(useInbox.getState().view).toBe('inbox'); + }); + + it('setView changes view', () => { + useInbox.getState().setView('completed'); + expect(useInbox.getState().view).toBe('completed'); + }); + + it('counts initialized to zero per status', () => { + expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 }); + }); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: store.ts 갱신** + +기존 `showTrash: boolean` + `showSettings: boolean` 2개 boolean 을 `view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'` 로 통합: + +```ts +// src/renderer/inbox/store.ts +type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'; + +interface InboxState { + // ... 기존 + view: InboxView; + counts: { active: number; completed: number; archived: number; trashed: number }; + setView: (view: InboxView) => void; + // 기존 toggleShowTrash / showTrash / showSettings / setShowSettings — DEPRECATED. setView 로 통합. + // backward compat 위해 일시 잔류 가능 OR 즉시 제거 (caller 갱신 필요). +} + +// initial: +view: 'inbox', +counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, + +// action: +setView(view) { set({ view }); /* 필요 시 데이터 fetch */ }, +``` + +기존 `showTrash` / `showSettings` 와 `toggleShowTrash` / `setShowSettings` 호출부 모두 `view` enum 으로 갱신 — Task 5 에서 본격 처리. + +- [ ] **Step 4: 테스트 + typecheck** + +기존 `showTrash` 사용처가 broken — Task 5 에서 정리할 것이므로 잠시 broken state 받아들임. 또는 backward-compat 으로 `showTrash: get().view === 'trash'` computed 형태로 잠시 유지 가능. + +추천: backward-compat — store 안에 `get showTrash() { return this.view === 'trash'; }` 같은 getter 또는 selector 함수 추가. caller 들 점진적 갱신. + +```ts +// 임시 backward-compat (Task 5 머지 후 제거) +showTrash: false, // computed below +showSettings: false, +// store update 시 view 변경할 때 같이 갱신: +setView(view) { + set({ view, showTrash: view === 'trash', showSettings: view === 'settings' }); +} +``` + +- [ ] **Step 5: commit** + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.view.test.ts +git commit -m "feat(v029): inbox store view enum 4탭 + counts per status (backward-compat showTrash/showSettings)" +``` + +--- + +### Task 5: 헤더 4탭 + count badge + +**Files:** + +- Modify: `src/renderer/inbox/App.tsx` +- Modify: `tests/unit/App.test.tsx` + +- [ ] **Step 1: failing test** + +```tsx +// tests/unit/App.test.tsx — 추가 +describe('App header — 4 tabs', () => { + it('renders 4 tabs (Inbox / 완료 / 보관 / 휴지통)', () => { + render(); + expect(screen.getByRole('button', { name: /Inbox/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^완료/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^보관/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^휴지통/ })).toBeInTheDocument(); + }); + + it('clicking 완료 tab sets view=completed', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /^완료/ })); + expect(useInbox.getState().view).toBe('completed'); + }); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: App.tsx 갱신 — 4탭** + +기존 2탭 (Inbox / 휴지통) → 4탭. 기존 tabBtnStyle 유지. + +```tsx +const view = useInbox(s => s.view); +const setView = useInbox(s => s.setView); +const counts = useInbox(s => s.counts); + +const tabs: Array<{ key: InboxView; label: string; count: number }> = [ + { key: 'inbox', label: 'Inbox', count: counts.active }, + { key: 'completed', label: '완료', count: counts.completed }, + { key: 'archived', label: '보관', count: counts.archived }, + { key: 'trash', label: '휴지통', count: counts.trashed } +]; + +// 헤더 안: +
+ {tabs.map(t => ( + + ))} +
+``` + +view='settings' 분기는 기존대로 유지. notes list rendering 시 `useInbox(s => s.notes)` 가 current view 에 맞는 노트 (active / completed / archived / trash) 자동 반환되도록 store action 갱신 — `loadInitial` / `setView` 시 해당 status fetch. + +- [ ] **Step 4: store loadInitial + setView 갱신** + +```ts +// store.ts — setView 가 해당 view 의 notes fetch +setView(view) { + set({ view, showTrash: view === 'trash', showSettings: view === 'settings' }); + if (view !== 'settings' && view !== 'inbox') { + void get().loadByView(view); // 새 helper + } else if (view === 'inbox') { + void get().loadInitial(); + } +} + +async loadByView(view: 'completed' | 'archived' | 'trash'): Promise { + const status = view === 'trash' ? 'trashed' : view; + const notes = await inboxApi.listByStatus(status, { limit: 200 }); + set({ notes }); // 또는 별도 cache map per status +} +``` + +(IPC `inbox:list-by-status` 도 신규 — Task 6 또는 본 task 에서 추가 가능. 본 task 에서 함께.) + +- [ ] **Step 5: IPC 핸들러 추가** + +```ts +// src/main/ipc/inboxApi.ts +ipcMain.handle('inbox:list-by-status', async (_e, status: NoteStatus, opts: { limit?: number }) => { + return repo.listByStatus(status, opts); +}); +``` + +api.ts wrapper + preload + types.ts InboxApi 갱신: + +```ts +listByStatus(status: NoteStatus, opts: { limit?: number }): Promise; +``` + +- [ ] **Step 6: 테스트 + typecheck + 회귀** + +```bash +npx vitest run +npm run typecheck +``` + +Expected: 회귀 481 → 484 (+3). + +- [ ] **Step 7: commit** + +```bash +git add src/renderer/inbox/App.tsx src/renderer/inbox/store.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/renderer/inbox/api.ts tests/unit/App.test.tsx +git commit -m "feat(v029): 헤더 4탭 (Inbox/완료/보관/휴지통) + listByStatus IPC" +``` + +--- + +## Phase 4: 사유 입력 modal + +### Task 6: NoteCard 액션 메뉴 (이동 버튼) + +**Files:** + +- Modify: `src/renderer/inbox/components/NoteCard.tsx` +- Modify: `tests/unit/NoteCard.test.tsx` + +- [ ] **Step 1: failing test** + +```tsx +it('renders move action menu (완료 / 보관 / 휴지통)', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /이동/ })); + expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '보관함으로 이동' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument(); +}); + +it('clicking "완료로 이동" opens MoveStatusModal with status preset', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /이동/ })); + fireEvent.click(screen.getByRole('button', { name: '완료로 이동' })); + expect(screen.getByRole('dialog', { name: /이동/ })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: NoteCard 갱신** + +기존 휴지통 버튼 1개 → 메뉴 (dropdown 또는 inline group): + +```tsx +import { useState } from 'react'; +import { MoveStatusModal } from './MoveStatusModal'; + +// NoteCard 안: +const [moveTarget, setMoveTarget] = useState(null); +const [menuOpen, setMenuOpen] = useState(false); + +// 기존 휴지통 버튼 자리 — view='inbox' 이면: +{mode === 'inbox' && ( +
+ + {menuOpen && ( +
+ + + +
+ )} +
+)} + +{moveTarget && ( + setMoveTarget(null)} + onMoved={(newStatus, reason) => { + onUpdated({ ...local, status: newStatus, moveReason: reason }); + setMoveTarget(null); + }} + /> +)} +``` + +mode='completed'/'archived' 등 다른 view 의 NoteCard 도 비슷한 메뉴 (예: 보관에서 active 로 복귀, 휴지통에서 restore). MoveStatusModal 의 inverse 동작 — 같은 컴포넌트 활용. + +- [ ] **Step 4: 테스트 + typecheck** + +MoveStatusModal 미존재라 import error — Task 7 에서 작성. 임시로 stub component 또는 skip 검사. + +```tsx +// 임시 stub — Task 7 까지 jsdom 에서 안 깨지도록: +function MoveStatusModal(props: any) { return
stub
; } +``` + +또는 MoveStatusModal 을 Task 7 에서 작성 후 NoteCard import 갱신 — 한 PR 안 두 commit 분리. + +추천: Task 6 안 NoteCard 갱신 + 임시 inline stub MoveStatusModal — Task 7 에서 별 파일로 추출 + 정식 구현. + +- [ ] **Step 5: commit** + +```bash +git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx +git commit -m "feat(v029): NoteCard 이동 메뉴 (완료/보관/휴지통) + MoveStatusModal stub" +``` + +--- + +### Task 7: MoveStatusModal — 사유 입력 + 4 status 버튼 + +**Files:** + +- Create: `src/renderer/inbox/components/MoveStatusModal.tsx` +- Create: `tests/unit/MoveStatusModal.test.tsx` +- Modify: `src/renderer/inbox/components/NoteCard.tsx` (stub → import) + +- [ ] **Step 1: failing test** + +```tsx +// tests/unit/MoveStatusModal.test.tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +const mockSetStatus = vi.fn(async () => ({ ok: true })); +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + setStatus: mockSetStatus, + classifyStatus: vi.fn(async () => ({ recommended: 'completed', rationale: '결재 끝' })) + } +})); + +import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal'; + +describe('MoveStatusModal', () => { + beforeEach(() => { vi.clearAllMocks(); cleanup(); }); + + it('renders reason textarea + 4 status buttons + AI classify button', () => { + render( + + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^완료$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^보관$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^휴지통$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument(); + }); + + it('clicking 완료 calls setStatus with reason', async () => { + const onMoved = vi.fn(); + render( + + ); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } }); + fireEvent.click(screen.getByRole('button', { name: /^완료$/ })); + await vi.waitFor(() => { + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝'); + expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'); + }); + }); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: MoveStatusModal.tsx 작성** + +```tsx +// src/renderer/inbox/components/MoveStatusModal.tsx +import React, { useState } from 'react'; +import { inboxApi } from '../api.js'; +import type { NoteStatus } from '@shared/types'; + +interface Props { + noteId: string; + rawText: string; + summary: string; + initialTarget: NoteStatus; + onClose: () => void; + onMoved: (status: NoteStatus, reason: string | null) => void; +} + +export function MoveStatusModal({ noteId, rawText, summary, initialTarget, onClose, onMoved }: Props): React.ReactElement { + const [reason, setReason] = useState(''); + const [recommendation, setRecommendation] = useState<{ status: NoteStatus; rationale: string } | null>(null); + const [classifying, setClassifying] = useState(false); + + async function move(status: NoteStatus): Promise { + const trimmedReason = reason.trim() || null; + await inboxApi.setStatus(noteId, status, trimmedReason); + onMoved(status, trimmedReason); + } + + async function classify(): Promise { + setClassifying(true); + setRecommendation(null); + const r = await inboxApi.classifyStatus(noteId, reason); + setRecommendation({ status: r.recommended, rationale: r.rationale }); + setClassifying(false); + } + + return ( +
+
+

메모 이동

+