From 06a1caf2bd74acc481e149265550a25c95fff498 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:27:15 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat(v029):=20m004=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=E2=80=94=20status/statu?= =?UTF-8?q?s=5Fchanged=5Fat/move=5Freason=20=EC=BB=AC=EB=9F=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notes 테이블 ADD COLUMN status (DEFAULT 'active'), status_changed_at, move_reason - deleted_at != NULL 노트 → status='trashed' + status_changed_at=deleted_at 로 backfill - index.ts registry 에 m004 추가 (runMigrations 자동 적용) - migrations.test.ts user_version assertion 4 로 갱신 --- src/main/db/migrations/index.ts | 3 +- src/main/db/migrations/m004_status.ts | 18 ++++++ tests/unit/m004-migration.test.ts | 80 +++++++++++++++++++++++++++ tests/unit/migrations.test.ts | 4 +- 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/main/db/migrations/m004_status.ts create mode 100644 tests/unit/m004-migration.test.ts diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 61129bd..a3630de 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -2,8 +2,9 @@ import type Database from 'better-sqlite3'; import * as m001 from './m001_initial.js'; import * as m002 from './m002_due_date.js'; import * as m003 from './m003_soft_delete.js'; +import * as m004 from './m004_status.js'; -const migrations = [m001, m002, m003]; +const migrations = [m001, m002, m003, m004]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m004_status.ts b/src/main/db/migrations/m004_status.ts new file mode 100644 index 0000000..2225506 --- /dev/null +++ b/src/main/db/migrations/m004_status.ts @@ -0,0 +1,18 @@ +// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at. +// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은 +// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능. +import type Database from 'better-sqlite3'; + +export const version = 4; + +export function up(db: Database.Database): void { + db.exec(` + ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE notes ADD COLUMN status_changed_at TEXT; + ALTER TABLE notes ADD COLUMN move_reason TEXT; + `); + db.prepare( + `UPDATE notes SET status='trashed', status_changed_at=deleted_at + WHERE deleted_at IS NOT NULL` + ).run(); +} diff --git a/tests/unit/m004-migration.test.ts b/tests/unit/m004-migration.test.ts new file mode 100644 index 0000000..c804c99 --- /dev/null +++ b/tests/unit/m004-migration.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m004_status.js'; + +describe('m004 migration — status column', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + // m003 baseline (notes 테이블 with deleted_at, real schema 따름) + db.exec(` + CREATE TABLE notes ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0, + summary_edited_by_user INTEGER NOT NULL DEFAULT 0, + user_intent TEXT, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + due_date_edited_by_user INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, + last_recalled_at TEXT, + recall_dismissed_at TEXT + ); + INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at) + VALUES ('a', 't1', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL), + ('b', 't2', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z'); + `); + }); + + afterEach(() => { + db.close(); + }); + + it('adds status / status_changed_at / move_reason columns', () => { + up(db); + const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + expect(names).toContain('status'); + expect(names).toContain('status_changed_at'); + expect(names).toContain('move_reason'); + }); + + it('default status="active" for non-deleted notes', () => { + up(db); + const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string }; + expect(a.status).toBe('active'); + }); + + it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => { + up(db); + const b = db + .prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`) + .get('b') as { status: string; status_changed_at: string }; + expect(b.status).toBe('trashed'); + expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z'); + }); + + it('move_reason NULL by default', () => { + up(db); + const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as { + move_reason: string | null; + }; + expect(a.move_reason).toBeNull(); + }); + + it('version exported as 4', async () => { + const mod = await import('../../src/main/db/migrations/m004_status.js'); + expect(mod.version).toBe(4); + }); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index b4b66a8..fc8337c 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); - it('user_version reaches 3', () => { + it('user_version reaches 4', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(3); + expect(row.user_version).toBe(4); db.close(); }); From facbf54025290beb11cd85e2ccfd0ac101dee97b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:33:49 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(v029):=20NoteRepository.setStatus=20?= =?UTF-8?q?+=20listByStatus=20+=20restoreNote=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoteStatus 타입 추가 ('active'/'completed'/'archived'/'trashed') - Note interface 에 status / statusChangedAt / moveReason 필드 추가 - setStatus(id, status, reason, now?) — 단일 transaction 으로 status + move_reason + status_changed_at + updated_at 갱신. status='trashed' ↔ deleted_at 동기화 (backward compat). 그 외 status 는 deleted_at NULL. - listByStatus(status, opts) — status 별 필터 + ORDER BY COALESCE(status_changed_at, created_at) DESC. limit cap 200. - hydrate 에 status / statusChangedAt / moveReason 매핑 추가. 미설정 row 는 'active' fallback. - restoreNote 재구현 — setStatus('active', null) 로 status + deleted_at 동기화 + v0.2.6 #10 round 1 fix (ai_status='failed'/'pending' → pending_jobs 재투입) 보존. - 기존 테스트 fixture 5건에 새 필드 추가 (NoteCard, store.expired/recall/tagFilter/trash). - 신규 테스트 11건 (setStatus + listByStatus + restoreNote 회귀). --- src/main/repository/NoteRepository.ts | 92 +++++++++++++++--- src/shared/types.ts | 7 ++ tests/unit/NoteCard.test.tsx | 3 + tests/unit/NoteRepository.test.ts | 128 ++++++++++++++++++++++++++ tests/unit/store.expired.test.ts | 1 + tests/unit/store.recall.test.ts | 3 +- tests/unit/store.tagFilter.test.ts | 3 + tests/unit/store.trash.test.ts | 1 + 8 files changed, 225 insertions(+), 13 deletions(-) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 3d47050..75e92aa 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,6 +1,6 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { Note, NoteMedia, NoteTag } from '@shared/types'; +import type { Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -410,25 +410,90 @@ export class NoteRepository { .run(now, id); } + /** + * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed). + * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at + * backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL). + * + * 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). + */ + setStatus( + id: string, + status: NoteStatus, + reason: string | null, + now: Date = new Date() + ): void { + const tx = this.db.transaction(() => { + const ts = now.toISOString(); + this.db + .prepare( + `UPDATE notes + SET status = ?, + move_reason = ?, + status_changed_at = ?, + updated_at = ? + WHERE id = ?` + ) + .run(status, reason, ts, ts, id); + // backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화. + if (status === 'trashed') { + this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id); + } else { + this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id); + } + }); + tx(); + } + + /** + * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), + * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). + */ + listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit ?? 200)); + const rows = this.db + .prepare( + `SELECT * FROM notes + WHERE status = ? + ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC + LIMIT ?` + ) + .all(status, limit) as Record[]; + return rows.map((r) => this.hydrate(r)); + } + + /** + * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). + */ restoreNote(id: string): void { const tx = this.db.transaction(() => { - const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const before = this.db + .prepare(`SELECT ai_status FROM notes WHERE id = ?`) + .get(id) as { ai_status: string } | undefined; + // setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신. + this.setStatus(id, 'active', null); const now = new Date().toISOString(); - this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 if (before?.ai_status === 'failed') { - this.db.prepare( - `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` - ).run(now, id); - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ) + .run(now, id); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } else if (before?.ai_status === 'pending') { // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent) - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } // done 노트는 재처리 안 함 (이미 결과 있음) }); @@ -669,6 +734,9 @@ export class NoteRepository { deletedAt: (row.deleted_at as string | null) ?? null, lastRecalledAt: (row.last_recalled_at as string | null) ?? null, recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null, + status: ((row.status as NoteStatus | undefined) ?? 'active'), + statusChangedAt: (row.status_changed_at as string | null) ?? null, + moveReason: (row.move_reason as string | null) ?? null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, tags: tags as NoteTag[], diff --git a/src/shared/types.ts b/src/shared/types.ts index 4e78fd8..7447c6c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,9 @@ export interface NoteMedia { export type AiStatus = 'pending' | 'done' | 'failed'; +// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus. +export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; + export interface NoteTag { name: string; source: 'ai' | 'user'; @@ -37,6 +40,10 @@ export interface Note { deletedAt: string | null; lastRecalledAt: string | null; recallDismissedAt: string | null; + // 신규 v4 (v0.2.9 Cut B): + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; createdAt: string; updatedAt: string; tags: NoteTag[]; diff --git a/tests/unit/NoteCard.test.tsx b/tests/unit/NoteCard.test.tsx index cff2f66..7d64ddb 100644 --- a/tests/unit/NoteCard.test.tsx +++ b/tests/unit/NoteCard.test.tsx @@ -48,6 +48,9 @@ const baseNote: Note = { deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, createdAt: '2026-05-09T00:00:00Z', updatedAt: '2026-05-09T00:00:00Z', tags: [], diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 37e6abb..9c7186d 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -852,3 +852,131 @@ describe('NoteRepository — failed retry helpers', () => { expect(repo.getTagIdByName('nothere')).toBeNull(); }); }); + +describe('NoteRepository — setStatus + listByStatus', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('setStatus updates status + reason + status_changed_at + updated_at', () => { + const { id } = repo.create({ rawText: 'test' }); + repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z')); + const note = repo.findById(id)!; + expect(note.status).toBe('completed'); + expect(note.moveReason).toBe('결재 끝'); + expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z'); + expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z'); + }); + + it('setStatus accepts null reason', () => { + const { id } = repo.create({ rawText: 'test' }); + repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + const note = repo.findById(id)!; + expect(note.status).toBe('archived'); + expect(note.moveReason).toBeNull(); + }); + + it('setStatus default now uses Date.now()', () => { + const { id } = repo.create({ rawText: 'test' }); + const before = Date.now(); + repo.setStatus(id, 'completed', null); + const after = Date.now(); + const note = repo.findById(id)!; + const ts = new Date(note.statusChangedAt!).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + + it('listByStatus filters correctly', () => { + const idA = repo.create({ rawText: 'a' }).id; + const idB = repo.create({ rawText: 'b' }).id; + repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + + const active = repo.listByStatus('active', { limit: 10 }); + const archived = repo.listByStatus('archived', { limit: 10 }); + expect(active.map((n) => n.id)).toContain(idA); + expect(active.map((n) => n.id)).not.toContain(idB); + expect(archived.map((n) => n.id)).toContain(idB); + expect(archived.map((n) => n.id)).not.toContain(idA); + }); + + it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); + repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z')); + repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z')); + const r = repo.listByStatus('completed', { limit: 10 }); + expect(r.map((n) => n.id)).toEqual([b, c, a]); + }); + + it('listByStatus respects limit (cap 200)', () => { + for (let i = 0; i < 5; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`)); + } + expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3); + expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5); + }); + + it('listByStatus default limit 200', () => { + repo.create({ rawText: 'a' }); + expect(repo.listByStatus('active')).toHaveLength(1); + }); + + it('setStatus("trashed") syncs deleted_at (backward compat)', () => { + const { id } = repo.create({ rawText: 't' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { + deleted_at: string; + }; + expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z'); + expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z'); + }); + + it('setStatus("active") clears deleted_at (restore from trash)', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z')); + const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { + deleted_at: string | null; + }; + expect(row.deleted_at).toBeNull(); + expect(repo.findById(id)!.deletedAt).toBeNull(); + }); + + it('setStatus("completed"/"archived") also clears deleted_at', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z')); + expect(repo.findById(id)!.deletedAt).toBeNull(); + }); + + it('newly created note hydrates as status=active', () => { + const { id } = repo.create({ rawText: 'fresh' }); + const note = repo.findById(id)!; + expect(note.status).toBe('active'); + expect(note.statusChangedAt).toBeNull(); + expect(note.moveReason).toBeNull(); + }); + + it('restoreNote sets status=active + clears moveReason', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z')); + expect(repo.findById(id)!.status).toBe('trashed'); + expect(repo.findById(id)!.moveReason).toBe('실수'); + + repo.restoreNote(id); + + const after = repo.findById(id)!; + expect(after.status).toBe('active'); + expect(after.moveReason).toBeNull(); + expect(after.deletedAt).toBeNull(); + }); +}); diff --git a/tests/unit/store.expired.test.ts b/tests/unit/store.expired.test.ts index f94e0ec..02b3735 100644 --- a/tests/unit/store.expired.test.ts +++ b/tests/unit/store.expired.test.ts @@ -32,6 +32,7 @@ const noteStub = (id: string): Note => ({ userIntent: null, intentPromptedAt: null, dueDate: '2026-04-20', dueDateEditedByUser: false, deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', statusChangedAt: null, moveReason: null, createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', tags: [], media: [] }); diff --git a/tests/unit/store.recall.test.ts b/tests/unit/store.recall.test.ts index eb67a18..43e636a 100644 --- a/tests/unit/store.recall.test.ts +++ b/tests/unit/store.recall.test.ts @@ -37,7 +37,8 @@ const note = (id: string): Note => ({ dueDate: null, dueDateEditedByUser: false, userIntent: null, intentPromptedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - deletedAt: null, lastRecalledAt: null, recallDismissedAt: null + deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', statusChangedAt: null, moveReason: null }); describe('store recall actions', () => { diff --git a/tests/unit/store.tagFilter.test.ts b/tests/unit/store.tagFilter.test.ts index 66dbbb5..19d1329 100644 --- a/tests/unit/store.tagFilter.test.ts +++ b/tests/unit/store.tagFilter.test.ts @@ -21,6 +21,9 @@ function sample(id: string, tags: string[]): Note { deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, createdAt: '2026-04-26T00:00:00Z', updatedAt: '2026-04-26T00:00:00Z', tags: tags.map((name) => ({ name, source: 'ai' as const })), diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts index fff4a9a..7bb173d 100644 --- a/tests/unit/store.trash.test.ts +++ b/tests/unit/store.trash.test.ts @@ -30,6 +30,7 @@ const noteStub = (id: string, deletedAt: string | null = null): Note => ({ userIntent: null, intentPromptedAt: null, dueDate: null, dueDateEditedByUser: false, deletedAt, lastRecalledAt: null, recallDismissedAt: null, + status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null, createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', tags: [], media: [] }); From fd839f6afe73014b79463641fcec91a3e00d4cd9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:43:01 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(v029):=20ai=5Fstatus=20'disabled'=20?= =?UTF-8?q?enum=20+=20CaptureService=20ai=5Fenabled=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=20(skip=20pending=5Fjobs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiStatus enum 'disabled' 추가 — settings.ai_enabled=false 일 때 새 노트의 초기 status. - m005 migration: ai_status CHECK 제약을 ('pending','done','failed','disabled') 로 relax. SQLite 가 ALTER COLUMN CHECK 미지원 → table recreate (notes_new INSERT SELECT DROP RENAME). 기존 인덱스 (idx_notes_created_at, idx_notes_ai_status, idx_notes_deleted_at) 재생성. - SettingsService schema 에 ai_enabled / onboarding_completed (optional) 추가 + isAiEnabled / setAiEnabled / isOnboardingCompleted / setOnboardingCompleted accessor. 기본 fallback (ai_enabled=true, onboarding_completed=false) — 기존 settings.json 무영향. - NoteRepository.create 가 optional aiStatus 받도록 — 'pending' 외 값일 때 pending_jobs skip. 기존 caller (rawText 만 전달) 무영향. - CaptureService deps 에 settings (좁은 AiEnabledSource 인터페이스) 추가. submit() 가 ai_enabled 조회 → false 면 ai_status='disabled' insert + enqueue skip. settings 미주입 시 기존 동작 (항상 enabled) 보존 — 테스트 케이스 무영향. - main/index.ts wiring: settings: settingsSvc 주입. Tests: 489 → 494 (CaptureService ai_enabled 2건 + m005 migration 3건). typecheck 0. --- src/main/db/migrations/index.ts | 3 +- src/main/db/migrations/m005_ai_disabled.ts | 65 ++++++++++++++++++++++ src/main/index.ts | 3 +- src/main/repository/NoteRepository.ts | 29 +++++++--- src/main/services/CaptureService.ts | 22 +++++++- src/main/services/SettingsService.ts | 38 ++++++++++++- src/shared/types.ts | 2 +- tests/unit/CaptureService.test.ts | 45 +++++++++++++++ tests/unit/migrations.test.ts | 48 +++++++++++++++- 9 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 src/main/db/migrations/m005_ai_disabled.ts diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index a3630de..cbbfc00 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -3,8 +3,9 @@ import * as m001 from './m001_initial.js'; import * as m002 from './m002_due_date.js'; import * as m003 from './m003_soft_delete.js'; import * as m004 from './m004_status.js'; +import * as m005 from './m005_ai_disabled.js'; -const migrations = [m001, m002, m003, m004]; +const migrations = [m001, m002, m003, m004, m005]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m005_ai_disabled.ts b/src/main/db/migrations/m005_ai_disabled.ts new file mode 100644 index 0000000..e97b0e9 --- /dev/null +++ b/src/main/db/migrations/m005_ai_disabled.ts @@ -0,0 +1,65 @@ +// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때 +// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip. +// +// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴. +// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라 +// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new. +// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈). +import type Database from 'better-sqlite3'; + +export const version = 5; + +export function up(db: Database.Database): void { + // 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가. + db.exec(` + PRAGMA foreign_keys=OFF; + CREATE TABLE notes_new ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed','disabled')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (title_edited_by_user IN (0,1)), + summary_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (summary_edited_by_user IN (0,1)), + user_intent TEXT, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + due_date_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (due_date_edited_by_user IN (0,1)), + deleted_at TEXT, + last_recalled_at TEXT, + recall_dismissed_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + status_changed_at TEXT, + move_reason TEXT + ); + INSERT INTO notes_new ( + id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at, + created_at, updated_at, due_date, due_date_edited_by_user, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + ) + SELECT + id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at, + created_at, updated_at, due_date, due_date_edited_by_user, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + FROM notes; + DROP TABLE notes; + ALTER TABLE notes_new RENAME TO notes; + CREATE INDEX idx_notes_created_at ON notes(created_at DESC); + CREATE INDEX idx_notes_ai_status ON notes(ai_status); + CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at); + PRAGMA foreign_keys=ON; + `); +} diff --git a/src/main/index.ts b/src/main/index.ts index 949d546..85284a2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -159,7 +159,8 @@ app.whenReady().then(async () => { const capture = new CaptureService(repo, store, { enqueue: (id) => worker.enqueue(id), celebrate: (id) => notify.celebrate(id), - telemetry + telemetry, + settings: settingsSvc }); registerCaptureApi(capture, getQuickCaptureWindow); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 75e92aa..b93e6a2 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,9 +1,16 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; +import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; -export interface CreateNoteInput { rawText: string; } +export interface CreateNoteInput { + rawText: string; + /** + * v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip. + * 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat). + */ + aiStatus?: AiStatus; +} export interface NewMediaRow { noteId: string; @@ -48,15 +55,19 @@ export class NoteRepository { create(input: CreateNoteInput): { id: string } { const id = uuidv7(); const now = new Date().toISOString(); + const aiStatus: AiStatus = input.aiStatus ?? 'pending'; const tx = this.db.transaction(() => { this.db .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) - VALUES (?, ?, 'pending', ?, ?)`) - .run(id, input.rawText, now, now); - this.db - .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) - VALUES (?, 0, ?)`) - .run(id, now); + VALUES (?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, now, now); + // pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함. + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, now); + } }); tx(); return { id }; @@ -721,7 +732,7 @@ export class NoteRepository { rawText: row.raw_text as string, aiTitle: row.ai_title as string | null, aiSummary: row.ai_summary as string | null, - aiStatus: row.ai_status as 'pending' | 'done' | 'failed', + aiStatus: row.ai_status as AiStatus, aiError: row.ai_error as string | null, aiProvider: row.ai_provider as string | null, aiGeneratedAt: row.ai_generated_at as string | null, diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 4dc03e2..0ed33d7 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { MediaStore } from './MediaStore.js'; import type { Note } from '@shared/types'; +/** + * v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스. + * SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요). + */ +export interface AiEnabledSource { + isAiEnabled(): Promise; +} + export interface TelemetryEmitter { emit(input: | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } @@ -23,6 +31,9 @@ export interface CaptureDeps { enqueue: (noteId: string) => Promise; celebrate: (noteId: string) => void; telemetry?: TelemetryEmitter; + // v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip. + // 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향. + settings?: AiEnabledSource; } export interface SubmitInput { @@ -44,7 +55,12 @@ export class CaptureService { if (trimmed.length === 0 && input.images.length === 0) { throw new Error('empty submission'); } - const { id } = this.repo.create({ rawText: input.text }); + // v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat). + const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true; + const { id } = this.repo.create({ + rawText: input.text, + aiStatus: aiEnabled ? 'pending' : 'disabled' + }); if (input.images.length > 0) { const rows = []; for (const img of input.images) { @@ -70,7 +86,9 @@ export class CaptureService { } }).catch(() => {}); } - await this.deps.enqueue(id); + if (aiEnabled) { + await this.deps.enqueue(id); + } this.deps.celebrate(id); return { noteId: id }; } diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 31e4d9e..a637a06 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -8,7 +8,12 @@ const OllamaSettingsSchema = z.object({ }).strict(); const SettingsSchema = z.object({ - ollama: OllamaSettingsSchema.optional() + ollama: OllamaSettingsSchema.optional(), + // v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가 + // true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 — + // load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동. + ai_enabled: z.boolean().optional(), + onboarding_completed: z.boolean().optional() }).strict(); export type Settings = z.infer; @@ -38,6 +43,37 @@ export class SettingsService { const validated = OllamaSettingsSchema.parse(value); const current = await this.load(); const next: Settings = { ...current, ollama: validated }; + await this.persist(next); + } + + /** + * v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을 + * 가진 사용자 (ai_enabled 키 부재) 도 무영향. + */ + async isAiEnabled(): Promise { + const s = await this.load(); + return s.ai_enabled ?? true; + } + + async setAiEnabled(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, ai_enabled: value }; + await this.persist(next); + } + + /** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */ + async isOnboardingCompleted(): Promise { + const s = await this.load(); + return s.onboarding_completed ?? false; + } + + async setOnboardingCompleted(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, onboarding_completed: value }; + await this.persist(next); + } + + private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); diff --git a/src/shared/types.ts b/src/shared/types.ts index 7447c6c..609f0c9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,7 +11,7 @@ export interface NoteMedia { bytes: number; } -export type AiStatus = 'pending' | 'done' | 'failed'; +export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled'; // v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus. export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 513f9fc..e0e10bf 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => { }); }); +describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let enqueued: string[]; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-')); + store = new MediaStore(tmp); + enqueued = []; + }); + + it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => { + const settings = { isAiEnabled: async () => false }; + const svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + settings + }); + const { noteId } = await svc.submit({ text: 'no-ai', images: [] }); + expect(repo.findById(noteId)?.aiStatus).toBe('disabled'); + expect(enqueued).toEqual([]); + const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId); + expect(row).toBeUndefined(); + }); + + it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => { + const settings = { isAiEnabled: async () => true }; + const svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + settings + }); + const { noteId } = await svc.submit({ text: 'with-ai', images: [] }); + expect(repo.findById(noteId)?.aiStatus).toBe('pending'); + expect(enqueued).toEqual([noteId]); + const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId); + expect(row).toBeDefined(); + }); +}); + describe('CaptureService recall methods (v0.2.3 #6)', () => { let db: Database.Database; let repo: NoteRepository; diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index fc8337c..7936510 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); - it('user_version reaches 4', () => { + it('user_version reaches latest (5)', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(4); + expect(row.user_version).toBe(5); db.close(); }); @@ -73,3 +73,47 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); }); + +describe('migration v5 — ai_status disabled enum', () => { + it("CHECK constraint accepts 'disabled'", () => { + const db = new Database(':memory:'); + runMigrations(db); + expect(() => { + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES ('d1', 't', 'disabled', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')` + ).run(); + }).not.toThrow(); + db.close(); + }); + + it('preserves existing notes (status, due_date, deleted_at, recall fields)', () => { + // m004 까지만 적용된 상태에서 데이터 insert 후 m005 까지 마이그레이션 → 데이터 보존 확인. + // runMigrations 가 user_version 으로 idempotent 라 한 번에 5 까지 가지만, + // 본 테스트는 single runMigrations 후 m004 시점에 가까운 row 를 넣고 cols 확인. + const db = new Database(':memory:'); + runMigrations(db); + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status, due_date, deleted_at) + VALUES ('p1', 'old', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'archived', '2026-05-10', NULL)` + ).run(); + const row = db.prepare('SELECT status, due_date, ai_status FROM notes WHERE id=?').get('p1') as any; + expect(row.status).toBe('archived'); + expect(row.due_date).toBe('2026-05-10'); + expect(row.ai_status).toBe('done'); + db.close(); + }); + + it('preserves idx_notes_ai_status + idx_notes_created_at + idx_notes_deleted_at', () => { + const db = new Database(':memory:'); + runMigrations(db); + const indexes = db + .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`) + .all() as Array<{ name: string }>; + const names = indexes.map((i) => i.name); + expect(names).toContain('idx_notes_ai_status'); + expect(names).toContain('idx_notes_created_at'); + expect(names).toContain('idx_notes_deleted_at'); + db.close(); + }); +}); From 606ac94976964b70e833508cb1a9355a864325b9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:51:51 +0900 Subject: [PATCH 04/18] feat(v029): useInbox view enum + counts + setView + listByStatus/countsByStatus IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.ts: view enum ('inbox'|'completed'|'archived'|'trash'|'settings') + counts + setView + loadByView. setShowSettings delegates to setView (mirror). - types.ts + preload + ipc/inboxApi: listByStatus + countsByStatus IPC. - NoteRepository.countByStatus 신규. - store.view.test (5) + NoteRepository.countByStatus test (1). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ipc/inboxApi.ts | 20 ++++++- src/main/repository/NoteRepository.ts | 11 ++++ src/preload/index.ts | 3 + src/renderer/inbox/store.ts | 56 ++++++++++++++--- src/shared/types.ts | 3 + tests/unit/NoteRepository.test.ts | 18 ++++++ tests/unit/store.view.test.ts | 86 +++++++++++++++++++++++++++ 7 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 tests/unit/store.view.test.ts diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 4da6e71..51c8c67 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -7,7 +7,7 @@ import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; import type { HealthChecker } from '../services/HealthChecker.js'; import type { IntentService } from '../services/IntentService.js'; -import type { Note } from '@shared/types'; +import type { Note, NoteStatus } from '@shared/types'; import type { HealthResult } from '../ai/InferenceProvider.js'; import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; import type { SettingsService } from '../services/SettingsService.js'; @@ -172,6 +172,24 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: true as const }; }); + // v0.2.9 Cut B Task 4 — status 별 노트 목록. + ipcMain.handle( + 'inbox:list-by-status', + (_e, status: NoteStatus, opts: { limit?: number } = {}) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) return [] as Note[]; + return deps.repo.listByStatus(status, opts); + } + ); + + // v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge). + ipcMain.handle('inbox:counts-by-status', () => ({ + active: deps.repo.countByStatus('active'), + completed: deps.repo.countByStatus('completed'), + archived: deps.repo.countByStatus('archived'), + trashed: deps.repo.countByStatus('trashed') + })); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index b93e6a2..adaecb1 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -456,6 +456,17 @@ export class NoteRepository { tx(); } + /** + * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용. + * tags/media hydrate 없음 (cheap path, listByStatus 와 별도). + */ + countByStatus(status: NoteStatus): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`) + .get(status) as { c: number }; + return row.c; + } + /** * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ffe824..e93fdea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -68,6 +68,9 @@ const api: InklingApi = { copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'), // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath), + // v0.2.9 Cut B Task 4 — status 별 list + counts. + listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), + countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'), } }; diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 3cc22ae..7abe2fe 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -5,12 +5,26 @@ import { nextKstMidnightMs } from '@shared/util/kstDate.js'; export { selectFilteredNotes } from './selectFilteredNotes.js'; +// v0.2.9 Cut B Task 4 — 4탭 view enum + settings. +// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage. +export type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'; + +export interface InboxCounts { + active: number; + completed: number; + archived: number; + trashed: number; +} + interface InboxState { notes: Note[]; trashNotes: Note[]; trashCount: number; showTrash: boolean; showSettings: boolean; + // v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류. + view: InboxView; + counts: InboxCounts; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; @@ -28,6 +42,8 @@ interface InboxState { removeNote: (id: string) => void; setTagFilter: (tag: string | null) => void; setShowSettings: (open: boolean) => void; + setView: (view: InboxView) => void; + loadByView: (view: 'completed' | 'archived' | 'trash') => Promise; toggleShowTrash: () => Promise; loadTrash: () => Promise; restoreNote: (id: string) => Promise; @@ -55,6 +71,8 @@ export const useInbox = create((set, get) => ({ trashCount: 0, showTrash: false, showSettings: false, + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, @@ -68,7 +86,7 @@ export const useInbox = create((set, get) => ({ recallSnoozeUntilMs: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), @@ -77,12 +95,13 @@ export const useInbox = create((set, get) => ({ inboxApi.getTrashCount(), inboxApi.listExpired(), inboxApi.getFailedCount(), - inboxApi.listRecallCandidate() + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), @@ -90,9 +109,10 @@ export const useInbox = create((set, get) => ({ inboxApi.getTrashCount(), inboxApi.listExpired(), inboxApi.getFailedCount(), - inboxApi.listRecallCandidate() + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts }); }, upsertNote(note) { // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 @@ -138,7 +158,29 @@ export const useInbox = create((set, get) => ({ set({ tagFilter: tag }); }, setShowSettings(open) { - set({ showSettings: open }); + // backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신. + if (open) get().setView('settings'); + else get().setView('inbox'); + }, + setView(view) { + set({ + view, + showTrash: view === 'trash', + showSettings: view === 'settings' + }); + // settings/inbox 외 status view 면 해당 status fetch. + if (view === 'completed' || view === 'archived' || view === 'trash') { + void get().loadByView(view); + } + }, + async loadByView(view) { + const status = view === 'trash' ? 'trashed' : view; + const notes = await inboxApi.listByStatus(status, { limit: 200 }); + if (view === 'trash') { + set({ trashNotes: notes, trashCount: notes.length }); + } else { + set({ notes }); + } }, async toggleShowTrash() { const next = !get().showTrash; diff --git a/src/shared/types.ts b/src/shared/types.ts index 609f0c9..cad1cba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -135,6 +135,9 @@ export interface InboxApi { copyAppInfo(): Promise; // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>; + // v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count. + listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise; + countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>; } export interface InklingApi { diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 9c7186d..28f3b26 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -966,6 +966,24 @@ describe('NoteRepository — setStatus + listByStatus', () => { expect(note.moveReason).toBeNull(); }); + it('countByStatus returns accurate count per status', () => { + const a = repo.create({ rawText: 'a' }).id; // active + repo.create({ rawText: 'b' }); // active + const c = repo.create({ rawText: 'c' }).id; + repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); + const d = repo.create({ rawText: 'd' }).id; + repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + const e = repo.create({ rawText: 'e' }).id; + repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z')); + + expect(repo.countByStatus('active')).toBe(2); + expect(repo.countByStatus('completed')).toBe(1); + expect(repo.countByStatus('archived')).toBe(1); + expect(repo.countByStatus('trashed')).toBe(1); + // sanity — a 가 여전히 active. + expect(repo.findById(a)!.status).toBe('active'); + }); + it('restoreNote sets status=active + clears moveReason', () => { const { id } = repo.create({ rawText: 'r' }); repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z')); diff --git a/tests/unit/store.view.test.ts b/tests/unit/store.view.test.ts new file mode 100644 index 0000000..4615275 --- /dev/null +++ b/tests/unit/store.view.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Note } from '@shared/types'; + +const mockApi = { + listNotes: vi.fn(async () => [] as Note[]), + listTrash: vi.fn(async () => [] as Note[]), + listByStatus: vi.fn(async () => [] as Note[]), + countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + getTrashCount: vi.fn(async () => 0), + getContinuity: vi.fn(async () => ({ + weekStart: '', weekCount: 0, weekTarget: 7, + consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null + })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + getFailedCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => [] as Note[]), + listRecallCandidate: vi.fn(async () => null), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + ollamaRecheck: vi.fn(async () => ({ ok: true })), + retryAllFailed: vi.fn(async () => {}), + markRecallOpened: vi.fn(async () => {}), + dismissRecall: vi.fn(async () => {}), + emitRecallSnoozed: vi.fn(async () => {}) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +describe('inbox store — view enum', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, + notes: [], trashNotes: [], trashCount: 0, + showTrash: false, showSettings: false, + loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, + ollamaStatus: { ok: true }, + continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, + expiredCandidates: [], expiredSnoozeUntilMs: null, + failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('initial view is inbox', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + expect(useInbox.getState().view).toBe('inbox'); + }); + + it('setView changes view', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('completed'); + expect(useInbox.getState().view).toBe('completed'); + }); + + it('counts initialized to zero per status', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 }); + }); + + it('backward-compat: showTrash mirrors view==="trash"', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('trash'); + expect(useInbox.getState().showTrash).toBe(true); + useInbox.getState().setView('inbox'); + expect(useInbox.getState().showTrash).toBe(false); + }); + + it('backward-compat: showSettings mirrors view==="settings"', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setView('settings'); + expect(useInbox.getState().showSettings).toBe(true); + useInbox.getState().setView('inbox'); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); From 92375edc31d90d79d7cb0666045e02fabcfe82c3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:51:59 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(v029):=20=ED=97=A4=EB=8D=94=204?= =?UTF-8?q?=ED=83=AD=20(Inbox/=EC=99=84=EB=A3=8C/=EB=B3=B4=EA=B4=80/?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5)=20+=20count=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: 기존 2탭 (Inbox/휴지통) → 4탭. setView/counts 사용. - onNavigate 도 setView 로 위임 (mirror state 동기 갱신). - App.test: 4탭 렌더 + 클릭 → setView('completed') + aria-pressed (3 cases). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/App.tsx | 46 +++++++++++++++++++------------------- tests/unit/App.test.tsx | 44 +++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 8421d73..8aa6e98 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -23,6 +23,10 @@ export function App(): React.ReactElement { } = useInbox(); const showSettings = useInbox((s) => s.showSettings); const setShowSettings = useInbox((s) => s.setShowSettings); + // v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통). + const view = useInbox((s) => s.view); + const counts = useInbox((s) => s.counts); + const setView = useInbox((s) => s.setView); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); useEffect(() => { @@ -35,15 +39,8 @@ export function App(): React.ReactElement { useInbox.setState({ ollamaStatus: status }); }); const unsubNav = inboxApi.onNavigate((view) => { - if (view === 'settings') { - useInbox.getState().setShowSettings(true); - } else if (view === 'inbox') { - useInbox.getState().setShowSettings(false); - if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); - } else if (view === 'trash') { - useInbox.getState().setShowSettings(false); - if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); - } + // v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신. + useInbox.getState().setView(view); }); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); @@ -72,20 +69,23 @@ export function App(): React.ReactElement {

