From dbc0acbaf572793c260e72a2c757ecc0506a1802 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 14 May 2026 17:56:16 +0900 Subject: [PATCH] docs(plan): v0.4 notebooks + lifecycle implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 task — m008 마이그레이션 → NotebookRepository → AI prompt + schema + AiWorker 매칭 → store (notebooks + promotionCandidate) → Sidebar / NotebookList / NotebookCreateModal / PromotionBanner → App 통합 (Cmd+B, 헤더 3탭, archived 제거) → search scope → CHANGELOG v0.4.0. 각 task TDD step (실패 test → 구현 → 통과 → commit). 모든 step 에 실제 code block 포함, placeholder 없음. sync (Cut E) 와의 frontmatter notebook 통합은 본 plan 에서 deferred — v0.4 본체 머지 후 별도 작업. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-14-v04-notebooks-lifecycle.md | 2168 +++++++++++++++++ 1 file changed, 2168 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md diff --git a/docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md b/docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md new file mode 100644 index 0000000..0a848cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md @@ -0,0 +1,2168 @@ +# v0.4 Notebooks + Lifecycle Simplification Implementation Plan + +> **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:** notebook 카테고리 도입 + lifecycle 3분기 단순화 + AI 의 자동 fit 매칭 / promotion 제안 구현. + +**Architecture:** 단일 SQLite DB 안에 `notebooks` 테이블 + `notes.notebook_id` 컬럼 추가. `archived` status 제거 (마이그레이션 시 completed 로 합침). 매 capture 시 AI 가 notebooks 목록을 보고 best-fit 자동 배치, 새 notebook 생성은 rule-based promotion (tag 3건 이상 누적) 으로만 제안. + +**Tech Stack:** TypeScript / better-sqlite3 / Zod / electron-vite / React / vitest + +선행 spec: `docs/superpowers/specs/2026-05-14-v04-notebooks-lifecycle-design.md` + +--- + +## File Structure + +### Create +- `src/main/db/migrations/m008_notebooks.ts` — schema 변경 + default notebook + archived 정리 +- `src/main/repository/NotebookRepository.ts` — CRUD + count + RESTRICT delete + promotion query helper +- `src/main/ipc/notebookApi.ts` — IPC 핸들러 +- `src/renderer/inbox/components/Sidebar.tsx` — 좌측 패널 root +- `src/renderer/inbox/components/NotebookList.tsx` — notebook 목록 + count badge +- `src/renderer/inbox/components/NotebookCreateModal.tsx` — name + color picker +- `src/renderer/inbox/components/PromotionBanner.tsx` — Inbox 상단 banner +- `tests/unit/m008.test.ts` +- `tests/unit/NotebookRepository.test.ts` +- `tests/unit/notebookApi.test.ts` +- `tests/unit/Sidebar.test.tsx` +- `tests/unit/NotebookList.test.tsx` +- `tests/unit/PromotionBanner.test.tsx` +- `tests/unit/store.notebook.test.ts` + +### Modify +- `src/main/db/migrations/index.ts` — m008 등록 +- `src/main/repository/NoteRepository.ts` — notebook_id 필터, findPromotionCandidates, hydrate 갱신 +- `src/main/ipc/inboxApi.ts` — list/search/counts/list-by-status 에 notebookId 옵션 +- `src/main/ai/prompt.ts` — buildPrompt 에 notebooks 목록 주입 +- `src/main/ai/schema.ts` — RawResponseSchema 에 `notebook_match` 필드, parseAiResponse 에 notebookMatch 반환 +- `src/main/ai/AiWorker.ts` — settings.getDefaultNotebookId / matchNotebook 처리 +- `src/main/ai/InferenceProvider.ts` — GenerateInput 에 notebooks 추가 +- `src/main/ai/LocalOllamaProvider.ts` — generate 가 notebooks input 전달 +- `src/main/services/SettingsService.ts` — sidebar_visible / sidebar_width / promotion_dismissed_tags / promotion_snoozed_until_ms +- `src/shared/types.ts` — Notebook type, NoteStatus 에서 archived 제거, InboxApi/SettingsApi 인터페이스 확장 +- `src/preload/index.ts` — notebookApi exposure +- `src/renderer/inbox/store.ts` — notebooks state + actions + promotionCandidate +- `src/renderer/inbox/api.ts` — notebookApi facade +- `src/renderer/inbox/App.tsx` — Sidebar 통합, 헤더 3탭, Cmd+B 단축키 +- `src/renderer/inbox/components/MoveStatusModal.tsx` — archived 옵션 제거 +- `src/renderer/inbox/components/NoteCard.tsx` — notebook chip + 1-click move +- `src/main/index.ts` — NotebookRepository / notebookApi 등록 +- `CHANGELOG.md` + +--- + +## Task 1: m008 마이그레이션 — schema + default notebook + archived 정리 + +**Files:** +- Create: `src/main/db/migrations/m008_notebooks.ts` +- Modify: `src/main/db/migrations/index.ts` +- Test: `tests/unit/m008.test.ts` + +- [ ] **Step 1: 마이그레이션 파일 신설** + +`src/main/db/migrations/m008_notebooks.ts`: + +```typescript +// v8: notebooks 테이블 + notes.notebook_id (FK) + archived → completed 정리. +// CHECK 제약 없는 status 컬럼이라 SQL 변경은 데이터 정리만, enum 단속은 TypeScript 측에서. +import type Database from 'better-sqlite3'; +import { v7 as uuidv7 } from 'uuid'; + +export const version = 8; + +const DEFAULT_NOTEBOOK_NAME = '기본'; + +export function up(db: Database.Database): void { + const now = new Date().toISOString(); + const defaultId = uuidv7(); + + db.exec(` + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX idx_notebooks_name ON notebooks(name); + + ALTER TABLE notes ADD COLUMN notebook_id TEXT + REFERENCES notebooks(id) ON DELETE RESTRICT; + CREATE INDEX idx_notes_notebook_id ON notes(notebook_id); + `); + + db.prepare( + `INSERT INTO notebooks (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run(defaultId, DEFAULT_NOTEBOOK_NAME, now, now); + + db.prepare(`UPDATE notes SET notebook_id = ? WHERE notebook_id IS NULL`).run(defaultId); + + // archived 잔류 (dogfood 0건 확인됐지만 defensive) → completed 로 통합. + db.prepare(`UPDATE notes SET status='completed' WHERE status='archived'`).run(); +} +``` + +- [ ] **Step 2: index.ts 에 m008 등록** + +`src/main/db/migrations/index.ts` — import 추가 + migrations 배열 끝에 m008 추가: + +```typescript +import * as m008 from './m008_notebooks.js'; +// ... +const migrations = [m001, m002, m003, m004, m005, m006, m007, m008]; +``` + +- [ ] **Step 3: 실패하는 test 작성** + +`tests/unit/m008.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; + +describe('m008 notebooks migration', () => { + let db: Database.Database; + beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); }); + afterEach(() => { db.close(); }); + + it('fresh DB: notebooks 테이블 + default "기본" notebook 생성', () => { + runMigrations(db); + const row = db.prepare(`SELECT name FROM notebooks`).get() as { name: string }; + expect(row.name).toBe('기본'); + }); + + it('기존 notes 가 default notebook 으로 마이그레이션', () => { + runMigrations(db); + const defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('n1','t','pending','2026-05-14','2026-05-14','active')`).run(); + // notebook_id 가 NOT NULL 직접 제약은 아니지만, m008 의 UPDATE 가 NULL 을 모두 채움 — 신규 insert 는 명시적으로 채워야 함을 테스트하려면 NotebookRepository task 에서. + const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string | null }; + // fresh insert 에는 default 가 자동 들어가지 않으므로 NULL — 사후 UPDATE 책임은 NoteRepository.create 가짐 (Task 5). + expect(r.notebook_id).toBeNull(); + }); + + it('archived 잔류 노트가 있다면 completed 로 통합', () => { + // m007 까지 적용된 상태에서 archived 노트 삽입 후 m008 적용 검증 — runMigrations 가 모든 미적용 마이그레이션을 한 번에 돌리므로, 본 테스트는 m008 만 분리 적용 패턴 사용. + // 간단히 fresh DB 에서 검증 — archived 통합 자체는 노트가 없어도 SQL 자체가 통과해야 함. + runMigrations(db); + expect(() => db.prepare(`SELECT COUNT(*) FROM notes WHERE status='archived'`).get()).not.toThrow(); + }); + + it('UNIQUE index 가 같은 이름 중복 INSERT 거부', () => { + runMigrations(db); + expect(() => + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('x','기본','t','t')`).run() + ).toThrow(); + }); +}); +``` + +- [ ] **Step 4: 테스트 실패 확인** + +Run: `npx vitest run tests/unit/m008.test.ts` +Expected: FAIL (m008 import 못 찾음 또는 migrations 배열에 없음) + +- [ ] **Step 5: Step 1, 2 의 코드가 실제 파일에 들어가 있는지 검증 후 재실행** + +Run: `npx vitest run tests/unit/m008.test.ts` +Expected: 4 passed + +- [ ] **Step 6: commit** + +```bash +git add src/main/db/migrations/m008_notebooks.ts src/main/db/migrations/index.ts tests/unit/m008.test.ts +git commit -m "feat(db): m008 — notebooks 테이블 + notes.notebook_id + archived 정리" +``` + +--- + +## Task 2: NotebookRepository — type 정의 + CRUD + count + +**Files:** +- Modify: `src/shared/types.ts` +- Create: `src/main/repository/NotebookRepository.ts` +- Test: `tests/unit/NotebookRepository.test.ts` + +- [ ] **Step 1: Notebook type 추가 (shared/types.ts)** + +`src/shared/types.ts` 의 적절한 위치 (Note 인터페이스 근처) 에 추가: + +```typescript +export interface Notebook { + id: string; + name: string; + color: string | null; + createdAt: string; + updatedAt: string; + noteCount: number; // status='active' 노트만 카운트 +} +``` + +같은 파일의 `NoteStatus` 정의에서 `'archived'` 제거: + +```typescript +// 변경 전 (참조용): +// export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; +// 변경 후: +export type NoteStatus = 'active' | 'completed' | 'trashed'; +``` + +- [ ] **Step 2: Note 인터페이스에 notebookId 추가** + +`src/shared/types.ts` 의 `Note` 인터페이스에 필드 추가: + +```typescript +export interface Note { + // ... 기존 필드 ... + notebookId: string; // m008 마이그레이션 보장 — NOT NULL 동치 +} +``` + +- [ ] **Step 3: 실패하는 test 작성** + +`tests/unit/NotebookRepository.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NotebookRepository } from '../../src/main/repository/NotebookRepository.js'; + +describe('NotebookRepository', () => { + let db: Database.Database; + let repo: NotebookRepository; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NotebookRepository(db); + }); + afterEach(() => { db.close(); }); + + it('list: 기본 notebook 1개 + noteCount 0', () => { + const all = repo.list(); + expect(all).toHaveLength(1); + expect(all[0]!.name).toBe('기본'); + expect(all[0]!.noteCount).toBe(0); + }); + + it('create: 새 notebook 추가', () => { + const nb = repo.create({ name: '회사', color: '#0a4b80' }); + expect(nb.name).toBe('회사'); + expect(nb.color).toBe('#0a4b80'); + expect(repo.list()).toHaveLength(2); + }); + + it('create: 같은 이름 두 번이면 throw', () => { + repo.create({ name: '회사' }); + expect(() => repo.create({ name: '회사' })).toThrow(); + }); + + it('rename: 이름 변경', () => { + const nb = repo.create({ name: '회사' }); + repo.rename(nb.id, '워크'); + const after = repo.findById(nb.id); + expect(after?.name).toBe('워크'); + }); + + it('delete: 메모 없으면 OK', () => { + const nb = repo.create({ name: '회사' }); + const r = repo.delete(nb.id); + expect(r.ok).toBe(true); + expect(repo.findById(nb.id)).toBeNull(); + }); + + it('delete: 메모 있으면 RESTRICT — ok:false', () => { + const nb = repo.create({ name: '회사' }); + const ts = '2026-05-14T00:00:00Z'; + db.prepare( + `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id) + VALUES('n1','t','pending',?,?,'active',?)` + ).run(ts, ts, nb.id); + const r = repo.delete(nb.id); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe('has_notes'); + }); + + it('noteCount: status="active" 만 카운트 (completed/trashed 제외)', () => { + const nb = repo.create({ name: '회사' }); + const ts = '2026-05-14T00:00:00Z'; + const insert = db.prepare( + `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id) + VALUES(?,?,?,?,?,?,?)` + ); + insert.run('n1','t','done',ts,ts,'active',nb.id); + insert.run('n2','t','done',ts,ts,'completed',nb.id); + insert.run('n3','t','done',ts,ts,'trashed',nb.id); + const found = repo.findById(nb.id); + expect(found?.noteCount).toBe(1); + }); +}); +``` + +- [ ] **Step 4: NotebookRepository 구현** + +`src/main/repository/NotebookRepository.ts`: + +```typescript +import type Database from 'better-sqlite3'; +import { v7 as uuidv7 } from 'uuid'; +import type { Notebook } from '@shared/types'; + +export class NotebookRepository { + constructor(private db: Database.Database) {} + + list(): Notebook[] { + const rows = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n + WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb + ORDER BY nb.name ASC` + ).all() as Array>; + return rows.map((r) => this.hydrate(r)); + } + + findById(id: string): Notebook | null { + const r = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n + WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb WHERE nb.id = ?` + ).get(id) as Record | undefined; + return r ? this.hydrate(r) : null; + } + + /** UNIQUE name violation 시 better-sqlite3 가 throw — caller 가 ok:false 변환. */ + create(input: { name: string; color?: string | null }): Notebook { + const id = uuidv7(); + const now = new Date().toISOString(); + this.db.prepare( + `INSERT INTO notebooks(id, name, color, created_at, updated_at) VALUES(?,?,?,?,?)` + ).run(id, input.name, input.color ?? null, now, now); + return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 }; + } + + rename(id: string, name: string): void { + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id); + } + + setColor(id: string, color: string | null): void { + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notebooks SET color=?, updated_at=? WHERE id=?`).run(color, now, id); + } + + /** FK RESTRICT 가 메모 잔류 시 throw — ok:false 로 변환. */ + delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' | 'not_found' } { + const exists = this.db.prepare(`SELECT 1 FROM notebooks WHERE id=?`).get(id); + if (!exists) return { ok: false, reason: 'not_found' }; + try { + this.db.prepare(`DELETE FROM notebooks WHERE id=?`).run(id); + return { ok: true }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('FOREIGN KEY')) return { ok: false, reason: 'has_notes' }; + throw e; + } + } + + /** notes.notebook_id 갱신만 (status 등은 보존). */ + moveNote(noteId: string, notebookId: string): void { + this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`) + .run(notebookId, new Date().toISOString(), noteId); + } + + private hydrate(r: Record): Notebook { + return { + id: r.id as string, + name: r.name as string, + color: (r.color as string | null) ?? null, + createdAt: r.created_at as string, + updatedAt: r.updated_at as string, + noteCount: r.note_count as number + }; + } +} +``` + +- [ ] **Step 5: 테스트 실패 확인 → 통과 확인** + +```bash +npx vitest run tests/unit/NotebookRepository.test.ts +``` + +Expected: 7 passed + +- [ ] **Step 6: commit** + +```bash +git add src/main/repository/NotebookRepository.ts src/shared/types.ts tests/unit/NotebookRepository.test.ts +git commit -m "feat(notebook): NotebookRepository CRUD + noteCount + RESTRICT delete" +``` + +--- + +## Task 3: NoteRepository 변경 — notebook_id hydrate + 생성 시 default 보장 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패하는 test 추가 (NoteRepository.test.ts 의 적절한 describe 안)** + +`tests/unit/NoteRepository.test.ts` 끝에 새 describe 추가: + +```typescript +describe('NoteRepository.create with notebook', () => { + let db: Database.Database; + let repo: NoteRepository; + let defaultId: string; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + }); + + it('notebook_id 미지정 시 default notebook 으로 들어감', () => { + const { id } = repo.create({ rawText: 'hello' }); + const r = repo.findById(id); + expect(r?.notebookId).toBe(defaultId); + }); + + it('notebook_id 지정 시 그 값 보존', () => { + const insert = db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`); + insert.run('nb-other', '회사', '2026-05-14', '2026-05-14'); + const { id } = repo.create({ rawText: 'hi', notebookId: 'nb-other' }); + expect(repo.findById(id)?.notebookId).toBe('nb-other'); + }); +}); +``` + +- [ ] **Step 2: `CreateNoteInput` 에 notebookId 추가 + create 구현** + +`src/main/repository/NoteRepository.ts` — `CreateNoteInput`: + +```typescript +export interface CreateNoteInput { + rawText: string; + aiStatus?: AiStatus; + notebookId?: string; // 미지정 시 default notebook +} +``` + +`create()` 메서드에 notebook_id 채우는 로직 추가. 구현: + +```typescript +create(input: CreateNoteInput, now: Date = new Date()): { id: string } { + const id = uuidv7(); + const ts = now.toISOString(); + const aiStatus: AiStatus = input.aiStatus ?? 'pending'; + const notebookId = input.notebookId ?? this.getDefaultNotebookId(); + const tx = this.db.transaction(() => { + this.db + .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, notebook_id) + VALUES (?, ?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, ts, ts, notebookId); + this.db + .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')`) + .run(id, input.rawText, ts); + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, ts); + } + }); + tx(); + return { id }; +} + +/** 가장 오래된 notebook 을 default 로 — m008 가 보장. cache 안 함 (per-write 1 query). */ +private getDefaultNotebookId(): string { + const r = this.db.prepare(`SELECT id FROM notebooks ORDER BY created_at ASC LIMIT 1`).get() as { id: string }; + return r.id; +} +``` + +- [ ] **Step 3: hydrate 에 notebookId 추가** + +`NoteRepository.hydrate` (Note 객체 생성하는 메서드) 에 1 줄: + +```typescript +notebookId: row.notebook_id as string, +``` + +각 SELECT 쿼리도 `notebook_id` 가 row 에 포함되도록 확인 (`SELECT *` 라면 자동 포함). + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/NoteRepository.test.ts +``` + +Expected: all passed (기존 + 신규 2건) + +- [ ] **Step 5: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(notes): notebook_id 필드 + create 시 default notebook 보장" +``` + +--- + +## Task 4: NoteRepository 의 list/search/counts 에 notebookId 옵션 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패하는 test 추가** + +`tests/unit/NoteRepository.test.ts`: + +```typescript +describe('NoteRepository.list / countByStatus with notebookId', () => { + let db: Database.Database; + let repo: NoteRepository; + let nbA: string, nbB: string; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + nbA = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + nbB = 'nb-b'; + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`).run(nbB,'회사','t','t'); + }); + + it('list 가 notebookId 필터로 노트 분리', () => { + repo.create({ rawText: 'in-default' }); + repo.create({ rawText: 'in-B', notebookId: nbB }); + expect(repo.list({ limit: 10, notebookId: nbA }).map((n) => n.rawText)).toEqual(['in-default']); + expect(repo.list({ limit: 10, notebookId: nbB }).map((n) => n.rawText)).toEqual(['in-B']); + }); + + it('countByStatus(notebookId) — 각 notebook 의 active 갯수', () => { + repo.create({ rawText: 'a1' }); + repo.create({ rawText: 'a2' }); + repo.create({ rawText: 'b1', notebookId: nbB }); + expect(repo.countByStatus('active', { notebookId: nbA })).toBe(2); + expect(repo.countByStatus('active', { notebookId: nbB })).toBe(1); + }); +}); +``` + +- [ ] **Step 2: list / listByStatus / countByStatus signature 확장** + +`NoteRepository.list`: + +```typescript +list(opts: { limit: number; cursor?: string; notebookId?: string }): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit)); + const params: unknown[] = []; + let sql = `SELECT * FROM notes WHERE deleted_at IS NULL`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + if (opts.cursor) { sql += ` AND created_at < ?`; params.push(opts.cursor); } + sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as Record[]; + return rows.map((r) => this.hydrate(r)); +} +``` + +`listByStatus`: + +```typescript +listByStatus(status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + const params: unknown[] = [status]; + let sql = `SELECT * FROM notes WHERE status = ?`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as Record[]; + return rows.map((r) => this.hydrate(r)); +} +``` + +`countByStatus` 도 signature 확장: + +```typescript +countByStatus(status: NoteStatus, opts: { notebookId?: string } = {}): number { + const params: unknown[] = [status]; + let sql = `SELECT COUNT(*) as c FROM notes WHERE status = ?`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + const r = this.db.prepare(sql).get(...params) as { c: number }; + return r.c; +} +``` + +같은 패턴으로 `search` 도 옵션 확장 — `search(query, opts: { limit?, status?, notebookId? })`: + +```typescript +search(query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}): Note[] { + // 기존 본문 + WHERE 절에 notebookId 추가 한 줄 + // ... (기존 sanitizeFtsQuery 등 보존) ... + if (opts.notebookId) { sql += ` AND n.notebook_id = ?`; params.push(opts.notebookId); } + // ... +} +``` + +- [ ] **Step 3: 테스트 통과 확인 + 기존 호출자 회귀 점검** + +```bash +npx vitest run tests/unit/NoteRepository.test.ts +``` + +Expected: all passed. 기존 호출자 (notebookId 미지정) 가 회귀 없음 확인. + +- [ ] **Step 4: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(notes): list/listByStatus/countByStatus/search 에 notebookId 옵션" +``` + +--- + +## Task 5: NoteRepository.findPromotionCandidates — tag 3건 이상 default notebook 클러스터 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패하는 test 추가** + +```typescript +describe('NoteRepository.findPromotionCandidates', () => { + let db: Database.Database; + let repo: NoteRepository; + let defaultId: string; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + }); + + function insertWithTag(rawText: string, tagName: string): string { + const { id } = repo.create({ rawText }); + repo.updateAiResult(id, { title: rawText, summary: 'a\nb\nc', tags: [tagName], provider: 'test', dueDate: null }); + return id; + } + + it('threshold 미만: 빈 결과', () => { + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); + + it('threshold 도달: tag 와 noteIds 반환', () => { + const a = insertWithTag('n1', 'mlx-ops'); + const b = insertWithTag('n2', 'mlx-ops'); + const c = insertWithTag('n3', 'mlx-ops'); + const r = repo.findPromotionCandidates(defaultId); + expect(r).toHaveLength(1); + expect(r[0]!.tag).toBe('mlx-ops'); + expect(r[0]!.noteIds.sort()).toEqual([a, b, c].sort()); + }); + + it('default 가 아닌 notebook 의 노트는 제외', () => { + const a = insertWithTag('n1', 'mlx-ops'); + const b = insertWithTag('n2', 'mlx-ops'); + const c = insertWithTag('n3', 'mlx-ops'); + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','t','t')`).run(); + db.prepare(`UPDATE notes SET notebook_id='nb-x' WHERE id=?`).run(c); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); + + it('trashed/completed 제외 — active 만', () => { + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + const c = insertWithTag('n3', 'mlx-ops'); + repo.setStatus(c, 'completed', null); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: findPromotionCandidates 메서드 추가** + +`NoteRepository.ts`: + +```typescript +/** + * default notebook 안 active 노트들 중 동일 tag 가 3건 이상 모인 클러스터 검출. + * PromotionBanner 가 사용 — 각 결과는 {tag, noteIds}. + */ +findPromotionCandidates( + defaultNotebookId: string, + threshold: number = 3 +): Array<{ tag: string; noteIds: string[] }> { + const rows = this.db.prepare( + `SELECT t.name AS tag, GROUP_CONCAT(n.id) AS ids, COUNT(DISTINCT n.id) AS cnt + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + JOIN notes n ON n.id = nt.note_id + WHERE n.status = 'active' + AND n.notebook_id = ? + GROUP BY t.id + HAVING cnt >= ?` + ).all(defaultNotebookId, threshold) as Array<{ tag: string; ids: string; cnt: number }>; + return rows.map((r) => ({ tag: r.tag, noteIds: r.ids.split(',') })); +} +``` + +- [ ] **Step 3: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/NoteRepository.test.ts +``` + +Expected: all passed (신규 4건 포함) + +- [ ] **Step 4: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(notes): findPromotionCandidates — tag 3건 default notebook 클러스터" +``` + +--- + +## Task 6: notebookApi IPC + preload exposure + +**Files:** +- Create: `src/main/ipc/notebookApi.ts` +- Modify: `src/main/index.ts`, `src/shared/types.ts`, `src/preload/index.ts` +- Test: `tests/unit/notebookApi.test.ts` + +- [ ] **Step 1: shared/types.ts 에 NotebookApi 추가** + +`src/shared/types.ts`: + +```typescript +export interface NotebookApi { + list(): Promise; + create(input: { name: string; color?: string }): Promise<{ ok: true; notebook: Notebook } | { ok: false; reason: string }>; + rename(id: string, name: string): Promise<{ ok: true } | { ok: false; reason: string }>; + setColor(id: string, color: string | null): Promise<{ ok: true }>; + delete(id: string): Promise<{ ok: true } | { ok: false; reason: 'has_notes' | 'not_found' }>; + moveNote(noteId: string, notebookId: string): Promise<{ ok: true }>; +} +``` + +- [ ] **Step 2: 실패하는 test 작성** + +`tests/unit/notebookApi.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ + default: { ipcMain: { handle: vi.fn(), on: vi.fn() } } +})); + +import electron from 'electron'; +import { registerNotebookApi } from '../../src/main/ipc/notebookApi.js'; + +function getHandler(channel: string): (...args: unknown[]) => unknown { + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const call = handle.mock.calls.find((c) => c[0] === channel); + if (!call) throw new Error(`channel ${channel} not registered`); + return call[1] as (...args: unknown[]) => unknown; +} + +function makeRepo(): { list: ReturnType; create: ReturnType; rename: ReturnType; setColor: ReturnType; delete: ReturnType; moveNote: ReturnType; findById: ReturnType } { + return { + list: vi.fn(() => []), + create: vi.fn(), + rename: vi.fn(), + setColor: vi.fn(), + delete: vi.fn(() => ({ ok: true })), + moveNote: vi.fn(), + findById: vi.fn(() => null) + }; +} + +describe('notebookApi', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + }); + + it('notebook:list — repo.list 결과 반환', async () => { + const repo = makeRepo(); + repo.list.mockReturnValue([{ id: '1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }]); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:list'); + const r = await h({}); + expect(r).toHaveLength(1); + }); + + it('notebook:create — UNIQUE 위반 시 ok:false', async () => { + const repo = makeRepo(); + repo.create.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); }); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:create'); + const r = await h({}, { name: '회사' }); + expect((r as { ok: boolean }).ok).toBe(false); + }); + + it('notebook:delete — has_notes 시 ok:false reason 그대로 전달', async () => { + const repo = makeRepo(); + repo.delete.mockReturnValue({ ok: false, reason: 'has_notes' }); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:delete'); + const r = await h({}, 'id1'); + expect(r).toEqual({ ok: false, reason: 'has_notes' }); + }); +}); +``` + +- [ ] **Step 3: notebookApi 구현** + +`src/main/ipc/notebookApi.ts`: + +```typescript +import electron from 'electron'; +const { ipcMain } = electron; +import type { NotebookRepository } from '../repository/NotebookRepository.js'; + +export interface NotebookIpcDeps { + repo: NotebookRepository; +} + +export function registerNotebookApi(deps: NotebookIpcDeps): void { + ipcMain.handle('notebook:list', () => deps.repo.list()); + + ipcMain.handle('notebook:create', (_e, input: { name: string; color?: string }) => { + if (!input.name || input.name.trim().length === 0) { + return { ok: false as const, reason: 'empty_name' }; + } + try { + const nb = deps.repo.create({ name: input.name.trim(), color: input.color ?? null }); + return { ok: true as const, notebook: nb }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' }; + return { ok: false as const, reason: msg }; + } + }); + + ipcMain.handle('notebook:rename', (_e, id: string, name: string) => { + if (!name || name.trim().length === 0) { + return { ok: false as const, reason: 'empty_name' }; + } + try { + deps.repo.rename(id, name.trim()); + return { ok: true as const }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' }; + return { ok: false as const, reason: msg }; + } + }); + + ipcMain.handle('notebook:set-color', (_e, id: string, color: string | null) => { + deps.repo.setColor(id, color); + return { ok: true as const }; + }); + + ipcMain.handle('notebook:delete', (_e, id: string) => deps.repo.delete(id)); + + ipcMain.handle('notebook:move-note', (_e, noteId: string, notebookId: string) => { + deps.repo.moveNote(noteId, notebookId); + return { ok: true as const }; + }); +} +``` + +- [ ] **Step 4: preload 에 노출** + +`src/preload/index.ts` — 기존 inboxApi exposure 옆에 추가: + +```typescript +notebookApi: { + list: () => ipcRenderer.invoke('notebook:list'), + create: (input: { name: string; color?: string }) => ipcRenderer.invoke('notebook:create', input), + rename: (id: string, name: string) => ipcRenderer.invoke('notebook:rename', id, name), + setColor: (id: string, color: string | null) => ipcRenderer.invoke('notebook:set-color', id, color), + delete: (id: string) => ipcRenderer.invoke('notebook:delete', id), + moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId) +} +``` + +`contextBridge.exposeInMainWorld` 호출 안에 포함되도록 위치 잡기. + +- [ ] **Step 5: main/index.ts 에 registerNotebookApi 호출** + +`src/main/index.ts` — repo 초기화 직후: + +```typescript +import { NotebookRepository } from './repository/NotebookRepository.js'; +import { registerNotebookApi } from './ipc/notebookApi.js'; +// ... +const notebookRepo = new NotebookRepository(db); +registerNotebookApi({ repo: notebookRepo }); +``` + +- [ ] **Step 6: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/notebookApi.test.ts +``` + +Expected: 3 passed + +- [ ] **Step 7: commit** + +```bash +git add src/main/ipc/notebookApi.ts src/main/index.ts src/preload/index.ts src/shared/types.ts tests/unit/notebookApi.test.ts +git commit -m "feat(ipc): notebookApi — list/create/rename/setColor/delete/moveNote" +``` + +--- + +## Task 7: inboxApi 의 list/listByStatus/countsByStatus/search 에 notebookId 옵션 + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts`, `src/shared/types.ts`, `src/preload/index.ts`, `src/renderer/inbox/api.ts` +- Test: 기존 통합 테스트 또는 신규 unit + +- [ ] **Step 1: shared/types.ts 의 InboxApi 인터페이스 갱신** + +```typescript +listNotes(opts: { limit: number; cursor?: string; notebookId?: string }): Promise; +listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise; +countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>; +search(query: string, opts?: { limit?: number; status?: NoteStatus; notebookId?: string }): Promise; +``` + +(archived 필드 제거) + +- [ ] **Step 2: inboxApi 핸들러 signature 확장** + +`src/main/ipc/inboxApi.ts`: + +```typescript +ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string; notebookId?: string }) => + deps.repo.list(opts) +); + +ipcMain.handle('inbox:list-by-status', (_e, status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed']; // archived 제거 + if (!VALID.includes(status)) return [] as Note[]; + return deps.repo.listByStatus(status, opts); +}); + +ipcMain.handle('inbox:counts-by-status', (_e, opts: { notebookId?: string } = {}) => ({ + active: deps.repo.countByStatus('active', opts), + completed: deps.repo.countByStatus('completed', opts), + trashed: deps.repo.countByStatus('trashed', opts) +})); + +ipcMain.handle('inbox:search', (_e, query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}) => + deps.repo.search(query, opts) +); +``` + +- [ ] **Step 3: preload 의 invoke 시그니처 그대로 (옵션 객체 패스스루)** + +`src/preload/index.ts` — 기존 invoke 들이 인자 그대로 패스이라 변경 없음. + +- [ ] **Step 4: renderer api facade 갱신** + +`src/renderer/inbox/api.ts`: + +```typescript +listNotes: (opts: { limit: number; cursor?: string; notebookId?: string }) => + ipcRenderer.invoke('inbox:list', opts), +listByStatus: (status: NoteStatus, opts?: { limit?: number; notebookId?: string }) => + ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), +// 등 +``` + +(또는 기존 facade 의 이미 사용중 패턴 그대로 패스스루) + +- [ ] **Step 5: 기존 호출자 회귀 검증** + +```bash +npm run typecheck && npm test +``` + +Expected: pass — 옵션 객체 미지정 시 기존 동작 보존. + +- [ ] **Step 6: commit** + +```bash +git add src/main/ipc/inboxApi.ts src/shared/types.ts src/renderer/inbox/api.ts +git commit -m "feat(ipc): inboxApi list/search/counts 에 notebookId 옵션 + archived 제거" +``` + +--- + +## Task 8: AI prompt 에 notebooks 목록 주입 + schema 의 notebook_match 필드 + +**Files:** +- Modify: `src/main/ai/prompt.ts`, `src/main/ai/schema.ts`, `src/main/ai/InferenceProvider.ts`, `src/main/ai/LocalOllamaProvider.ts` +- Test: `tests/unit/prompt.test.ts`, `tests/unit/schema.test.ts` + +- [ ] **Step 1: prompt.ts 실패하는 test 추가** + +`tests/unit/prompt.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { buildPrompt } from '../../src/main/ai/prompt.js'; + +describe('buildPrompt with notebooks', () => { + it('notebooks 목록이 prompt 에 포함됨', () => { + const p = buildPrompt('hi', '2026-05-14', [], [], ['기본', '회사']); + expect(p).toContain('기본'); + expect(p).toContain('회사'); + expect(p).toContain('notebook_match'); + }); + + it('notebooks 빈 배열 시 notebook 섹션 생략', () => { + const p = buildPrompt('hi', '2026-05-14', [], [], []); + expect(p).not.toContain('notebook_match'); + }); +}); +``` + +- [ ] **Step 2: buildPrompt signature 확장 + notebook 블록** + +`src/main/ai/prompt.ts`: + +```typescript +export function buildPrompt( + rawText: string, + todayKst: string, + candidates: ParseResult[] = [], + vocab: string[] = [], + notebooks: string[] = [] +): string { + // ... 기존 candidateBlock / vocabBlock ... + const notebookBlock = notebooks.length > 0 + ? `\n사용 가능한 노트북: ${notebooks.join(', ')}\n이 노트가 위 노트북 중 하나에 명확히 속하면 "notebook_match" 에 그 이름을, 그렇지 않으면 null 을 반환. 기존 목록 안에서만 선택 — 새 이름 만들지 말 것.\n` + : ''; + // ... return template 에 notebookBlock 추가 + notebook_match 키 명세도 추가: + // - "notebook_match": "사용 가능한 노트북" 목록 중 하나 또는 null +} +``` + +- [ ] **Step 3: schema.ts 의 RawResponseSchema 갱신** + +`src/main/ai/schema.ts`: + +```typescript +const RawResponseSchema = z.object({ + title: z.string().trim().min(1).max(200), + summary: z.string().min(1), + tags: z.array(z.string()).default([]), + due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional(), + notebook_match: z.string().nullable().optional() +}); + +export interface AiResponse { + title: string; + summary: string; + tags: string[]; + dueDate: string | null; + notebookMatch: string | null; // 추가 +} +``` + +`parseAiResponse` 의 반환에 `notebookMatch: parsed.notebook_match ?? null` 추가. + +- [ ] **Step 4: schema.test.ts 회귀 검증** + +`tests/unit/schema.test.ts` 의 기존 테스트가 새 필드로 깨지면 mock response 에 `notebook_match: null` 추가. + +- [ ] **Step 5: InferenceProvider / LocalOllamaProvider GenerateInput 에 notebooks** + +`src/main/ai/InferenceProvider.ts`: + +```typescript +export interface GenerateInput { + text: string; + images?: Array<{ base64: string; mime: string }>; + todayKst: string; + dueDateCandidates: ParseResult[]; + vocab?: string[]; + notebooks?: string[]; // 추가 +} +``` + +`LocalOllamaProvider.generate`: + +```typescript +const prompt = useVision + ? buildVisionPrompt(input.text, input.todayKst, ...) + : buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [], input.notebooks ?? []); +``` + +- [ ] **Step 6: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/prompt.test.ts tests/unit/schema.test.ts +``` + +Expected: pass + +- [ ] **Step 7: commit** + +```bash +git add src/main/ai/prompt.ts src/main/ai/schema.ts src/main/ai/InferenceProvider.ts src/main/ai/LocalOllamaProvider.ts tests/unit/prompt.test.ts tests/unit/schema.test.ts +git commit -m "feat(ai): prompt 에 notebooks 목록 + schema 의 notebook_match 필드" +``` + +--- + +## Task 9: AiWorker — notebook_match 처리 (자동 배치) + +**Files:** +- Modify: `src/main/ai/AiWorker.ts` +- Test: `tests/unit/AiWorker.test.ts` (기존 또는 신규 describe) + +- [ ] **Step 1: AiWorker 가 notebookRepo + notebooks list 받도록 옵션 확장** + +`AiWorkerOptions` 에 `notebookRepo?: { list(): Notebook[]; moveNote(noteId,nbId): void; findByName(name): Notebook | null }` 같은 deps. 단순화: `notebookRepo?: Pick` + `findByName` 헬퍼 추가. + +NotebookRepository 에 `findByName(name)` 메서드 추가: + +```typescript +findByName(name: string): Notebook | null { + const r = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb WHERE nb.name = ?` + ).get(name) as Record | undefined; + return r ? this.hydrate(r) : null; +} +``` + +- [ ] **Step 2: AiWorker.processJob 에 notebook matching 통합** + +`src/main/ai/AiWorker.ts` — generate 호출 직전: + +```typescript +const notebooks = this.notebookRepo ? this.notebookRepo.list().map((nb) => nb.name) : []; +// ... +const res = await this.holder.get().generate( + { text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab, notebooks }, + { visionModel: visionModel ?? undefined } +); +``` + +성공 후 (`updateAiResult` 직후) notebook 매칭: + +```typescript +// res.notebookMatch 가 valid notebook 이름이면 자동 이동. +if (res.notebookMatch && this.notebookRepo) { + const nb = this.notebookRepo.findByName(res.notebookMatch); + if (nb) { + this.notebookRepo.moveNote(job.noteId, nb.id); + this.logger.info('ai.notebook.match', { noteId: job.noteId, notebook: nb.name }); + } else { + this.logger.info('ai.notebook.miss', { noteId: job.noteId, attempted: res.notebookMatch }); + } +} +``` + +- [ ] **Step 3: 실패하는 test 추가** + +`tests/unit/AiWorker.test.ts`: + +```typescript +it('AI 응답의 notebook_match 가 valid 이름이면 moveNote 호출', async () => { + const moveNote = vi.fn(); + const notebookRepo = { + list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }], + moveNote, + findByName: (n: string) => n === '회사' ? { id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } : null + }; + // ... worker setup with notebookRepo, mock provider returning notebookMatch:'회사' ... + // capture + run worker + expect(moveNote).toHaveBeenCalledWith(expect.any(String), 'nb-회사'); +}); + +it('notebook_match null 이면 moveNote 호출 안 함 (default 유지)', async () => { + // ... 비슷한 setup, provider 가 notebookMatch:null 반환 ... + expect(moveNote).not.toHaveBeenCalled(); +}); +``` + +(기존 AiWorker.test 패턴 따라 fake provider / repo 사용) + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/AiWorker.test.ts +``` + +Expected: pass + +- [ ] **Step 5: commit** + +```bash +git add src/main/ai/AiWorker.ts src/main/repository/NotebookRepository.ts tests/unit/AiWorker.test.ts tests/unit/NotebookRepository.test.ts +git commit -m "feat(ai): AiWorker — notebook_match 매치 시 자동 moveNote" +``` + +--- + +## Task 10: store — notebooks state + actions + +**Files:** +- Modify: `src/renderer/inbox/store.ts` +- Test: `tests/unit/store.notebook.test.ts` + +- [ ] **Step 1: 실패하는 test 작성** + +`tests/unit/store.notebook.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { /* 기존 mock 패턴 */ }, + notebookApi: { + list: vi.fn(async () => [ + { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 }, + { id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 1 } + ]), + create: vi.fn(async (i) => ({ ok: true, notebook: { id: 'nb-new', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } })), + delete: vi.fn(async () => ({ ok: true })), + rename: vi.fn(async () => ({ ok: true })), + moveNote: vi.fn(async () => ({ ok: true })) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store.js'; + +describe('store notebooks', () => { + beforeEach(() => { + useInbox.setState({ + notebooks: [], + selectedNotebookId: null, + sidebarVisible: false + } as never); + }); + + it('loadNotebooks 가 notebookApi.list 결과 반영', async () => { + await useInbox.getState().loadNotebooks(); + expect(useInbox.getState().notebooks).toHaveLength(2); + }); + + it('selectNotebook 가 selectedNotebookId 설정', () => { + useInbox.getState().selectNotebook('nb-1'); + expect(useInbox.getState().selectedNotebookId).toBe('nb-1'); + }); + + it('createNotebook 성공 시 notebooks 에 추가', async () => { + await useInbox.getState().createNotebook('학습', '#ccc'); + expect(useInbox.getState().notebooks.some((n) => n.name === '학습')).toBe(true); + }); + + it('toggleSidebar 가 sidebarVisible 반전', () => { + expect(useInbox.getState().sidebarVisible).toBe(false); + useInbox.getState().toggleSidebar(); + expect(useInbox.getState().sidebarVisible).toBe(true); + }); +}); +``` + +- [ ] **Step 2: store.ts 에 notebooks state 추가** + +`src/renderer/inbox/store.ts`: + +```typescript +interface InboxState { + // ... 기존 ... + notebooks: Notebook[]; + selectedNotebookId: string | null; + sidebarVisible: boolean; + sidebarWidth: number; + loadNotebooks: () => Promise; + selectNotebook: (id: string) => void; + createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>; + renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>; + deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>; + moveNoteToNotebook: (noteId: string, notebookId: string) => Promise; + toggleSidebar: () => void; +} +``` + +구현 (zustand store body 에서): + +```typescript +notebooks: [], +selectedNotebookId: null, +sidebarVisible: false, +sidebarWidth: 240, + +async loadNotebooks() { + const list = await notebookApi.list(); + set({ notebooks: list, selectedNotebookId: get().selectedNotebookId ?? list[0]?.id ?? null }); +}, + +selectNotebook(id) { set({ selectedNotebookId: id }); }, + +async createNotebook(name, color) { + const r = await notebookApi.create({ name, color }); + if (r.ok) { + set({ notebooks: [...get().notebooks, r.notebook] }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; +}, + +async renameNotebook(id, name) { + const r = await notebookApi.rename(id, name); + if (r.ok) { + set({ notebooks: get().notebooks.map((nb) => nb.id === id ? { ...nb, name } : nb) }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; +}, + +async deleteNotebook(id) { + const r = await notebookApi.delete(id); + if (r.ok) { + set({ notebooks: get().notebooks.filter((nb) => nb.id !== id) }); + if (get().selectedNotebookId === id) set({ selectedNotebookId: get().notebooks[0]?.id ?? null }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; +}, + +async moveNoteToNotebook(noteId, notebookId) { + await notebookApi.moveNote(noteId, notebookId); + await get().refreshMeta(); +}, + +toggleSidebar() { set({ sidebarVisible: !get().sidebarVisible }); }, +``` + +- [ ] **Step 3: 테스트 통과 확인** + +```bash +npx vitest run tests/unit/store.notebook.test.ts +``` + +Expected: 4 passed + +- [ ] **Step 4: commit** + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.notebook.test.ts +git commit -m "feat(store): notebooks state + actions (load/select/create/rename/delete/move/toggle)" +``` + +--- + +## Task 11: store — promotionCandidate state + action + +**Files:** +- Modify: `src/renderer/inbox/store.ts`, `src/shared/types.ts`, `src/preload/index.ts`, `src/main/ipc/inboxApi.ts` +- Test: `tests/unit/store.notebook.test.ts` (확장) + +- [ ] **Step 1: inboxApi 에 list-promotion-candidates IPC 추가** + +`src/main/ipc/inboxApi.ts`: + +```typescript +ipcMain.handle('inbox:list-promotion-candidates', () => { + const defaultNb = deps.notebookRepo.list()[0]; + if (!defaultNb) return []; + return deps.repo.findPromotionCandidates(defaultNb.id); +}); +``` + +deps 에 notebookRepo 추가. preload + renderer api facade 도 동일. + +- [ ] **Step 2: PromotionCandidate type 추가** + +`src/shared/types.ts`: + +```typescript +export interface PromotionCandidate { + tag: string; + noteIds: string[]; + suggestedName: string; // tag 이름 Title Case +} +``` + +- [ ] **Step 3: store 에 promotionCandidate state** + +`src/renderer/inbox/store.ts`: + +```typescript +promotionCandidates: PromotionCandidate[]; +async loadPromotionCandidates() { + const dismissed = await inboxApi.getPromotionDismissedTags(); + const snoozedUntil = await inboxApi.getPromotionSnoozeUntil(); + if (snoozedUntil > Date.now()) { set({ promotionCandidates: [] }); return; } + const raw = await inboxApi.listPromotionCandidates(); + const filtered = raw.filter((c) => !dismissed.includes(c.tag)); + set({ promotionCandidates: filtered.map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) })) }); +}, +async acceptPromotion(tag, customName, color) { + const c = get().promotionCandidates.find((p) => p.tag === tag); + if (!c) return; + const created = await notebookApi.create({ name: customName, color }); + if (!created.ok) return; + await Promise.all(c.noteIds.map((id) => notebookApi.moveNote(id, created.notebook.id))); + set({ + notebooks: [...get().notebooks, created.notebook], + promotionCandidates: get().promotionCandidates.filter((p) => p.tag !== tag), + sidebarVisible: true, + selectedNotebookId: created.notebook.id + }); + await get().refreshMeta(); +}, +async snoozePromotion() { + const until = Date.now() + 24 * 60 * 60 * 1000; + await inboxApi.setPromotionSnoozeUntil(until); + set({ promotionCandidates: [] }); +}, +async dismissPromotion(tag) { + await inboxApi.addPromotionDismissedTag(tag); + set({ promotionCandidates: get().promotionCandidates.filter((p) => p.tag !== tag) }); +} +``` + +`toTitleCase` 헬퍼: + +```typescript +function toTitleCase(s: string): string { + return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} +``` + +- [ ] **Step 4: SettingsService 에 promotion 상태 저장** + +`src/main/services/SettingsService.ts` schema 확장 (Zod): + +```typescript +promotion_dismissed_tags: z.array(z.string()).optional(), +promotion_snoozed_until_ms: z.number().int().optional(), +sidebar_visible: z.boolean().optional(), +sidebar_width: z.number().int().min(180).max(400).optional() +``` + +각각 getter / setter 메서드 추가. settingsApi IPC 핸들러도 노출 (`inbox:get-promotion-dismissed-tags` 등). + +- [ ] **Step 5: 테스트 추가** + +`tests/unit/store.notebook.test.ts` 확장: + +```typescript +it('acceptPromotion 가 새 notebook 생성 + 노트 일괄 이동 + 사이드바 자동 열기', async () => { + // mock setup ... + useInbox.setState({ promotionCandidates: [{ tag: 'mlx-ops', noteIds: ['n1','n2'], suggestedName: 'Mlx Ops' }] } as never); + await useInbox.getState().acceptPromotion('mlx-ops', 'MLX Ops', null); + expect(useInbox.getState().sidebarVisible).toBe(true); + expect(useInbox.getState().promotionCandidates).toHaveLength(0); +}); +``` + +- [ ] **Step 6: 테스트 통과 확인 + commit** + +```bash +npx vitest run tests/unit/store.notebook.test.ts +git add src/renderer/inbox/store.ts src/shared/types.ts src/main/ipc/inboxApi.ts src/main/services/SettingsService.ts src/preload/index.ts src/renderer/inbox/api.ts tests/unit/store.notebook.test.ts +git commit -m "feat(promotion): store promotionCandidates + accept/snooze/dismiss + settings 저장" +``` + +--- + +## Task 12: Sidebar 컴포넌트 + NotebookList + +**Files:** +- Create: `src/renderer/inbox/components/Sidebar.tsx`, `src/renderer/inbox/components/NotebookList.tsx` +- Test: `tests/unit/Sidebar.test.tsx`, `tests/unit/NotebookList.test.tsx` + +- [ ] **Step 1: NotebookList 실패 test** + +`tests/unit/NotebookList.test.tsx`: + +```typescript +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { NotebookList } from '../../src/renderer/inbox/components/NotebookList'; + +const notebooks = [ + { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 }, + { id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 7 } +]; + +describe('NotebookList', () => { + beforeEach(cleanup); + + it('노트북 이름 + count 렌더링', () => { + render( {}} onCreate={() => {}} />); + expect(screen.getByText('기본')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('회사')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('클릭 시 onSelect 호출', () => { + const onSelect = vi.fn(); + render( {}} />); + fireEvent.click(screen.getByText('회사')); + expect(onSelect).toHaveBeenCalledWith('nb-2'); + }); + + it('+ 새 노트북 클릭 시 onCreate 호출', () => { + const onCreate = vi.fn(); + render( {}} onCreate={onCreate} />); + fireEvent.click(screen.getByRole('button', { name: /새 노트북/ })); + expect(onCreate).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: NotebookList 구현** + +`src/renderer/inbox/components/NotebookList.tsx`: + +```typescript +import React from 'react'; +import type { Notebook } from '@shared/types'; + +interface Props { + notebooks: Notebook[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; +} + +export function NotebookList({ notebooks, selectedId, onSelect, onCreate }: Props): React.ReactElement { + return ( +
+ {notebooks.map((nb) => { + const active = nb.id === selectedId; + return ( + + ); + })} + +
+ ); +} +``` + +- [ ] **Step 3: Sidebar 실패 test** + +`tests/unit/Sidebar.test.tsx`: + +```typescript +// @vitest-environment jsdom +// store mock + render Sidebar +// 검증: sidebarVisible=false 면 null, true 면 NotebookList 렌더링 +``` + +- [ ] **Step 4: Sidebar 구현** + +`src/renderer/inbox/components/Sidebar.tsx`: + +```typescript +import React, { useState } from 'react'; +import { useInbox } from '../store.js'; +import { NotebookList } from './NotebookList.js'; +import { NotebookCreateModal } from './NotebookCreateModal.js'; + +export function Sidebar(): React.ReactElement | null { + const visible = useInbox((s) => s.sidebarVisible); + const width = useInbox((s) => s.sidebarWidth); + const notebooks = useInbox((s) => s.notebooks); + const selectedId = useInbox((s) => s.selectedNotebookId); + const selectNotebook = useInbox((s) => s.selectNotebook); + const [createOpen, setCreateOpen] = useState(false); + if (!visible) return null; + return ( + + ); +} +``` + +- [ ] **Step 5: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/NotebookList.test.tsx tests/unit/Sidebar.test.tsx +git add src/renderer/inbox/components/Sidebar.tsx src/renderer/inbox/components/NotebookList.tsx tests/unit/NotebookList.test.tsx tests/unit/Sidebar.test.tsx +git commit -m "feat(ui): Sidebar + NotebookList — notebook 목록 + count + 선택" +``` + +--- + +## Task 13: NotebookCreateModal + +**Files:** +- Create: `src/renderer/inbox/components/NotebookCreateModal.tsx` +- Test: `tests/unit/NotebookCreateModal.test.tsx` + +- [ ] **Step 1: 실패 test** + +```typescript +// 이름 입력 + 색 선택 + 확인 → store.createNotebook 호출 + 모달 닫힘 +// 빈 이름 시 확인 disabled +// 중복 이름 시 reason 표시 +``` + +- [ ] **Step 2: 구현** + +`NotebookCreateModal.tsx`: + +```typescript +import React, { useState } from 'react'; +import { useInbox } from '../store.js'; + +const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c', '#1a5b6e']; + +export function NotebookCreateModal({ onClose }: { onClose: () => void }): React.ReactElement { + const createNotebook = useInbox((s) => s.createNotebook); + const [name, setName] = useState(''); + const [color, setColor] = useState(COLOR_PALETTE[0]!); + const [err, setErr] = useState(null); + + async function onSubmit() { + const r = await createNotebook(name.trim(), color); + if (r.ok) onClose(); + else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패')); + } + + return ( +
+
e.stopPropagation()} + style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }} + > +

