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) <noreply@anthropic.com>
73 KiB
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 helpersrc/main/ipc/notebookApi.ts— IPC 핸들러src/renderer/inbox/components/Sidebar.tsx— 좌측 패널 rootsrc/renderer/inbox/components/NotebookList.tsx— notebook 목록 + count badgesrc/renderer/inbox/components/NotebookCreateModal.tsx— name + color pickersrc/renderer/inbox/components/PromotionBanner.tsx— Inbox 상단 bannertests/unit/m008.test.tstests/unit/NotebookRepository.test.tstests/unit/notebookApi.test.tstests/unit/Sidebar.test.tsxtests/unit/NotebookList.test.tsxtests/unit/PromotionBanner.test.tsxtests/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_mssrc/shared/types.ts— Notebook type, NoteStatus 에서 archived 제거, InboxApi/SettingsApi 인터페이스 확장src/preload/index.ts— notebookApi exposuresrc/renderer/inbox/store.ts— notebooks state + actions + promotionCandidatesrc/renderer/inbox/api.ts— notebookApi facadesrc/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 movesrc/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:
// 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 추가:
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:
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
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 인터페이스 근처) 에 추가:
export interface Notebook {
id: string;
name: string;
color: string | null;
createdAt: string;
updatedAt: string;
noteCount: number; // status='active' 노트만 카운트
}
같은 파일의 NoteStatus 정의에서 'archived' 제거:
// 변경 전 (참조용):
// export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
// 변경 후:
export type NoteStatus = 'active' | 'completed' | 'trashed';
- Step 2: Note 인터페이스에 notebookId 추가
src/shared/types.ts 의 Note 인터페이스에 필드 추가:
export interface Note {
// ... 기존 필드 ...
notebookId: string; // m008 마이그레이션 보장 — NOT NULL 동치
}
- Step 3: 실패하는 test 작성
tests/unit/NotebookRepository.test.ts:
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:
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<Record<string, unknown>>;
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<string, unknown> | 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<string, unknown>): 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: 테스트 실패 확인 → 통과 확인
npx vitest run tests/unit/NotebookRepository.test.ts
Expected: 7 passed
- Step 6: commit
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 추가:
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:
export interface CreateNoteInput {
rawText: string;
aiStatus?: AiStatus;
notebookId?: string; // 미지정 시 default notebook
}
create() 메서드에 notebook_id 채우는 로직 추가. 구현:
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 줄:
notebookId: row.notebook_id as string,
각 SELECT 쿼리도 notebook_id 가 row 에 포함되도록 확인 (SELECT * 라면 자동 포함).
- Step 4: 테스트 통과 확인
npx vitest run tests/unit/NoteRepository.test.ts
Expected: all passed (기존 + 신규 2건)
- Step 5: commit
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:
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:
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<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
listByStatus:
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<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
countByStatus 도 signature 확장:
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? }):
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: 테스트 통과 확인 + 기존 호출자 회귀 점검
npx vitest run tests/unit/NoteRepository.test.ts
Expected: all passed. 기존 호출자 (notebookId 미지정) 가 회귀 없음 확인.
- Step 4: commit
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 추가
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:
/**
* 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: 테스트 통과 확인
npx vitest run tests/unit/NoteRepository.test.ts
Expected: all passed (신규 4건 포함)
- Step 4: commit
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:
export interface NotebookApi {
list(): Promise<Notebook[]>;
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:
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<typeof vi.fn> }).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<typeof vi.fn>; create: ReturnType<typeof vi.fn>; rename: ReturnType<typeof vi.fn>; setColor: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn>; moveNote: ReturnType<typeof vi.fn>; findById: ReturnType<typeof vi.fn> } {
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<typeof vi.fn> }).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:
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 옆에 추가:
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 초기화 직후:
import { NotebookRepository } from './repository/NotebookRepository.js';
import { registerNotebookApi } from './ipc/notebookApi.js';
// ...
const notebookRepo = new NotebookRepository(db);
registerNotebookApi({ repo: notebookRepo });
- Step 6: 테스트 통과 확인
npx vitest run tests/unit/notebookApi.test.ts
Expected: 3 passed
- Step 7: commit
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 인터페이스 갱신
listNotes(opts: { limit: number; cursor?: string; notebookId?: string }): Promise<Note[]>;
listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise<Note[]>;
countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>;
search(query: string, opts?: { limit?: number; status?: NoteStatus; notebookId?: string }): Promise<Note[]>;
(archived 필드 제거)
- Step 2: inboxApi 핸들러 signature 확장
src/main/ipc/inboxApi.ts:
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:
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: 기존 호출자 회귀 검증
npm run typecheck && npm test
Expected: pass — 옵션 객체 미지정 시 기존 동작 보존.
- Step 6: commit
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:
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:
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:
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:
export interface GenerateInput {
text: string;
images?: Array<{ base64: string; mime: string }>;
todayKst: string;
dueDateCandidates: ParseResult[];
vocab?: string[];
notebooks?: string[]; // 추가
}
LocalOllamaProvider.generate:
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, ...)
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [], input.notebooks ?? []);
- Step 6: 테스트 통과 확인
npx vitest run tests/unit/prompt.test.ts tests/unit/schema.test.ts
Expected: pass
- Step 7: commit
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<NotebookRepository, 'list' | 'moveNote'> + findByName 헬퍼 추가.
NotebookRepository 에 findByName(name) 메서드 추가:
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<string, unknown> | undefined;
return r ? this.hydrate(r) : null;
}
- Step 2: AiWorker.processJob 에 notebook matching 통합
src/main/ai/AiWorker.ts — generate 호출 직전:
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 매칭:
// 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:
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: 테스트 통과 확인
npx vitest run tests/unit/AiWorker.test.ts
Expected: pass
- Step 5: commit
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:
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:
interface InboxState {
// ... 기존 ...
notebooks: Notebook[];
selectedNotebookId: string | null;
sidebarVisible: boolean;
sidebarWidth: number;
loadNotebooks: () => Promise<void>;
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<void>;
toggleSidebar: () => void;
}
구현 (zustand store body 에서):
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: 테스트 통과 확인
npx vitest run tests/unit/store.notebook.test.ts
Expected: 4 passed
- Step 4: commit
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:
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:
export interface PromotionCandidate {
tag: string;
noteIds: string[];
suggestedName: string; // tag 이름 Title Case
}
- Step 3: store 에 promotionCandidate state
src/renderer/inbox/store.ts:
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 헬퍼:
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):
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 확장:
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
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:
// @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(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} 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(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={onSelect} onCreate={() => {}} />);
fireEvent.click(screen.getByText('회사'));
expect(onSelect).toHaveBeenCalledWith('nb-2');
});
it('+ 새 노트북 클릭 시 onCreate 호출', () => {
const onCreate = vi.fn();
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={onCreate} />);
fireEvent.click(screen.getByRole('button', { name: /새 노트북/ }));
expect(onCreate).toHaveBeenCalled();
});
});
- Step 2: NotebookList 구현
src/renderer/inbox/components/NotebookList.tsx:
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 (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{notebooks.map((nb) => {
const active = nb.id === selectedId;
return (
<button
key={nb.id}
onClick={() => onSelect(nb.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
border: 'none', cursor: 'pointer', textAlign: 'left',
color: active ? '#0a4b80' : '#333', fontSize: 13
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: nb.color ?? '#bbb', flexShrink: 0
}} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{nb.name}
</span>
<span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
</button>
);
})}
<button
onClick={onCreate}
style={{
padding: '6px 12px', background: 'transparent', border: 'none',
cursor: 'pointer', textAlign: 'left', color: '#888', fontSize: 12
}}
>
+ 새 노트북
</button>
</div>
);
}
- Step 3: Sidebar 실패 test
tests/unit/Sidebar.test.tsx:
// @vitest-environment jsdom
// store mock + render Sidebar
// 검증: sidebarVisible=false 면 null, true 면 NotebookList 렌더링
- Step 4: Sidebar 구현
src/renderer/inbox/components/Sidebar.tsx:
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 (
<aside style={{
width, borderRight: '1px solid #e0e0e0',
background: '#fafafa', overflowY: 'auto',
flexShrink: 0
}}>
<NotebookList
notebooks={notebooks}
selectedId={selectedId}
onSelect={selectNotebook}
onCreate={() => setCreateOpen(true)}
/>
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
</aside>
);
}
- Step 5: 테스트 통과 + commit
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
// 이름 입력 + 색 선택 + 확인 → store.createNotebook 호출 + 모달 닫힘
// 빈 이름 시 확인 disabled
// 중복 이름 시 reason 표시
- Step 2: 구현
NotebookCreateModal.tsx:
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<string>(COLOR_PALETTE[0]!);
const [err, setErr] = useState<string | null>(null);
async function onSubmit() {
const r = await createNotebook(name.trim(), color);
if (r.ok) onClose();
else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패'));
}
return (
<div
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 120 }}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }}
>
<h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}>새 노트북</h3>
<input
aria-label="노트북 이름"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 회사, 학습"
autoFocus
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
/>
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
{COLOR_PALETTE.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`색 ${c}`}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c, border: c === color ? '2px solid #333' : '1px solid #ccc',
cursor: 'pointer'
}}
/>
))}
</div>
{err && <div style={{ color: '#c33', fontSize: 12, marginTop: 8 }}>{err}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
<button onClick={onClose} style={{ padding: '5px 12px', fontSize: 12 }}>취소</button>
<button
onClick={() => { void onSubmit(); }}
disabled={name.trim().length === 0}
style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4 }}
>
만들기
</button>
</div>
</div>
</div>
);
}
- Step 3: 테스트 통과 + commit
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:
it('promotionCandidates 비어있으면 null', () => {});
it('첫 candidate 의 tag/suggestedName 표시', () => {});
it('수락 클릭 → modal 띄움 → 이름 입력 → store.acceptPromotion 호출', () => {});
it('나중에 클릭 → store.snoozePromotion 호출', () => {});
it('숨기기 클릭 → store.dismissPromotion 호출 (tag)', () => {});
- Step 2: 구현
PromotionBanner.tsx:
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 (
<Banner severity="info">
{editing === null ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>💡 <code>{c.tag}</code> 관련 노트 {c.noteIds.length}개가 모였어요. 새 노트북 <b>{c.suggestedName}</b> 로 분리할까요?</span>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
<button
onClick={() => setEditing({ tag: c.tag, name: c.suggestedName, color: COLOR_PALETTE[0]! })}
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
>
수락
</button>
<button
onClick={() => snooze()}
style={{ background: 'transparent', color: '#234', border: '1px solid #ccc', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
>
나중에
</button>
<button
onClick={() => dismiss(c.tag)}
style={{ background: 'transparent', color: '#888', border: 'none', cursor: 'pointer', fontSize: 12 }}
>
숨기기
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }}
autoFocus
/>
<div style={{ display: 'flex', gap: 4 }}>
{COLOR_PALETTE.map((col) => (
<button
key={col}
onClick={() => setEditing({ ...editing, color: col })}
aria-label={`색 ${col}`}
style={{ width: 20, height: 20, borderRadius: '50%', background: col, border: col === editing.color ? '2px solid #333' : '1px solid #ccc', cursor: 'pointer' }}
/>
))}
</div>
<button
onClick={() => { void accept(editing.tag, editing.name.trim(), editing.color); setEditing(null); }}
disabled={editing.name.trim().length === 0}
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
>
만들기
</button>
<button onClick={() => setEditing(null)} style={{ fontSize: 12 }}>취소</button>
</div>
)}
</Banner>
);
}
- Step 3: 테스트 통과 + commit
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 구조 변경:
<div style={{ display: 'flex', height: '100vh' }}>
<Sidebar />
<main style={{ flex: 1, overflowY: 'auto' }}>
{/* 기존 헤더 + Banner 들 + NoteCard list */}
</main>
</div>
<Sidebar /> import + <PromotionBanner /> 를 ExpiryBanner / RecallBanner 옆에 배치.
- Step 3: Cmd+B / Ctrl+B 단축키
App 최상단 useEffect 에:
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 단계:
await useInbox.getState().loadNotebooks();
await useInbox.getState().loadPromotionCandidates();
- Step 5: 헤더 탭 클릭 시 selectedNotebookId 기준으로 count 가져오기
기존 counts 가 server-authoritative 라면 inboxApi.countsByStatus({ notebookId: selectedId }) 로 fetch. 또는 store 에서 자동 갱신.
- Step 6: 테스트 + commit
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
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 추가
it('NoteCard 가 현재 notebook 이름 chip 렌더링', () => {
// mock store 의 notebooks → render → chip 텍스트 확인
});
it('chip 클릭 → 다른 notebook 선택 dropdown → store.moveNoteToNotebook 호출', () => {});
- Step 2: NoteCard 안 notebook chip 추가
NoteCard.tsx — notebook 이름 표시 + 클릭 시 dropdown:
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 && (
<NotebookChip
current={currentNb}
notebooks={notebooks}
onMove={async (newId) => {
await move(local.id, newId);
setLocal({ ...local, notebookId: newId });
}}
/>
)}
별도 NotebookChip 컴포넌트 신설 또는 inline. inline 으로:
function NotebookChip({ current, notebooks, onMove }: {
current: Notebook; notebooks: Notebook[]; onMove: (id: string) => Promise<void>
}) {
const [open, setOpen] = useState(false);
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setOpen(!open)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: '#f0f0f0', border: 'none', borderRadius: 10,
padding: '2px 8px', fontSize: 11, cursor: 'pointer'
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb' }} />
{current.name}
</button>
{open && (
<div style={{ position: 'absolute', top: '100%', left: 0, background: '#fff', border: '1px solid #ccc', borderRadius: 4, zIndex: 50 }}>
{notebooks.filter((nb) => nb.id !== current.id).map((nb) => (
<button
key={nb.id}
onClick={async () => { await onMove(nb.id); setOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '4px 10px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11 }}
>
{nb.name}
</button>
))}
</div>
)}
</span>
);
}
- Step 3: 테스트 통과 + commit
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:
const [scope, setScope] = useState<'current' | 'all'>('current');
// fetch 시 notebookId 옵션:
const notebookId = scope === 'current' ? selectedNotebookId : undefined;
const results = await inboxApi.search(query, { notebookId });
UI 에 작은 dropdown 또는 토글:
<select value={scope} onChange={(e) => setScope(e.target.value as 'current' | 'all')}>
<option value="current">이 노트북</option>
<option value="all">모든 노트북</option>
</select>
- Step 2: 테스트 통과 + commit
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 반영
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:
selectNotebook(id) {
set({ selectedNotebookId: id });
void get().loadByView(get().view);
void get().refreshMeta();
}
- Step 2: typecheck + 전체 test
npm run typecheck && npm test
- Step 3: commit
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
## [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
git add CHANGELOG.md package.json
git commit -m "chore(release): v0.4.0 — notebooks + lifecycle 3분기"
Self-Review
- Spec coverage: §3 범위 (notebook 모델 B / lifecycle 3분기 / default notebook / 사이드바 토글 / search scope / RESTRICT / 자동 fit) 모두 task 1-19 에 분배.
- AI × Notebook (§11): Task 8 (prompt + schema) / Task 9 (AiWorker matching) / Task 11 (promotionCandidate store) / Task 14 (PromotionBanner UI) 로 분해.
- placeholders: TBD / TODO / "implement later" 없음. 모든 step 에 실제 code block.
- type consistency:
Notebook/NotebookApi/PromotionCandidate모두 shared/types.ts 에 정의, 후속 task 가 동일 시그니처 사용. - migration version: m008 (m007 이 FTS 로 사용중이므로 충돌 회피).
- archived 제거: status enum + UI 헤더 + MoveStatusModal + listByStatus IPC + countsByStatus IPC 일관 적용.
- 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?