Inkling

- - + {( + [ + { key: 'inbox', label: 'Inbox', count: counts.active }, + { key: 'completed', label: '완료', count: counts.completed }, + { key: 'archived', label: '보관', count: counts.archived }, + { key: 'trash', label: '휴지통', count: counts.trashed } + ] as const + ).map((t) => ( + + ))}
diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 8e077f5..f289ebe 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -6,6 +6,8 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { listNotes: vi.fn(async () => []), + listByStatus: vi.fn(async () => []), + countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null @@ -58,7 +60,12 @@ import { inboxApi } from '../../src/renderer/inbox/api.js'; describe('App — settings view', () => { beforeEach(() => { cleanup(); - useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 }); + useInbox.setState({ + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, + showSettings: false, showTrash: false, + notes: [], trashNotes: [], trashCount: 0 + }); }); it('renders SettingsPage when showSettings=true', async () => { @@ -89,3 +96,38 @@ describe('App — settings view', () => { await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); }); }); + +describe('App header — 4 tabs', () => { + beforeEach(() => { + cleanup(); + useInbox.setState({ + view: 'inbox', + counts: { active: 5, completed: 3, archived: 2, trashed: 1 }, + notes: [], trashNotes: [], trashCount: 0, + showTrash: false, showSettings: false + }); + }); + + it('renders 4 tabs with counts', () => { + render(); + expect(screen.getByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument(); + }); + + it('clicking 완료 tab sets view=completed', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /완료/ })); + expect(useInbox.getState().view).toBe('completed'); + }); + + it('aria-pressed reflects current view', () => { + useInbox.setState({ view: 'archived' }); + render(); + const archivedBtn = screen.getByRole('button', { name: /보관/ }); + expect(archivedBtn.getAttribute('aria-pressed')).toBe('true'); + const inboxBtn = screen.getByRole('button', { name: /Inbox/ }); + expect(inboxBtn.getAttribute('aria-pressed')).toBe('false'); + }); +}); From d4dce9bf34008d2bd27a382ec1e7c12af83ceaee Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:59:43 +0900 Subject: [PATCH 06/18] feat(v029): inbox:set-status + ai:classify-status (stub) IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut B Task 8 — Modal/NoteCard 메뉴 path 의 IPC backbone. - inbox:set-status: id + status + reason → repo.setStatus, invalid status 거부 - ai:classify-status: stub (Task 9 에서 Ollama provider 호출로 정식 구현) - types.ts InboxApi.setStatus / classifyStatus 시그니처 + preload wire-up - 4 단위 테스트 (valid/null reason/invalid status/stub shape) --- src/main/ipc/inboxApi.ts | 25 +++++++ src/preload/index.ts | 3 + src/shared/types.ts | 7 ++ tests/unit/inboxApi-setStatus.test.ts | 95 +++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 tests/unit/inboxApi-setStatus.test.ts diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 51c8c67..f830c2d 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -190,6 +190,31 @@ export function registerInboxApi(deps: InboxIpcDeps): void { trashed: deps.repo.countByStatus('trashed') })); + // v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함). + // Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는 + // NoteRepository.setStatus 내부에서 처리 (deleted_at sync). + ipcMain.handle( + 'inbox:set-status', + async (_e, id: string, status: NoteStatus, reason: string | null) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) { + return { ok: false as const, reason: 'invalid status' as const }; + } + deps.repo.setStatus(id, status, reason); + return { ok: true as const }; + } + ); + + // v0.2.9 Cut B Task 8 — AI 자동 분류 stub. Task 9 에서 본격 구현 + // (Ollama provider 호출 + structured 출력). 본 task 는 Modal 의 + // 호출 path 만 working state 로 만들기 위한 안전 default. + ipcMain.handle('ai:classify-status', async (_e, _id: string, _reason: string) => { + return { + recommended: 'archived' as const, + rationale: '본 task 는 stub. Task 9 에서 정식 구현.' + }; + }); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/preload/index.ts b/src/preload/index.ts index e93fdea..38c17b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,9 @@ const api: InklingApi = { // v0.2.9 Cut B Task 4 — status 별 list + counts. listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'), + // v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천. + setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason), + classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index cad1cba..d8a2b63 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -138,6 +138,13 @@ export interface InboxApi { // v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count. listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise; countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>; + // v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천. + setStatus( + id: string, + status: NoteStatus, + reason: string | null + ): Promise<{ ok: true } | { ok: false; reason: string }>; + classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>; } export interface InklingApi { diff --git a/tests/unit/inboxApi-setStatus.test.ts b/tests/unit/inboxApi-setStatus.test.ts new file mode 100644 index 0000000..045652f --- /dev/null +++ b/tests/unit/inboxApi-setStatus.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { handlers, mockSetStatus } = vi.hoisted(() => ({ + handlers: {} as Record unknown>, + mockSetStatus: vi.fn() +})); + +vi.mock('electron', () => ({ + default: { + ipcMain: { + handle: (ch: string, fn: (...args: unknown[]) => unknown) => { + handlers[ch] = fn; + } + }, + dialog: {}, + shell: {} + } +})); + +import { registerInboxApi } from '../../src/main/ipc/inboxApi'; + +function makeDeps(): Parameters[0] { + // Minimal stub — `inbox:set-status` 핸들러는 deps.repo.setStatus 만 참조. + return { + repo: { + setStatus: mockSetStatus, + list: vi.fn(), + listByStatus: vi.fn(), + countByStatus: vi.fn(() => 0) + } as never, + continuity: {} as never, + capture: {} as never, + health: {} as never, + intent: {} as never, + getInboxWindow: () => null, + settings: {} as never, + providerHolder: {} as never, + paths: { profileDir: '/profile' } + }; +} + +describe('inbox:set-status IPC', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + mockSetStatus.mockReset(); + }); + + it('forwards valid status + reason to repo.setStatus', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, 'n1', 'completed', '결재 끝'); + expect(r).toEqual({ ok: true }); + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝'); + }); + + it('forwards null reason as-is', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, 'n1', 'archived', null); + expect(r).toEqual({ ok: true }); + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null); + }); + + it('rejects invalid status without calling repo', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = (await handler(null, 'n1', 'invalid', null)) as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid status'); + expect(mockSetStatus).not.toHaveBeenCalled(); + }); +}); + +describe('ai:classify-status IPC (stub)', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + }); + + it('returns recommendation shape (stub default)', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['ai:classify-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = (await handler(null, 'n1', '결재 끝')) as { + recommended: string; + rationale: string; + }; + expect(typeof r.recommended).toBe('string'); + expect(['active', 'completed', 'archived', 'trashed']).toContain(r.recommended); + expect(typeof r.rationale).toBe('string'); + expect(r.rationale.length).toBeGreaterThan(0); + }); +}); From 9eb7abc831e6f1deb12fec2e766d5a5ff9eccee8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 16:00:51 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat(v029):=20MoveStatusModal=20=E2=80=94?= =?UTF-8?q?=20=EC=82=AC=EC=9C=A0=20=EC=9E=85=EB=A0=A5=20+=204=20status=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20+=20AI=20=EC=9E=90=EB=8F=99=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut B Task 7 — NoteCard 메뉴가 여는 modal. - 사유 textarea (선택) + 활성/완료/보관/휴지통 버튼 (옮기기 즉시 setStatus + onMoved) - 빈 사유 → null reason 전달 (trim 처리) - AI 자동 분류 버튼 → classifyStatus(stub) 호출 + 추천 표시 + 확정 버튼 - statusLabel helper export (NoteCard 메뉴에서 재사용) - 4 단위 테스트 (render / 버튼 클릭 / AI 추천 흐름 / 빈 사유 null) --- .../inbox/components/MoveStatusModal.tsx | 141 ++++++++++++++++++ tests/unit/MoveStatusModal.test.tsx | 102 +++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 src/renderer/inbox/components/MoveStatusModal.tsx create mode 100644 tests/unit/MoveStatusModal.test.tsx diff --git a/src/renderer/inbox/components/MoveStatusModal.tsx b/src/renderer/inbox/components/MoveStatusModal.tsx new file mode 100644 index 0000000..9ac14f6 --- /dev/null +++ b/src/renderer/inbox/components/MoveStatusModal.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { inboxApi } from '../api.js'; +import type { NoteStatus } from '@shared/types'; + +interface Props { + noteId: string; + rawText: string; + summary: string; + initialTarget: NoteStatus; + onClose: () => void; + onMoved: (status: NoteStatus, reason: string | null) => void; +} + +/** + * v0.2.9 Cut B Task 7 — 메모 이동 Modal. + * + * 사유 입력 + 4 status 버튼 (활성/완료/보관/휴지통) + AI 자동 분류 placeholder. + * AI 분류는 Task 9 에서 정식 구현 — 본 task 는 stub IPC (`ai:classify-status`) + * 호출 path 만 working state. 추천 결과는 화면 표시 + 확정 버튼 두 단계. + */ +export function MoveStatusModal({ + noteId, + initialTarget: _initialTarget, + onClose, + onMoved +}: Props): React.ReactElement { + const [reason, setReason] = useState(''); + const [recommendation, setRecommendation] = useState<{ + status: NoteStatus; + rationale: string; + } | null>(null); + const [classifying, setClassifying] = useState(false); + + async function move(status: NoteStatus): Promise { + const trimmedReason = reason.trim() === '' ? null : reason.trim(); + await inboxApi.setStatus(noteId, status, trimmedReason); + onMoved(status, trimmedReason); + } + + async function classify(): Promise { + setClassifying(true); + setRecommendation(null); + try { + const r = await inboxApi.classifyStatus(noteId, reason); + setRecommendation({ status: r.recommended, rationale: r.rationale }); + } finally { + setClassifying(false); + } + } + + return ( +
+
+

메모 이동

+