새 노트북

+ setName(e.target.value)} + placeholder="예: 회사, 학습" + autoFocus + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} + /> +
+ {COLOR_PALETTE.map((c) => ( +
+ {err &&
{err}
} +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 3: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/NotebookCreateModal.test.tsx +git add src/renderer/inbox/components/NotebookCreateModal.tsx tests/unit/NotebookCreateModal.test.tsx +git commit -m "feat(ui): NotebookCreateModal — 이름 + 색 + 중복 검증" +``` + +--- + +## Task 14: PromotionBanner + +**Files:** +- Create: `src/renderer/inbox/components/PromotionBanner.tsx` +- Test: `tests/unit/PromotionBanner.test.tsx` + +- [ ] **Step 1: 실패 test** + +`tests/unit/PromotionBanner.test.tsx`: + +```typescript +it('promotionCandidates 비어있으면 null', () => {}); +it('첫 candidate 의 tag/suggestedName 표시', () => {}); +it('수락 클릭 → modal 띄움 → 이름 입력 → store.acceptPromotion 호출', () => {}); +it('나중에 클릭 → store.snoozePromotion 호출', () => {}); +it('숨기기 클릭 → store.dismissPromotion 호출 (tag)', () => {}); +``` + +- [ ] **Step 2: 구현** + +`PromotionBanner.tsx`: + +```typescript +import React, { useState } from 'react'; +import { useInbox } from '../store.js'; +import { Banner } from './Banner.js'; + +const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c']; + +export function PromotionBanner(): React.ReactElement | null { + const candidates = useInbox((s) => s.promotionCandidates); + const accept = useInbox((s) => s.acceptPromotion); + const snooze = useInbox((s) => s.snoozePromotion); + const dismiss = useInbox((s) => s.dismissPromotion); + const [editing, setEditing] = useState<{ tag: string; name: string; color: string } | null>(null); + + if (candidates.length === 0) return null; + const c = candidates[0]!; + + return ( + + {editing === null ? ( +
+ 💡 {c.tag} 관련 노트 {c.noteIds.length}개가 모였어요. 새 노트북 {c.suggestedName} 로 분리할까요? +
+ + + +
+
+ ) : ( +
+ setEditing({ ...editing, name: e.target.value })} + style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }} + autoFocus + /> +
+ {COLOR_PALETTE.map((col) => ( +
+ + +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/PromotionBanner.test.tsx +git add src/renderer/inbox/components/PromotionBanner.tsx tests/unit/PromotionBanner.test.tsx +git commit -m "feat(promotion): PromotionBanner — 수락/나중에/숨기기 + inline 이름·색 수정" +``` + +--- + +## Task 15: App.tsx 통합 — Sidebar 레이아웃, 헤더 3탭, Cmd+B 단축키, Banner 위치 + +**Files:** +- Modify: `src/renderer/inbox/App.tsx` +- Test: `tests/unit/App.test.tsx` + +- [ ] **Step 1: 헤더 탭 archived 제거** + +`App.tsx` 의 헤더 탭 영역에서 "보관(N)" 버튼 제거. counts 객체에서도 archived 키 제거. + +- [ ] **Step 2: 본문을 flex 컨테이너로 + Sidebar 좌측 배치** + +`App.tsx` return 구조 변경: + +```tsx +
+ +
+ {/* 기존 헤더 + Banner 들 + NoteCard list */} +
+
+``` + +`` import + `` 를 ExpiryBanner / RecallBanner 옆에 배치. + +- [ ] **Step 3: Cmd+B / Ctrl+B 단축키** + +App 최상단 useEffect 에: + +```typescript +useEffect(() => { + const isMac = /Mac/i.test(navigator.platform); + const handler = (e: KeyboardEvent) => { + if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) { + e.preventDefault(); + useInbox.getState().toggleSidebar(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); +}, []); +``` + +- [ ] **Step 4: app 초기화에서 loadNotebooks + loadPromotionCandidates 호출** + +`loadInitial` 또는 useEffect mount 단계: + +```typescript +await useInbox.getState().loadNotebooks(); +await useInbox.getState().loadPromotionCandidates(); +``` + +- [ ] **Step 5: 헤더 탭 클릭 시 selectedNotebookId 기준으로 count 가져오기** + +기존 `counts` 가 server-authoritative 라면 `inboxApi.countsByStatus({ notebookId: selectedId })` 로 fetch. 또는 store 에서 자동 갱신. + +- [ ] **Step 6: 테스트 + commit** + +```bash +npm run typecheck && npm test +git add src/renderer/inbox/App.tsx tests/unit/App.test.tsx +git commit -m "feat(app): Sidebar 통합 + 헤더 3탭 + Cmd+B 단축키 + PromotionBanner" +``` + +--- + +## Task 16: MoveStatusModal — archived 옵션 제거 + +**Files:** +- Modify: `src/renderer/inbox/components/MoveStatusModal.tsx` +- Test: `tests/unit/MoveStatusModal.test.tsx` (기존) + +- [ ] **Step 1: 기존 test 의 "보관" 케이스 제거 또는 archived → no-op 검증** + +- [ ] **Step 2: MoveStatusModal 의 status 선택지에서 archived 옵션 제거** + +archived 버튼 / option 삭제. AI classifyStatus 호출 결과가 archived 면 client side 에서 completed 로 coerce. + +- [ ] **Step 3: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/MoveStatusModal.test.tsx +git add src/renderer/inbox/components/MoveStatusModal.tsx tests/unit/MoveStatusModal.test.tsx +git commit -m "fix(move): archived 옵션 제거 — lifecycle 3분기" +``` + +--- + +## Task 17: NoteCard — notebook chip + 1-click 이동 + +**Files:** +- Modify: `src/renderer/inbox/components/NoteCard.tsx` +- Test: `tests/unit/NoteCard.test.tsx` + +- [ ] **Step 1: 실패 test 추가** + +```typescript +it('NoteCard 가 현재 notebook 이름 chip 렌더링', () => { + // mock store 의 notebooks → render → chip 텍스트 확인 +}); + +it('chip 클릭 → 다른 notebook 선택 dropdown → store.moveNoteToNotebook 호출', () => {}); +``` + +- [ ] **Step 2: NoteCard 안 notebook chip 추가** + +`NoteCard.tsx` — notebook 이름 표시 + 클릭 시 dropdown: + +```typescript +const notebooks = useInbox((s) => s.notebooks); +const move = useInbox((s) => s.moveNoteToNotebook); +const currentNb = notebooks.find((nb) => nb.id === local.notebookId); + +// NoteCard 의 적절한 위치 (title 옆 또는 footer) 에: +{currentNb && ( + { + await move(local.id, newId); + setLocal({ ...local, notebookId: newId }); + }} + /> +)} +``` + +별도 `NotebookChip` 컴포넌트 신설 또는 inline. inline 으로: + +```typescript +function NotebookChip({ current, notebooks, onMove }: { + current: Notebook; notebooks: Notebook[]; onMove: (id: string) => Promise +}) { + const [open, setOpen] = useState(false); + return ( + + + {open && ( +
+ {notebooks.filter((nb) => nb.id !== current.id).map((nb) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/NoteCard.test.tsx +git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx +git commit -m "feat(ui): NoteCard 의 notebook chip + 1-click 이동" +``` + +--- + +## Task 18: search scope — current vs all notebooks + +**Files:** +- Modify: `src/renderer/inbox/components/SearchBox.tsx`, `src/renderer/inbox/store.ts` +- Test: `tests/unit/SearchBox.test.tsx` + +- [ ] **Step 1: SearchBox 옆에 scope toggle 추가** + +`SearchBox.tsx`: + +```typescript +const [scope, setScope] = useState<'current' | 'all'>('current'); +// fetch 시 notebookId 옵션: +const notebookId = scope === 'current' ? selectedNotebookId : undefined; +const results = await inboxApi.search(query, { notebookId }); +``` + +UI 에 작은 dropdown 또는 토글: + +```jsx + +``` + +- [ ] **Step 2: 테스트 통과 + commit** + +```bash +npx vitest run tests/unit/SearchBox.test.tsx +git add src/renderer/inbox/components/SearchBox.tsx tests/unit/SearchBox.test.tsx +git commit -m "feat(search): scope 토글 — 이 노트북 / 모든 노트북" +``` + +--- + +## Task 19: 헤더 / list 의 selectedNotebookId 필터링 + counts notebookId 반영 + +**Files:** +- Modify: `src/renderer/inbox/store.ts`, `src/renderer/inbox/App.tsx` + +- [ ] **Step 1: store 의 loadInitial / refreshMeta / loadByView 에 selectedNotebookId 반영** + +```typescript +async loadInitial() { + await get().loadNotebooks(); + const nbId = get().selectedNotebookId; + // 기존 fetch 들 모두 { notebookId: nbId } 전달 +} + +async refreshMeta() { + const nbId = get().selectedNotebookId; + const counts = await inboxApi.countsByStatus({ notebookId: nbId }); + // ... +} +``` + +selectedNotebookId 변경 시 자동 refresh: + +```typescript +selectNotebook(id) { + set({ selectedNotebookId: id }); + void get().loadByView(get().view); + void get().refreshMeta(); +} +``` + +- [ ] **Step 2: typecheck + 전체 test** + +```bash +npm run typecheck && npm test +``` + +- [ ] **Step 3: commit** + +```bash +git add src/renderer/inbox/store.ts src/renderer/inbox/App.tsx +git commit -m "feat(store): selectedNotebookId 변경 시 list/counts 자동 refresh" +``` + +--- + +## Task 20: CHANGELOG + dogfood 안내 + +**Files:** +- Modify: `CHANGELOG.md`, `package.json` + +- [ ] **Step 1: package.json version 0.3.14 → 0.4.0** + +- [ ] **Step 2: CHANGELOG.md 상단에 v0.4.0 entry** + +```markdown +## [0.4.0] — 2026-XX-XX + +Notebooks + Lifecycle Simplification. 19일 dogfood 데이터 (archived 0건 / mlx-ops tag 가 사실상 컨텍스트 그룹 역할) 가 묶음 변경의 근거. + +### 추가 +- **Notebook 카테고리** — 좌측 사이드바 (Cmd+B / Ctrl+B 토글), 색 + count badge. +- **AI 자동 fit 매칭** — 매 capture 시 AI 가 기존 notebook 중 best-fit 자동 배치. NoteCard 의 chip 으로 1-click 변경. +- **Promotion 제안** — tag 가 3건 이상 default notebook 에 누적되면 "새 notebook 으로 분리?" banner 표시. 24h snooze + 영구 dismiss 지원. +- m008 마이그레이션 — default "기본" notebook 자동 생성 + 모든 기존 노트 배치. + +### 변경 +- **lifecycle 3분기** — `archived` 제거. 기존 보관 노트 (dogfood 0건) 는 마이그레이션 시 completed 로 합침. +- 헤더 탭: Inbox / 완료 / 휴지통 (3탭). +- 검색 scope 토글 — "이 노트북" / "모든 노트북". + +### 게이트 +- 단위 테스트 N PASS (notebook CRUD + migration + UI + AI fit/promotion 분기 + status 3분기 회귀) +- typecheck 0 errors +``` + +- [ ] **Step 3: commit** + +```bash +git add CHANGELOG.md package.json +git commit -m "chore(release): v0.4.0 — notebooks + lifecycle 3분기" +``` + +--- + +## Self-Review + +- [x] **Spec coverage**: §3 범위 (notebook 모델 B / lifecycle 3분기 / default notebook / 사이드바 토글 / search scope / RESTRICT / 자동 fit) 모두 task 1-19 에 분배. +- [x] **AI × Notebook** (§11): Task 8 (prompt + schema) / Task 9 (AiWorker matching) / Task 11 (promotionCandidate store) / Task 14 (PromotionBanner UI) 로 분해. +- [x] **placeholders**: TBD / TODO / "implement later" 없음. 모든 step 에 실제 code block. +- [x] **type consistency**: `Notebook` / `NotebookApi` / `PromotionCandidate` 모두 shared/types.ts 에 정의, 후속 task 가 동일 시그니처 사용. +- [x] **migration version**: m008 (m007 이 FTS 로 사용중이므로 충돌 회피). +- [x] **archived 제거**: status enum + UI 헤더 + MoveStatusModal + listByStatus IPC + countsByStatus IPC 일관 적용. +- [x] **sync (Cut E) 통합**: spec §10 명시. 본 plan 에서 deferred — frontmatter notebook 필드는 v0.4 본체 머지 후 별도 작업. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach?