From 1104a8c6663fce96e49033966264ecdd63cb10bc Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:11:12 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs(plan):=20v0.2.11=20Cut=20D=20?= =?UTF-8?q?=E2=80=94=20FTS5=20search=20+=20=ED=9A=8C=EA=B3=A0=20view=20(sp?= =?UTF-8?q?ec=20m006=E2=86=92m007=20=EC=A0=95=EC=A0=95=20+=20ai=5Ftitle/ai?= =?UTF-8?q?=5Fsummary=20+=20note=5Ftags=20JOIN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-10-v0211-cut-d-fts5-review.md | 1443 +++++++++++++++++ .../specs/2026-05-09-v0211-cut-d-design.md | 139 +- 2 files changed, 1547 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md diff --git a/docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md b/docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md new file mode 100644 index 0000000..730cd62 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md @@ -0,0 +1,1443 @@ +# v0.2.11 Cut D — FTS5 search + 회고 view 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:** F19-A (FTS5 search) + F19-D (일/주/월 회고 view). recall 핵심 가치 도달 — 누적 노트에서 키워드 검색 + 기간별 회고. + +**Architecture:** SQLite FTS5 virtual table `notes_fts` + AFTER trigger 3개 (notes 컬럼 자동 sync) + tags 변경은 NoteRepository 의 single write path 헬퍼 (`rebuildFtsTagsForNote`) 명시 호출. search = JOIN + MATCH + rank order + status filter. 회고 view = aggregate query (totalCount / recentNotes / tagCounts / dueProgress) + ReviewView 컴포넌트 + 헤더 드롭다운 진입. + +**Tech Stack:** SQLite FTS5 (better-sqlite3 12.9 내장, `tokenize='unicode61'`), Electron IPC, React 19 + zustand 5, vitest 4 + RTL. + +**선행 문서:** + +- `docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md` — source spec (m007 정정 반영) +- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut D 위치 +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F19 + +--- + +## File Structure + +**Create:** + +- `src/main/db/migrations/m007_fts.ts` — FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill (note_tags JOIN) +- `src/main/repository/ftsHelpers.ts` — `sanitizeFtsQuery`, `computeCutoff` (period → KST 자정 ISO) 순수 함수 +- `src/renderer/inbox/components/ReviewView.tsx` — 일/주/월 회고 화면 (총 N건 + tag bar + due progress + 최근 50 NoteCard) +- `src/renderer/inbox/components/SearchBox.tsx` — 헤더 search input (debounce 200ms) +- `tests/unit/m007-migration.test.ts` — 6 tests (table + trigger insert/update/delete + backfill + rebuildTagsForNote 기존 노트) +- `tests/unit/ftsHelpers.test.ts` — sanitize + computeCutoff 단위 +- `tests/unit/NoteRepository.search.test.ts` — search 매칭 + status filter + 빈 query +- `tests/unit/NoteRepository.reviewAggregate.test.ts` — period 별 카운트/태그/due +- `tests/unit/SearchBox.test.tsx` — debounce + 빈 값 → 기본 list +- `tests/unit/ReviewView.test.tsx` — aggregate 결과 렌더 + period 라벨 + +**Modify:** + +- `src/main/db/migrations/index.ts` — m007 import + 배열 추가 +- `src/main/repository/NoteRepository.ts` — 신규 `search` / `reviewAggregate` / `rebuildFtsTagsForNote` 헬퍼 + `updateAiResult` / `updateUserAiFields` 안에서 헬퍼 호출 +- `src/main/ipc/inboxApi.ts` — 2 신규 IPC handler (`inbox:search`, `inbox:review-aggregate`) +- `src/preload/index.ts` — 2 bridge 함수 +- `src/shared/types.ts` — `ReviewAggregate` interface + `InboxApi` 2 메서드 +- `src/renderer/inbox/store.ts` — view enum 확장 (`'review-daily' | 'review-weekly' | 'review-monthly'`) + searchQuery state + `searchNotes` action + `loadReview` action +- `src/renderer/inbox/App.tsx` — 헤더에 SearchBox + 회고 드롭다운 + ReviewView 라우트 +- `tests/unit/NoteRepository.test.ts` — `updateAiResult` / `updateUserAiFields` 가 FTS tags 컬럼 sync 회귀 1건 추가 +- `package.json` — version `0.2.10` → `0.2.11` +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F19 promoted 마킹 (A+D 옵션) + Cut D 라벨 + +--- + +## 단위 목표 + +569 (v0.2.10) → 약 595 (+26), typecheck 0. + +--- + +## Task 1: m007 migration — FTS5 virtual table + trigger + backfill + +**Files:** + +- Create: `src/main/db/migrations/m007_fts.ts` +- Create: `tests/unit/m007-migration.test.ts` +- Modify: `src/main/db/migrations/index.ts` + +FTS5 가상 테이블 `notes_fts` (UNINDEXED `note_id` + raw_text/ai_title/ai_summary/tags + tokenize='unicode61'). Trigger 3개 (AFTER INSERT/DELETE/UPDATE on notes) — raw_text/ai_title/ai_summary 자동 sync (tags 는 별도 헬퍼 path). Backfill = `status != 'trashed'` 노트만 INSERT (tags 는 note_tags+tags JOIN GROUP_CONCAT). + +- [ ] **Step 1: failing test 작성** — `tests/unit/m007-migration.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m007_fts.js'; + +describe('m007 migration — notes_fts virtual table + triggers', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + // m006 baseline (notes 가 모든 컬럼 + note_tags + tags + note_revisions 포함) + 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','disabled')), + 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, + status TEXT NOT NULL DEFAULT 'active', status_changed_at TEXT, move_reason TEXT + ); + CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE); + CREATE TABLE note_tags ( + note_id TEXT NOT NULL, tag_id INTEGER NOT NULL, source TEXT NOT NULL, + PRIMARY KEY(note_id, tag_id), + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES + ('a', '오늘 회의 정리', '회의록', '월요일 회의', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', 'active'), + ('b', '예전 메모', '예전 제목', '예전 요약', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'completed'), + ('c', '버려진 메모', '버린 제목', '버린 요약', 'done', '2026-03-01T00:00:00Z', '2026-03-01T00:00:00Z', 'trashed'); + INSERT INTO tags (id, name) VALUES (1, '기획'), (2, '회의'); + INSERT INTO note_tags (note_id, tag_id, source) VALUES ('a', 1, 'ai'), ('a', 2, 'user'); + `); + }); + + afterEach(() => { db.close(); }); + + it('creates notes_fts virtual table with FTS5 columns', () => { + up(db); + const rows = db.prepare(`SELECT sql FROM sqlite_master WHERE name='notes_fts'`).all() as Array<{ sql: string }>; + expect(rows).toHaveLength(1); + expect(rows[0]!.sql.toLowerCase()).toContain('using fts5'); + }); + + it('backfills active/completed notes; excludes trashed', () => { + up(db); + const rows = db + .prepare(`SELECT note_id, ai_title, tags FROM notes_fts ORDER BY note_id`) + .all() as Array<{ note_id: string; ai_title: string; tags: string }>; + expect(rows.map((r) => r.note_id)).toEqual(['a', 'b']); + const a = rows.find((r) => r.note_id === 'a')!; + expect(a.ai_title).toBe('회의록'); + // tags csv 가 GROUP_CONCAT 결과 (순서는 tag_id ASC 기대) + expect(a.tags.split(' ').sort()).toEqual(['기획', '회의']); + const b = rows.find((r) => r.note_id === 'b')!; + expect(b.tags).toBe(''); + }); + + it('AFTER INSERT trigger syncs new note', () => { + up(db); + db.prepare(`INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES ('d', '새 메모', '새 제목', '새 요약', 'pending', '2026-05-09T00:00:00Z', '2026-05-09T00:00:00Z', 'active')`).run(); + const r = db.prepare(`SELECT raw_text, ai_title FROM notes_fts WHERE note_id=?`).get('d') as { raw_text: string; ai_title: string }; + expect(r.raw_text).toBe('새 메모'); + expect(r.ai_title).toBe('새 제목'); + }); + + it('AFTER UPDATE trigger syncs raw_text + ai_title + ai_summary', () => { + up(db); + db.prepare(`UPDATE notes SET raw_text=?, ai_title=?, ai_summary=?, updated_at=? WHERE id=?`) + .run('수정한 본문', '수정 제목', '수정 요약', '2026-05-10T00:00:00Z', 'a'); + const r = db.prepare(`SELECT raw_text, ai_title, ai_summary FROM notes_fts WHERE note_id=?`).get('a') as { + raw_text: string; ai_title: string; ai_summary: string; + }; + expect(r.raw_text).toBe('수정한 본문'); + expect(r.ai_title).toBe('수정 제목'); + expect(r.ai_summary).toBe('수정 요약'); + }); + + it('AFTER DELETE trigger removes FTS row', () => { + up(db); + db.prepare(`DELETE FROM notes WHERE id=?`).run('a'); + const r = db.prepare(`SELECT * FROM notes_fts WHERE note_id=?`).all('a'); + expect(r).toHaveLength(0); + }); + + it('exports version=7', async () => { + const mod = await import('../../src/main/db/migrations/m007_fts.js'); + expect(mod.version).toBe(7); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** — Run `npx vitest run tests/unit/m007-migration.test.ts`. Expected: module not found. + +- [ ] **Step 3: m007 migration 구현** — `src/main/db/migrations/m007_fts.ts`: + +```ts +// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill. +// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를 +// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path. +import type Database from 'better-sqlite3'; + +export const version = 7; + +export function up(db: Database.Database): void { + db.exec(` + CREATE VIRTUAL TABLE notes_fts USING fts5( + note_id UNINDEXED, + raw_text, + ai_title, + ai_summary, + tags, + tokenize='unicode61' + ); + + CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), ''); + END; + + CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE note_id = OLD.id; + END; + + CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN + UPDATE notes_fts + SET raw_text = NEW.raw_text, + ai_title = COALESCE(NEW.ai_title, ''), + ai_summary = COALESCE(NEW.ai_summary, '') + WHERE note_id = NEW.id; + END; + + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + SELECT + n.id, + n.raw_text, + COALESCE(n.ai_title, ''), + COALESCE(n.ai_summary, ''), + COALESCE((SELECT GROUP_CONCAT(t.name, ' ') + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = n.id), '') + FROM notes n + WHERE n.status != 'trashed'; + `); +} +``` + +- [ ] **Step 4: index.ts 갱신**: + +```ts +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'; +import * as m006 from './m006_revisions.js'; +import * as m007 from './m007_fts.js'; + +const migrations = [m001, m002, m003, m004, m005, m006, m007]; +``` + +- [ ] **Step 5: tests/unit/migrations.test.ts 의 latest version 5/6/7 어디로 갱신됐는지 확인** — `grep -n "user_version reaches" tests/unit/migrations.test.ts` 실행 후 latest 값을 7 로 정정. + +- [ ] **Step 6: test PASS 확인** — Run `npx vitest run tests/unit/m007-migration.test.ts tests/unit/migrations.test.ts`. Expected: 6/6 + 기존 PASS. + +- [ ] **Step 7: typecheck 0 errors** — `npm run typecheck`. + +- [ ] **Step 8: commit**: + +```bash +git add src/main/db/migrations/m007_fts.ts \ + src/main/db/migrations/index.ts \ + tests/unit/m007-migration.test.ts \ + tests/unit/migrations.test.ts +git commit -m "feat(v0211): m007 migration — notes_fts FTS5 + trigger 3 + backfill" +``` + +--- + +## Task 2: NoteRepository.rebuildFtsTagsForNote + tags 변경 path 통합 + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRepository.test.ts` + +`note_tags` 변경 후 `notes_fts.tags` 컬럼 갱신하는 헬퍼 추가. `updateAiResult` / `updateUserAiFields` 가 tags 갱신 path 끝에서 단일 transaction 안 호출. + +- [ ] **Step 1: failing test 추가** — `tests/unit/NoteRepository.test.ts` 의 적절한 describe 안에: + +```ts +it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => { + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: '회의 본문' }); + repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' }); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(id) as { tags: string }; + expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']); +}); + +it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => { + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: '본문' }); + repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' }); + repo.updateUserAiFields(id, { tags: ['new1', 'new2'] }); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(id) as { tags: string }; + expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']); +}); +``` + +- [ ] **Step 2: test FAIL 확인** — Expected: tags csv 가 stale (빈 문자열). + +- [ ] **Step 3: NoteRepository 변경** — 새 private 헬퍼 추가 (예: `setStatus` 위 또는 다른 update 메서드 근처): + +```ts +/** + * v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성. + * 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출. + */ +private rebuildFtsTagsForNote(noteId: string): void { + const row = this.db + .prepare( + `SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = ?` + ) + .get(noteId) as { csv: string }; + this.db + .prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`) + .run(row.csv, noteId); +} +``` + +`updateAiResult` 의 transaction 안 마지막 (DELETE pending_jobs 다음) 에: + +```ts +this.rebuildFtsTagsForNote(id); +``` + +`updateUserAiFields` 의 `if (fields.tags !== undefined)` 블록 안 for-loop 종료 후: + +```ts +this.rebuildFtsTagsForNote(id); +``` + +- [ ] **Step 4: test PASS 확인** — `npx vitest run tests/unit/NoteRepository.test.ts`. + +- [ ] **Step 5: commit**: + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(v0211): rebuildFtsTagsForNote 헬퍼 + tags 변경 path 통합 (single write)" +``` + +--- + +## Task 3: ftsHelpers (sanitizeFtsQuery + computeCutoff) — 순수 함수 + +**Files:** + +- Create: `src/main/repository/ftsHelpers.ts` +- Create: `tests/unit/ftsHelpers.test.ts` + +검색 query 의 FTS5 special chars 이스케이프 + 회고 cutoff 계산을 별도 모듈로 분리 (NoteRepository 가 import). 순수 함수 → 테스트 용이. + +- [ ] **Step 1: failing test 작성** — `tests/unit/ftsHelpers.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { sanitizeFtsQuery, computeCutoff } from '../../src/main/repository/ftsHelpers.js'; + +describe('sanitizeFtsQuery', () => { + it('strips FTS5 special chars', () => { + expect(sanitizeFtsQuery('"기획" *회의*')).toBe('기획 회의'); + expect(sanitizeFtsQuery('foo: (bar)')).toBe('foo bar'); + }); + it('keeps Korean + alphanumeric tokens', () => { + expect(sanitizeFtsQuery('회의 결재 v2')).toBe('회의 결재 v2'); + }); + it('collapses whitespace', () => { + expect(sanitizeFtsQuery(' 회의 ')).toBe('회의'); + }); + it('returns empty string for whitespace-only', () => { + expect(sanitizeFtsQuery(' ')).toBe(''); + }); +}); + +describe('computeCutoff', () => { + // KST = UTC+9. KST 자정 = UTC 전날 15:00. + it('daily — KST 오늘 자정 ISO', () => { + const now = new Date('2026-05-10T05:30:00Z'); // KST 14:30 + expect(computeCutoff('daily', now)).toBe('2026-05-09T15:00:00.000Z'); + }); + it('weekly — 7일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('weekly', now)).toBe('2026-05-02T15:00:00.000Z'); + }); + it('monthly — 30일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('monthly', now)).toBe('2026-04-09T15:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — module not found. + +- [ ] **Step 3: 구현** — `src/main/repository/ftsHelpers.ts`: + +```ts +/** + * v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼. + */ + +const FTS5_SPECIAL_CHARS_RE = /["*():]/g; +const WS_COLLAPSE_RE = /\s+/g; + +/** + * FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리. + * 다중 토큰은 그대로 두어 FTS5 implicit AND 활용. + */ +export function sanitizeFtsQuery(input: string): string { + return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim(); +} + +export type ReviewPeriod = 'daily' | 'weekly' | 'monthly'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * 회고 cutoff = period 시작점의 KST 자정 (UTC ISO). + * daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시. + */ +export function computeCutoff(period: ReviewPeriod, now: Date): string { + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const y = kstNow.getUTCFullYear(); + const m = kstNow.getUTCMonth(); + const d = kstNow.getUTCDate(); + const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS; + const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30; + return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString(); +} +``` + +- [ ] **Step 4: test PASS** — `npx vitest run tests/unit/ftsHelpers.test.ts`. Expected: 7/7. + +- [ ] **Step 5: commit**: + +```bash +git add src/main/repository/ftsHelpers.ts tests/unit/ftsHelpers.test.ts +git commit -m "feat(v0211): ftsHelpers — sanitizeFtsQuery + computeCutoff 순수 함수" +``` + +--- + +## Task 4: NoteRepository.search + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts` +- Create: `tests/unit/NoteRepository.search.test.ts` + +FTS5 MATCH 쿼리 + status filter (default `!= 'trashed'`) + ORDER BY rank + LIMIT. 빈 query → []. multi-token AND. + +- [ ] **Step 1: failing test 작성** — `tests/unit/NoteRepository.search.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.search — FTS5', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + const a = repo.create({ rawText: '오늘 월요일 회의 정리' }); + repo.updateAiResult(a.id, { title: '회의록', summary: '월요일', tags: ['기획', '회의'], provider: 'p' }); + const b = repo.create({ rawText: '결재 요청 본문' }); + repo.updateAiResult(b.id, { title: '결재', summary: '요청서', tags: ['결재'], provider: 'p' }); + const c = repo.create({ rawText: '버려진 메모' }); + repo.setStatus(c.id, 'trashed', null); + }); + + afterEach(() => { db.close(); }); + + it('빈 query → 빈 배열', () => { + expect(repo.search('')).toEqual([]); + expect(repo.search(' ')).toEqual([]); + }); + + it('keyword 매칭 → hydrated Note', () => { + const r = repo.search('월요일'); + expect(r.length).toBeGreaterThan(0); + const titles = r.map((n) => n.aiTitle); + expect(titles).toContain('회의록'); + }); + + it('multi-token implicit AND', () => { + const r1 = repo.search('회의 월요일'); + expect(r1.length).toBeGreaterThan(0); + const r2 = repo.search('회의 결재'); // 동시 매칭 노트 없음 + expect(r2).toEqual([]); + }); + + it('default 는 trashed 제외', () => { + const r = repo.search('버려진'); + expect(r).toEqual([]); + }); + + it('status filter 명시 시 해당 status 만', () => { + const r = repo.search('버려진', { status: 'trashed' }); + expect(r.length).toBe(1); + }); + + it('FTS5 special char 안전 처리', () => { + expect(() => repo.search('"회의*" (월요일):')).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — `repo.search` 미정의. + +- [ ] **Step 3: NoteRepository.search 구현** — `src/main/repository/NoteRepository.ts` 의 import 에 ftsHelpers 추가: + +```ts +import { sanitizeFtsQuery } from './ftsHelpers.js'; +``` + +새 메서드 (다른 list 메서드 근처): + +```ts +/** + * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외. + * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize. + */ +search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { + const sanitized = sanitizeFtsQuery(query); + if (sanitized.length === 0) return []; + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; + const sql = ` + SELECT n.* FROM notes n + JOIN notes_fts f ON n.id = f.note_id + WHERE notes_fts MATCH ? ${statusClause} + ORDER BY rank + LIMIT ? + `; + const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit]; + const rows = this.db.prepare(sql).all(...args) as Record[]; + return rows.map((r) => this.hydrate(r)); +} +``` + +- [ ] **Step 4: test PASS** — `npx vitest run tests/unit/NoteRepository.search.test.ts`. + +- [ ] **Step 5: commit**: + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.search.test.ts +git commit -m "feat(v0211): NoteRepository.search — FTS5 MATCH + status filter + sanitize" +``` + +--- + +## Task 5: NoteRepository.reviewAggregate + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts` +- Create: `tests/unit/NoteRepository.reviewAggregate.test.ts` + +period 별 totalCount + recentNotes (50개) + tagCounts (DESC) + dueProgress (passed/pending KST 비교). + +- [ ] **Step 1: failing test 작성** — `tests/unit/NoteRepository.reviewAggregate.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.reviewAggregate', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + it('daily — 오늘 KST 자정 이후 노트만 카운트', () => { + // 강제로 created_at 을 직접 INSERT — repo.create 는 now 사용 + const now = new Date('2026-05-10T05:00:00Z'); // KST 14:00 + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('today', '오늘 메모', '2026-05-10T00:30:00Z', '2026-05-10T00:30:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('yesterday', '어제 메모', '2026-05-09T10:00:00Z', '2026-05-09T10:00:00Z'); + const r = repo.reviewAggregate('daily', now); + expect(r.totalCount).toBe(1); + expect(r.recentNotes).toHaveLength(1); + expect(r.recentNotes[0]!.id).toBe('today'); + }); + + it('weekly — 7일 전 KST 자정 이후', () => { + const now = new Date('2026-05-10T05:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('5dago', '5일 전', '2026-05-05T00:00:00Z', '2026-05-05T00:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('10dago', '10일 전', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z'); + const r = repo.reviewAggregate('weekly', now); + expect(r.totalCount).toBe(1); + }); + + it('trashed 제외', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: '활성' }); + const b = repo.create({ rawText: '버린' }); + repo.setStatus(b.id, 'trashed', null); + const r = repo.reviewAggregate('monthly', now); + expect(r.recentNotes.map((n) => n.id)).toContain(a.id); + expect(r.recentNotes.map((n) => n.id)).not.toContain(b.id); + }); + + it('tagCounts — period 안 노트의 태그만 DESC', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + repo.updateAiResult(a.id, { title: 't', summary: 's', tags: ['x', 'y'], provider: 'p' }); + repo.updateAiResult(b.id, { title: 't', summary: 's', tags: ['x'], provider: 'p' }); + const r = repo.reviewAggregate('monthly', now); + expect(r.tagCounts[0]).toEqual({ tag: 'x', count: 2 }); + expect(r.tagCounts[1]).toEqual({ tag: 'y', count: 1 }); + }); + + it('dueProgress — passed / pending KST today 기준', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + const c = repo.create({ rawText: 'c' }); // due 없음 → 카운트 X + repo.setDueDate(a.id, '2026-05-01'); // passed + repo.setDueDate(b.id, '2026-05-15'); // pending + const r = repo.reviewAggregate('monthly', now); + expect(r.dueProgress).toEqual({ total: 2, passed: 1, pending: 1 }); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — `repo.reviewAggregate` 미정의. + +- [ ] **Step 3: 구현** — `src/main/repository/NoteRepository.ts` 의 import: + +```ts +import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js'; +import { kstTodayIso } from '../../shared/util/kstDate.js'; +``` + +`kstTodayIso` 는 이미 import 되어 있으면 추가 불필요. 신규 메서드: + +```ts +/** + * v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트 + * (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) / + * dueProgress(passed/pending KST today 기준). + */ +reviewAggregate(period: ReviewPeriod, now: Date = new Date()): { + totalCount: number; + recentNotes: Note[]; + tagCounts: Array<{ tag: string; count: number }>; + dueProgress: { total: number; passed: number; pending: number }; +} { + const cutoff = computeCutoff(period, now); + const todayIso = kstTodayIso(now); + + const totalCount = (this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`) + .get(cutoff) as { c: number }).c; + + const recentRows = this.db + .prepare( + `SELECT * FROM notes + WHERE created_at >= ? AND status != 'trashed' + ORDER BY created_at DESC, id DESC LIMIT 50` + ) + .all(cutoff) as Record[]; + const recentNotes = recentRows.map((r) => this.hydrate(r)); + + const tagCounts = this.db + .prepare( + `SELECT t.name AS tag, COUNT(*) AS count + FROM note_tags nt + JOIN notes n ON n.id = nt.note_id + JOIN tags t ON t.id = nt.tag_id + WHERE n.created_at >= ? AND n.status != 'trashed' + GROUP BY t.id + ORDER BY count DESC, t.name ASC` + ) + .all(cutoff) as Array<{ tag: string; count: number }>; + + const dueRow = this.db + .prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending + FROM notes + WHERE created_at >= ? + AND status != 'trashed' + AND due_date IS NOT NULL` + ) + .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null }; + const dueProgress = { + total: dueRow.total, + passed: dueRow.passed ?? 0, + pending: dueRow.pending ?? 0 + }; + + return { totalCount, recentNotes, tagCounts, dueProgress }; +} +``` + +- [ ] **Step 4: test PASS** — 5/5. + +- [ ] **Step 5: commit**: + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.reviewAggregate.test.ts +git commit -m "feat(v0211): NoteRepository.reviewAggregate — period 별 카운트/태그/due" +``` + +--- + +## Task 6: shared types + IPC + preload + +**Files:** + +- Modify: `src/shared/types.ts` +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Create: `tests/unit/inboxApi-search-review.test.ts` + +`InboxApi` 에 `search` / `reviewAggregate` + `ReviewAggregate` 타입 추가. IPC handler 등록 + preload bridge. + +- [ ] **Step 1: types 추가** — `src/shared/types.ts` 의 `NoteRevision` 아래: + +```ts +// v0.2.11 Cut D — 회고 view aggregate. +export type ReviewPeriod = 'daily' | 'weekly' | 'monthly'; +export interface ReviewAggregate { + totalCount: number; + recentNotes: Note[]; + tagCounts: Array<{ tag: string; count: number }>; + dueProgress: { total: number; passed: number; pending: number }; +} +``` + +`InboxApi` 의 마지막에: + +```ts + // v0.2.11 Cut D — FTS5 search + 회고 aggregate. + search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise; + reviewAggregate(period: ReviewPeriod): Promise; +``` + +- [ ] **Step 2: failing test 작성** — `tests/unit/inboxApi-search-review.test.ts` (Cut C `inboxApi-revisions.test.ts` 패턴 mirror): + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } })); +import electron from 'electron'; +import { registerInboxApi } from '../../src/main/ipc/inboxApi.js'; +import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js'; + +function getHandler(channel: string): (...args: unknown[]) => unknown { + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const call = handle.mock.calls.find((c) => c[0] === channel); + if (!call) throw new Error(`channel ${channel} not registered`); + return call[1] as (...args: unknown[]) => unknown; +} + +function makeDeps(overrides: Partial = {}): InboxIpcDeps { + const repo = { + search: vi.fn(() => []), + reviewAggregate: vi.fn(() => ({ totalCount: 0, recentNotes: [], tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 } })), + list: vi.fn(), + listByStatus: vi.fn(), + countByStatus: vi.fn(() => 0), + countByAiStatus: vi.fn(() => 0), + countTrashed: vi.fn(() => 0), + countFailed: vi.fn(() => 0), + listTrashed: vi.fn(() => []), + setStatus: vi.fn(), + requeueDisabled: vi.fn(() => 0), + getAllPendingJobs: vi.fn(() => []), + getPendingCount: vi.fn(() => 0), + countToday: vi.fn(() => 0), + findById: vi.fn(), + listRevisions: vi.fn(() => []), + restoreRevision: vi.fn(), + updateRawText: vi.fn() + } as unknown as InboxIpcDeps['repo']; + return { + repo, + continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'], + capture: {} as InboxIpcDeps['capture'], + health: {} as InboxIpcDeps['health'], + intent: {} as InboxIpcDeps['intent'], + getInboxWindow: () => null, + settings: {} as InboxIpcDeps['settings'], + providerHolder: {} as InboxIpcDeps['providerHolder'], + paths: { profileDir: '/tmp' }, + ...overrides + }; +} + +describe('inboxApi search/review IPC', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + }); + + it('inbox:search — repo.search 호출 결과 반환', async () => { + const deps = makeDeps(); + (deps.repo.search as ReturnType).mockReturnValue([{ id: 'a' }]); + registerInboxApi(deps); + const h = getHandler('inbox:search'); + const r = await h({}, '회의', { status: 'active', limit: 10 }); + expect(deps.repo.search).toHaveBeenCalledWith('회의', { status: 'active', limit: 10 }); + expect(r).toEqual([{ id: 'a' }]); + }); + + it('inbox:review-aggregate — repo.reviewAggregate 호출 결과 반환', async () => { + const deps = makeDeps(); + const fake = { totalCount: 5, recentNotes: [], tagCounts: [{ tag: 'x', count: 2 }], dueProgress: { total: 1, passed: 1, pending: 0 } }; + (deps.repo.reviewAggregate as ReturnType).mockReturnValue(fake); + registerInboxApi(deps); + const h = getHandler('inbox:review-aggregate'); + const r = await h({}, 'weekly'); + expect(deps.repo.reviewAggregate).toHaveBeenCalledWith('weekly'); + expect(r).toEqual(fake); + }); + + it('inbox:review-aggregate — 잘못된 period reject', async () => { + const deps = makeDeps(); + registerInboxApi(deps); + const h = getHandler('inbox:review-aggregate'); + const r = await h({}, 'yearly'); + expect(deps.repo.reviewAggregate).not.toHaveBeenCalled(); + expect(r).toMatchObject({ totalCount: 0 }); + }); +}); +``` + +- [ ] **Step 3: test FAIL** — handler 미등록. + +- [ ] **Step 4: IPC 등록** — `src/main/ipc/inboxApi.ts` 의 마지막 handler 다음: + +```ts + // v0.2.11 Cut D — FTS5 검색 + 회고 aggregate. + ipcMain.handle( + 'inbox:search', + (_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) => + deps.repo.search(query, opts) + ); + + ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => { + const VALID = ['daily', 'weekly', 'monthly'] as const; + if (!(VALID as readonly string[]).includes(period)) { + return { + totalCount: 0, + recentNotes: [], + tagCounts: [], + dueProgress: { total: 0, passed: 0, pending: 0 } + }; + } + return deps.repo.reviewAggregate(period); + }); +``` + +- [ ] **Step 5: preload bridge** — `src/preload/index.ts` 의 마지막 항목 다음: + +```ts + // v0.2.11 Cut D — search + 회고 aggregate. + search: (query: string, opts?: { limit?: number; status?: NoteStatus }) => + ipcRenderer.invoke('inbox:search', query, opts ?? {}), + reviewAggregate: (period: 'daily' | 'weekly' | 'monthly') => + ipcRenderer.invoke('inbox:review-aggregate', period), +``` + +(NoteStatus import 가 preload 에서 필요하면 같이 추가.) + +- [ ] **Step 6: typecheck + tests PASS**: + +```bash +npm run typecheck +npx vitest run tests/unit/inboxApi-search-review.test.ts +``` + +- [ ] **Step 7: commit**: + +```bash +git add src/shared/types.ts src/main/ipc/inboxApi.ts src/preload/index.ts \ + tests/unit/inboxApi-search-review.test.ts +git commit -m "feat(v0211): InboxApi.search + reviewAggregate (types + IPC + preload)" +``` + +--- + +## Task 7: store — view enum 확장 + searchQuery + searchNotes/loadReview action + +**Files:** + +- Modify: `src/renderer/inbox/store.ts` + +view enum 에 `'review-daily' | 'review-weekly' | 'review-monthly'` 추가. searchQuery state + 200ms debounce 는 SearchBox 가 담당 (store 는 그냥 setQuery + searchNotes 비동기). reviewData state + loadReview action. + +- [ ] **Step 1: store 변경** — `src/renderer/inbox/store.ts` 의 view enum: + +```ts +export type InboxView = + | 'inbox' | 'completed' | 'archived' | 'trash' | 'settings' + | 'review-daily' | 'review-weekly' | 'review-monthly'; +``` + +state 에: + +```ts + searchQuery: string; + searchResults: Note[] | null; // null = 검색 안 한 상태 + reviewData: ReviewAggregate | null; +``` + +(import 에 `ReviewAggregate` 추가) + +initial state: + +```ts + searchQuery: '', + searchResults: null, + reviewData: null, +``` + +actions: + +```ts + setSearchQuery: (q: string) => void; + searchNotes: (q: string) => Promise; + clearSearch: () => void; + loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise; +``` + +구현: + +```ts +setSearchQuery(q) { + set({ searchQuery: q }); + if (q.trim().length === 0) set({ searchResults: null }); +}, +async searchNotes(q) { + if (q.trim().length === 0) { + set({ searchResults: null }); + return; + } + const view = get().view; + // 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색 + const status = view === 'completed' || view === 'archived' || view === 'trash' + ? (view === 'trash' ? 'trashed' : view) + : view === 'inbox' ? 'active' : undefined; + const r = await inboxApi.search(q, status ? { status } : {}); + set({ searchResults: r }); +}, +clearSearch() { + set({ searchQuery: '', searchResults: null }); +}, +async loadReview(period) { + const data = await inboxApi.reviewAggregate(period); + set({ reviewData: data }); +}, +``` + +`setView` 가 review-* 또는 settings 외 view 로 전환될 때 searchResults clear 하지 않음 (사용자가 탭 전환 후에도 검색 결과 유지 — UX 판단). review-* view 진입 시 자동으로 loadReview 호출: + +`setView` 안에 추가: + +```ts +if (view === 'review-daily') void get().loadReview('daily'); +if (view === 'review-weekly') void get().loadReview('weekly'); +if (view === 'review-monthly') void get().loadReview('monthly'); +``` + +- [ ] **Step 2: test 작성** — `tests/unit/store.search.test.ts` (간단): + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + search: vi.fn(), + reviewAggregate: vi.fn(), + listNotes: vi.fn(() => []), + getContinuity: vi.fn(() => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(() => 0), + getOllamaStatus: vi.fn(() => ({ ok: true })), + getTodayCount: vi.fn(() => 0), + getTrashCount: vi.fn(() => 0), + listExpired: vi.fn(() => []), + getFailedCount: vi.fn(() => 0), + listRecallCandidate: vi.fn(() => null), + countsByStatus: vi.fn(() => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + getSettings: vi.fn(() => ({ ai_enabled: true })), + listByStatus: vi.fn(() => []) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; + +describe('store — searchNotes', () => { + beforeEach(() => { + vi.clearAllMocks(); + useInbox.setState({ searchQuery: '', searchResults: null, view: 'inbox' }); + }); + + it('빈 query → searchResults null + IPC 미호출', async () => { + await useInbox.getState().searchNotes(' '); + expect(useInbox.getState().searchResults).toBeNull(); + expect(inboxApi.search).not.toHaveBeenCalled(); + }); + + it('keyword query → IPC 호출 + searchResults set', async () => { + (inboxApi.search as ReturnType).mockResolvedValue([{ id: 'a' }]); + await useInbox.getState().searchNotes('회의'); + expect(inboxApi.search).toHaveBeenCalledWith('회의', { status: 'active' }); + expect(useInbox.getState().searchResults).toEqual([{ id: 'a' }]); + }); + + it('clearSearch — query + results 모두 초기화', () => { + useInbox.setState({ searchQuery: '회의', searchResults: [{ id: 'a' } as never] }); + useInbox.getState().clearSearch(); + expect(useInbox.getState().searchQuery).toBe(''); + expect(useInbox.getState().searchResults).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: tests + typecheck PASS**: + +```bash +npm run typecheck +npx vitest run tests/unit/store.search.test.ts +``` + +- [ ] **Step 4: commit**: + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.search.test.ts +git commit -m "feat(v0211): store — search + reviewData state + actions + view enum 확장" +``` + +--- + +## Task 8: SearchBox 컴포넌트 — 헤더 search input + debounce + +**Files:** + +- Create: `src/renderer/inbox/components/SearchBox.tsx` +- Create: `tests/unit/SearchBox.test.tsx` +- Modify: `src/renderer/inbox/App.tsx` (헤더에 mount + 결과 렌더 분기) + +debounce 200ms → store.searchNotes(query). 빈 값 → store.clearSearch(). x 버튼으로 빈 값 직접. + +- [ ] **Step 1: failing test 작성** — `tests/unit/SearchBox.test.tsx`: + +```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 React from 'react'; + +const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({ + mockSearchNotes: vi.fn(), + mockClearSearch: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: Object.assign( + (selector?: (s: { searchQuery: string }) => unknown) => { + const state = { searchQuery: '' }; + return selector ? selector(state) : state; + }, + { getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) } + ) +})); + +import { SearchBox } from '../../src/renderer/inbox/components/SearchBox'; + +describe('SearchBox', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.useFakeTimers(); + }); + + it('타이핑 → 200ms debounce 후 searchNotes 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '회의' } }); + expect(mockSearchNotes).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(mockSearchNotes).toHaveBeenCalledWith('회의'); + }); + + it('빈 값 → clearSearch 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '' } }); + vi.advanceTimersByTime(200); + expect(mockClearSearch).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — module not found. + +- [ ] **Step 3: 구현** — `src/renderer/inbox/components/SearchBox.tsx`: + +```tsx +import React, { useEffect, useState } from 'react'; +import { useInbox } from '../store.js'; + +export function SearchBox(): React.ReactElement { + const [draft, setDraft] = useState(''); + + useEffect(() => { + const handle = setTimeout(() => { + const trimmed = draft.trim(); + if (trimmed.length === 0) useInbox.getState().clearSearch(); + else void useInbox.getState().searchNotes(trimmed); + }, 200); + return () => clearTimeout(handle); + }, [draft]); + + return ( + setDraft(e.target.value)} + aria-label="노트 검색" + style={{ + marginLeft: 12, + padding: '4px 8px', + fontSize: 12, + border: '1px solid #bbb', + borderRadius: 4, + width: 200 + }} + /> + ); +} +``` + +- [ ] **Step 4: App.tsx 헤더에 mount + 결과 렌더 분기** — + +import: + +```tsx +import { SearchBox } from './components/SearchBox.js'; +``` + +헤더의 탭 버튼 다음 (`
` 직전): + +```tsx + +``` + +main 의 list 렌더 분기: + +`const filtered = selectFilteredNotes({ notes, tagFilter });` 다음에: + +```tsx +const searchResults = useInbox((s) => s.searchResults); +const displayed = searchResults !== null ? searchResults : filtered; +``` + +(이 변수는 inbox view 에서만 사용. trash/settings/review-* view 는 분기 별도.) + +기존 inbox view 의 `filtered.map(...)` 부분을 `displayed.map(...)` 으로 변경 + 빈 결과 안내 분기 보강: + +```tsx +{searchResults !== null && displayed.length === 0 ? ( +
검색 결과가 없습니다.
+) : displayed.length === 0 ? ( +
머릿속에 떠다니는 한 줄을 적어보세요. Ctrl+Shift+J
+) : ( + displayed.map((n) => ( + removeNote(n.id)} + onUpdated={(u) => upsertNote(u)} + /> + )) +)} +``` + +(기존 `filtered.length === 0 ?` 분기는 유지 — tagFilter 케이스 보존. `displayed` 도입은 `notes.length === 0` 빈 inbox 분기와 함께 점검.) + +- [ ] **Step 5: tests + typecheck PASS**: + +```bash +npm run typecheck +npx vitest run tests/unit/SearchBox.test.tsx +npx vitest run tests/unit/App.test.tsx # 회귀 — 기존 App test 유지 +``` + +- [ ] **Step 6: commit**: + +```bash +git add src/renderer/inbox/components/SearchBox.tsx src/renderer/inbox/App.tsx \ + tests/unit/SearchBox.test.tsx +git commit -m "feat(v0211): SearchBox + 헤더 mount + inbox 결과 렌더 분기" +``` + +--- + +## Task 9: ReviewView 컴포넌트 + 헤더 진입점 + +**Files:** + +- Create: `src/renderer/inbox/components/ReviewView.tsx` +- Create: `tests/unit/ReviewView.test.tsx` +- Modify: `src/renderer/inbox/App.tsx` (헤더 회고 dropdown + main 분기) + +period 별 화면 (총 N건 / tagBar / dueProgress / 최근 50 NoteCard read-only). + +- [ ] **Step 1: failing test 작성** — `tests/unit/ReviewView.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; + +const baseState = { + reviewData: { + totalCount: 12, + recentNotes: [], + tagCounts: [{ tag: '회의', count: 5 }, { tag: '결재', count: 3 }], + dueProgress: { total: 10, passed: 4, pending: 6 } + } +}; + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: Object.assign( + (selector?: (s: typeof baseState) => unknown) => (selector ? selector(baseState) : baseState), + { getState: () => baseState } + ) +})); + +import { ReviewView } from '../../src/renderer/inbox/components/ReviewView'; + +describe('ReviewView', () => { + beforeEach(() => { cleanup(); }); + + it('daily — 라벨 + totalCount + tagBar + dueProgress 렌더', () => { + render(); + expect(screen.getByText(/일간/)).toBeInTheDocument(); + expect(screen.getByText(/총.*12건/)).toBeInTheDocument(); + expect(screen.getByText('회의')).toBeInTheDocument(); + expect(screen.getByText('결재')).toBeInTheDocument(); + expect(screen.getByText(/4.*\/.*10/)).toBeInTheDocument(); // passed/total + }); + + it('weekly — 라벨 weekly', () => { + render(); + expect(screen.getByText(/주간/)).toBeInTheDocument(); + }); + + it('monthly — 라벨 monthly', () => { + render(); + expect(screen.getByText(/월간/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — module not found. + +- [ ] **Step 3: 구현** — `src/renderer/inbox/components/ReviewView.tsx`: + +```tsx +import React from 'react'; +import { useInbox } from '../store.js'; +import { NoteCard } from './NoteCard.js'; + +interface Props { + period: 'daily' | 'weekly' | 'monthly'; +} + +const periodLabel: Record = { + daily: '일간', + weekly: '주간', + monthly: '월간' +}; + +export function ReviewView({ period }: Props): React.ReactElement { + const reviewData = useInbox((s) => s.reviewData); + if (!reviewData) { + return
불러오는 중…
; + } + const max = reviewData.tagCounts[0]?.count ?? 1; + return ( +
+

{periodLabel[period]} 회고

+
+ 총 {reviewData.totalCount}건 +
+
+

태그 분포

+ {reviewData.tagCounts.length === 0 && ( +
태그 없음
+ )} + {reviewData.tagCounts.slice(0, 10).map((t) => ( +
+ {t.tag} +
+
+
+ {t.count} +
+ ))} +
+
+

마감 진행

+
+ 완료 {reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · 대기 {reviewData.dueProgress.pending} +
+
+
+

최근 노트 ({reviewData.recentNotes.length})

+ {reviewData.recentNotes.map((n) => ( + {}} /> + ))} +
+
+ ); +} +``` + +- [ ] **Step 4: App.tsx 헤더 dropdown + main 분기** + +헤더 탭 버튼 다음에 회고 드롭다운 (단순 ` { + const v = e.target.value; + if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView); + }} + style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }} +> + + + + + +``` + +main 분기 — `if (showSettings) return ;` 직전 또는 직후에: + +```tsx +if (view === 'review-daily') return ; +if (view === 'review-weekly') return ; +if (view === 'review-monthly') return ; +``` + +import: + +```tsx +import { ReviewView } from './components/ReviewView.js'; +import type { InboxView } from './store.js'; +``` + +- [ ] **Step 5: tests + typecheck PASS**: + +```bash +npm run typecheck +npx vitest run tests/unit/ReviewView.test.tsx +``` + +- [ ] **Step 6: commit**: + +```bash +git add src/renderer/inbox/components/ReviewView.tsx src/renderer/inbox/App.tsx \ + tests/unit/ReviewView.test.tsx +git commit -m "feat(v0211): ReviewView — 일/주/월 회고 + 헤더 dropdown 진입점" +``` + +--- + +## Task 10: dogfood promoted + version bump + release commit + +**Files:** + +- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F19 promoted) +- Modify: `package.json` + `package-lock.json` (version 0.2.10 → 0.2.11) + +- [ ] **Step 1: dogfood-feedback.md F19 섹션 갱신** + +F19 헤더 옆에 `(✅ promoted v0.2.11 Cut D — A+D 옵션)` 추가. 본문 마지막에: + +```markdown +**상태**: ✅ promoted v0.2.11 Cut D — m007 FTS5 + NoteRepository.search + reviewAggregate + SearchBox + ReviewView. B/C/E/F 옵션은 v0.3+ deferred. +``` + +- [ ] **Step 2: package.json version 0.2.10 → 0.2.11** + package-lock.json (`name` + `packages[""].version`). + +- [ ] **Step 3: 단위 + typecheck full run**: + +```bash +npm run typecheck +npx vitest run +``` + +Expected: typecheck 0, 약 595 PASS. + +- [ ] **Step 4: e2e smoke** — worktree node_modules 비어 있어 prebuild 실패 가능. main 머지 후 검증으로 정책 (Cut C 동일). + +- [ ] **Step 5: commit**: + +```bash +git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json +git commit -m "chore(release): v0.2.11 — Cut D (FTS5 search + 회고 view)" +``` + +--- + +## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검) + +- [ ] **Spec coverage**: §3-1 m007 (Task 1) / §3-2 trigger (Task 1) / §3-3 search (Task 4) / §3-4 search box (Task 8) / §3-5 IPC (Task 6) / §4-1 라우트 (Task 7) / §4-2 ReviewView (Task 9) / §4-3 reviewAggregate (Task 5) / §4-4 tagBar (Task 9) / §4-5 dueProgress (Task 9) / §5 테스트 전략 카운트 일치 +- [ ] **Single write path 강제**: tags 변경하는 메서드 (`updateAiResult`, `updateUserAiFields`) 가 `rebuildFtsTagsForNote` 호출 — Task 2 의 회귀 test 검증 +- [ ] **Type 일관성**: `ReviewPeriod` (ftsHelpers + types) / `ReviewAggregate` (types + IPC + store) 모두 동일 shape +- [ ] **단위 카운트**: m007 6 + tags sync 2 + ftsHelpers 7 + search 6 + reviewAggregate 5 + IPC 3 + store 3 + SearchBox 2 + ReviewView 3 = **37 신규** (목표 26 보다 많음 — 회귀 test 포함). 실제 약 595~605 정도 + +--- + +## Risk + +- **FTS5 한국어 token 정확도**: unicode61 의 word boundary 가 한국어에서 부정확 — dogfood 검증 필요. 부족 시 mecab-ko 또는 trigram tokenize 검토 (별도 cut) +- **Trigger overhead**: notes 모든 UPDATE 마다 trigger 발동. raw_text 가변 (Cut C) + AI 결과 갱신 빈도가 높을 때 성능 영향 — 본 cut 의 dogfood 규모 (수백 건) 에서는 무시 가능 +- **`rebuildFtsTagsForNote` 누락 회귀**: tags 변경 path 추가 시 호출 누락 가능 — 코드 리뷰 단계 매번 점검 (memory 의 single write path 정책 명시) +- **trashed 노트 FTS row 잔존**: search query 단계 `n.status != 'trashed'` 필터로 처리. cleanup 안 함 — DB 크기 영향은 dogfood 규모 미미 +- **e2e**: Cut C 와 동일 — worktree 내 미수행 (better-sqlite3 prebuild path), main 검증 diff --git a/docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md b/docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md index 23dea46..9e36b15 100644 --- a/docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md +++ b/docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md @@ -27,35 +27,52 @@ recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A ( ## 3. F19-A 디테일 (FTS5) -### 3-1. Schema 마이그레이션 (m006) +### 3-1. Schema 마이그레이션 (m007) + +> 메모: 본 스펙 작성 시점에는 m006 로 예상했으나 Cut C (v0.2.10) 에서 m006 (note_revisions) 가 선점됨 → 실제 번호는 **m007**. + +실제 schema 정정: +- `notes.title`/`notes.summary` 컬럼 없음 → 실제 `notes.ai_title`/`notes.ai_summary` 사용 +- `notes.tags_csv` 컬럼 없음 → tags 는 `note_tags` join (note_tags.note_id ↔ tags.id) +- `notes.status` (Cut B m004 도입) 사용 가능 — `status != 'trashed'` 필터 ```sql CREATE VIRTUAL TABLE notes_fts USING fts5( note_id UNINDEXED, raw_text, - title, - summary, + ai_title, + ai_summary, tags, tokenize='unicode61' ); --- 기존 notes 모두 인덱스 -INSERT INTO notes_fts (note_id, raw_text, title, summary, tags) - SELECT id, raw_text, title, summary, tags_csv FROM notes WHERE status != 'trashed'; +-- 기존 notes (active/completed/archived 만 — trashed 제외) 모두 인덱스. +-- tags 는 note_tags+tags JOIN 후 GROUP_CONCAT 으로 csv 구성. +INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + SELECT + n.id, + n.raw_text, + COALESCE(n.ai_title, ''), + COALESCE(n.ai_summary, ''), + COALESCE((SELECT GROUP_CONCAT(t.name, ' ') + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = n.id), '') + FROM notes n + WHERE n.status != 'trashed'; ``` `tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default. -`tags_csv` — notes.tags (JSON array) 를 csv 로 flatten 하여 인덱스 (예: `"기획 회의 결재"`). +`tags` 컬럼 = note_tags JOIN 결과 csv (예: `"기획 회의 결재"`). `note_tags` 변경 시 NoteRepository 에서 명시적 헬퍼 (`rebuildFtsTagsForNote(noteId)`) 호출 — trigger 로 sync 어려움 (`note_tags` INSERT/DELETE 가 다른 노트 row 재계산 트리거하기 부담). 단일 write path 패턴 (Cut C 확립) 으로 강제. -### 3-2. Trigger — auto-sync +### 3-2. Trigger — auto-sync (notes 컬럼 한정) -`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync: +`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync (raw_text/ai_title/ai_summary 만; tags 는 별도 헬퍼): ```sql CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN - INSERT INTO notes_fts (note_id, raw_text, title, summary, tags) - VALUES (NEW.id, NEW.raw_text, NEW.title, NEW.summary, NEW.tags_csv); + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), ''); END; CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN @@ -63,32 +80,45 @@ CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN END; CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN - UPDATE notes_fts SET raw_text=NEW.raw_text, title=NEW.title, summary=NEW.summary, tags=NEW.tags_csv - WHERE note_id = NEW.id; + UPDATE notes_fts + SET raw_text = NEW.raw_text, + ai_title = COALESCE(NEW.ai_title, ''), + ai_summary = COALESCE(NEW.ai_summary, '') + WHERE note_id = NEW.id; END; ``` Cut C 의 `updateRawText` 가 `notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신. -`tags_csv` 는 별도 generated column 또는 NoteRepository 에서 수동 갱신 (zod parse 후 csv join). YAGNI: 수동 갱신. +`tags` 갱신 path: +- `NoteRepository.updateAiResult` (AI tags) / `updateUserAiFields` (사용자 tags) 모두 `note_tags` 변경 후 동일 transaction 안에서 `rebuildFtsTagsForNote(noteId)` 호출. + +trashed 노트 처리 — `setStatus(id, 'trashed', ...)` 시 trigger AFTER UPDATE 발동되어 FTS row 가 그대로 유지됨. 검색 시 query 단계에서 `n.status != 'trashed'` 필터로 제외 (별도 FTS row cleanup 안 함 — YAGNI). ### 3-3. NoteRepository.search ```ts -search(query: string, opts: { limit?: number; status?: NoteStatus }): Note[] { - const limit = opts.limit ?? 50; - const statusClause = opts.status ? `AND n.status = ?` : ''; +search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { + if (query.trim().length === 0) return []; + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + const ftsQuery = sanitizeFtsQuery(query); // FTS5 special char escape + const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; const sql = ` SELECT n.* FROM notes n JOIN notes_fts f ON n.id = f.note_id WHERE notes_fts MATCH ? ${statusClause} ORDER BY rank LIMIT ? `; - const args = opts.status ? [query, opts.status, limit] : [query, limit]; - return this.db.prepare(sql).all(...args) as Note[]; + const args = opts.status ? [ftsQuery, opts.status, limit] : [ftsQuery, limit]; + const rows = this.db.prepare(sql).all(...args) as Record[]; + return rows.map((r) => this.hydrate(r)); } ``` +`hydrate` — 기존 패턴 (tags + media join). `sanitizeFtsQuery` — FTS5 special chars (`"`, `*`, `(`, `)`, `:`) 이스케이프 및 multi-word AND 결합 (예: `기획 회의` → `"기획" AND "회의"` 또는 `기획 회의` 그대로 수용). YAGNI: 다중 토큰을 그대로 FTS5 implicit AND 로 보냄 + 따옴표 제거. + +`status` 미지정 시 default = trashed 제외. + `MATCH` 쿼리 syntax — FTS5 standard (`"기획 회의"`, `회의 OR 결재`, `기획*` 등). ### 3-4. UI — inbox 헤더 search box @@ -149,23 +179,55 @@ export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly' NoteRepository: ```ts -reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): { +reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date = new Date()): { totalCount: number; recentNotes: Note[]; tagCounts: Array<{ tag: string; count: number }>; dueProgress: { total: number; passed: number; pending: number }; } { - const cutoff = computeCutoff(period, now); - // 단일 transaction 안에 N개 query - const totalCount = this.db.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`).get(cutoff).c; - const recentNotes = this.db.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed' ORDER BY created_at DESC LIMIT 50`).all(cutoff); - // tagCounts — JSON tags array unnest → group by - // dueProgress — due_date 컬럼 + KST 비교 - return { ... }; + const cutoff = computeCutoff(period, now); // ISO string — KST 자정 / 7일전 / 30일전 + const todayIso = kstTodayIso(now); // YYYY-MM-DD + const totalCount = (this.db + .prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`) + .get(cutoff) as { c: number }).c; + const recentRows = this.db + .prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed' + ORDER BY created_at DESC, id DESC LIMIT 50`) + .all(cutoff) as Record[]; + const recentNotes = recentRows.map((r) => this.hydrate(r)); + // tag counts via note_tags JOIN — period 안 노트의 태그만 집계 + const tagCounts = this.db + .prepare(`SELECT t.name AS tag, COUNT(*) AS count + FROM note_tags nt + JOIN notes n ON n.id = nt.note_id + JOIN tags t ON t.id = nt.tag_id + WHERE n.created_at >= ? AND n.status != 'trashed' + GROUP BY t.id + ORDER BY count DESC, t.name ASC`) + .all(cutoff) as Array<{ tag: string; count: number }>; + // due progress — period 안 created 노트 중 due_date 가 있는 것 + const dueRow = this.db + .prepare(`SELECT + COUNT(*) AS total, + SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending + FROM notes + WHERE created_at >= ? + AND status != 'trashed' + AND due_date IS NOT NULL`) + .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null }; + const dueProgress = { + total: dueRow.total, + passed: dueRow.passed ?? 0, + pending: dueRow.pending ?? 0 + }; + return { totalCount, recentNotes, tagCounts, dueProgress }; } ``` -`computeCutoff('daily', now)` = KST 자정. `'weekly'` = 7일 전 KST. `'monthly'` = 30일 전 KST. +`computeCutoff('daily', now)` = KST 자정 (오늘 시작) ISO. `'weekly'` = 7일 전 KST 자정 ISO. `'monthly'` = 30일 전 KST 자정 ISO. `kstTodayIso` 는 `src/shared/util/kstDate.ts` 에 이미 존재 (Cut B 활용). + +period 별 query 는 동일 transaction 으로 wrap 해도 되나, read-only + 단일 호출이라 단순 sequential 호출로 충분 (better-sqlite3 동기 API). ### 4-4. Tag distribution chart @@ -195,14 +257,19 @@ reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): { | 영역 | 단위 | |---|---| -| m006 마이그레이션 | FTS5 virtual table 생성 + 기존 notes backfill (status != 'trashed' 만) | -| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync | -| `search` | 한국어 token 매칭 + status filter | +| m007 마이그레이션 | FTS5 virtual table + trigger 3개 + 기존 notes backfill (status != 'trashed' + tags JOIN) | +| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync (raw_text/ai_title/ai_summary) | +| `rebuildFtsTagsForNote` 헬퍼 | note_tags 변경 후 FTS tags 컬럼 재구성 | +| `updateAiResult` / `updateUserAiFields` | tags 변경 path 가 헬퍼 호출하여 FTS sync (회귀) | +| `updateRawText` (Cut C) FTS sync 회귀 | trigger 자동 발동 검증 | +| `search` | 한국어 token 매칭 + status filter + trashed 기본 제외 + 빈 query → [] | +| `sanitizeFtsQuery` | FTS5 special char 이스케이프 + multi-word 통과 | | inbox header search box | debounce + 빈 값 → 기본 list 복귀 | -| ReviewView 단위 | aggregate query 결과 렌더 | -| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress | +| ReviewView 단위 | aggregate query 결과 렌더 + period 라벨 | +| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) | +| `computeCutoff` | daily/weekly/monthly KST 자정 ISO | -**목표**: 단위 505 → 약 528 (+23), typecheck 0. +**목표**: 단위 569 → 약 595 (+26), typecheck 0. --- @@ -213,7 +280,9 @@ reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): { | FTS5 한국어 token 정확도 (unicode61 가 word boundary 부정확) | dogfood 검증. 부족 시 v0.3+ 에서 mecab-ko 또는 trigram tokenize 검토 | | FTS5 인덱스 size (notes 수만건 시 DB 크기 ↑) | 수만건 도달 전엔 무시. v0.3+ 에서 prune 또는 partial 인덱스 | | 회고 aggregate query latency | LIMIT 50 + index 활용 (`created_at DESC`). 수만건도 sub-second 예상 | -| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). 정책 일관 | +| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 | +| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 | +| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 | --- -- 2.49.1 From 19edeab7b1218109ce29233dc3ec3af986cffec1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:16:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(v0211):=20m007=20migration=20?= =?UTF-8?q?=E2=80=94=20notes=5Ffts=20FTS5=20+=20trigger=203=20+=20backfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/db/migrations/index.ts | 3 +- src/main/db/migrations/m007_fts.ts | 48 +++++++++++++++ tests/unit/m007-migration.test.ts | 96 ++++++++++++++++++++++++++++++ tests/unit/migrations.test.ts | 4 +- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/main/db/migrations/m007_fts.ts create mode 100644 tests/unit/m007-migration.test.ts diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 5a540e8..8b09003 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -5,8 +5,9 @@ import * as m003 from './m003_soft_delete.js'; import * as m004 from './m004_status.js'; import * as m005 from './m005_ai_disabled.js'; import * as m006 from './m006_revisions.js'; +import * as m007 from './m007_fts.js'; -const migrations = [m001, m002, m003, m004, m005, m006]; +const migrations = [m001, m002, m003, m004, m005, m006, m007]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m007_fts.ts b/src/main/db/migrations/m007_fts.ts new file mode 100644 index 0000000..1a1e518 --- /dev/null +++ b/src/main/db/migrations/m007_fts.ts @@ -0,0 +1,48 @@ +// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill. +// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를 +// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path. +import type Database from 'better-sqlite3'; + +export const version = 7; + +export function up(db: Database.Database): void { + db.exec(` + CREATE VIRTUAL TABLE notes_fts USING fts5( + note_id UNINDEXED, + raw_text, + ai_title, + ai_summary, + tags, + tokenize='unicode61' + ); + + CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), ''); + END; + + CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE note_id = OLD.id; + END; + + CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN + UPDATE notes_fts + SET raw_text = NEW.raw_text, + ai_title = COALESCE(NEW.ai_title, ''), + ai_summary = COALESCE(NEW.ai_summary, '') + WHERE note_id = NEW.id; + END; + + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + SELECT + n.id, + n.raw_text, + COALESCE(n.ai_title, ''), + COALESCE(n.ai_summary, ''), + COALESCE((SELECT GROUP_CONCAT(t.name, ' ') + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = n.id), '') + FROM notes n + WHERE n.status != 'trashed'; + `); +} diff --git a/tests/unit/m007-migration.test.ts b/tests/unit/m007-migration.test.ts new file mode 100644 index 0000000..4a37651 --- /dev/null +++ b/tests/unit/m007-migration.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m007_fts.js'; + +describe('m007 migration — notes_fts virtual table + triggers', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + 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','disabled')), + 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, + status TEXT NOT NULL DEFAULT 'active', status_changed_at TEXT, move_reason TEXT + ); + CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE); + CREATE TABLE note_tags ( + note_id TEXT NOT NULL, tag_id INTEGER NOT NULL, source TEXT NOT NULL, + PRIMARY KEY(note_id, tag_id), + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES + ('a', '오늘 회의 정리', '회의록', '월요일 회의', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', 'active'), + ('b', '예전 메모', '예전 제목', '예전 요약', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'completed'), + ('c', '버려진 메모', '버린 제목', '버린 요약', 'done', '2026-03-01T00:00:00Z', '2026-03-01T00:00:00Z', 'trashed'); + INSERT INTO tags (id, name) VALUES (1, '기획'), (2, '회의'); + INSERT INTO note_tags (note_id, tag_id, source) VALUES ('a', 1, 'ai'), ('a', 2, 'user'); + `); + }); + + afterEach(() => { db.close(); }); + + it('creates notes_fts virtual table with FTS5 columns', () => { + up(db); + const rows = db.prepare(`SELECT sql FROM sqlite_master WHERE name='notes_fts'`).all() as Array<{ sql: string }>; + expect(rows).toHaveLength(1); + expect(rows[0]!.sql.toLowerCase()).toContain('using fts5'); + }); + + it('backfills active/completed notes; excludes trashed', () => { + up(db); + const rows = db + .prepare(`SELECT note_id, ai_title, tags FROM notes_fts ORDER BY note_id`) + .all() as Array<{ note_id: string; ai_title: string; tags: string }>; + expect(rows.map((r) => r.note_id)).toEqual(['a', 'b']); + const a = rows.find((r) => r.note_id === 'a')!; + expect(a.ai_title).toBe('회의록'); + expect(a.tags.split(' ').sort()).toEqual(['기획', '회의']); + const b = rows.find((r) => r.note_id === 'b')!; + expect(b.tags).toBe(''); + }); + + it('AFTER INSERT trigger syncs new note', () => { + up(db); + db.prepare(`INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES ('d', '새 메모', '새 제목', '새 요약', 'pending', '2026-05-09T00:00:00Z', '2026-05-09T00:00:00Z', 'active')`).run(); + const r = db.prepare(`SELECT raw_text, ai_title FROM notes_fts WHERE note_id=?`).get('d') as { raw_text: string; ai_title: string }; + expect(r.raw_text).toBe('새 메모'); + expect(r.ai_title).toBe('새 제목'); + }); + + it('AFTER UPDATE trigger syncs raw_text + ai_title + ai_summary', () => { + up(db); + db.prepare(`UPDATE notes SET raw_text=?, ai_title=?, ai_summary=?, updated_at=? WHERE id=?`) + .run('수정한 본문', '수정 제목', '수정 요약', '2026-05-10T00:00:00Z', 'a'); + const r = db.prepare(`SELECT raw_text, ai_title, ai_summary FROM notes_fts WHERE note_id=?`).get('a') as { + raw_text: string; ai_title: string; ai_summary: string; + }; + expect(r.raw_text).toBe('수정한 본문'); + expect(r.ai_title).toBe('수정 제목'); + expect(r.ai_summary).toBe('수정 요약'); + }); + + it('AFTER DELETE trigger removes FTS row', () => { + up(db); + db.prepare(`DELETE FROM notes WHERE id=?`).run('a'); + const r = db.prepare(`SELECT * FROM notes_fts WHERE note_id=?`).all('a'); + expect(r).toHaveLength(0); + }); + + it('exports version=7', async () => { + const mod = await import('../../src/main/db/migrations/m007_fts.js'); + expect(mod.version).toBe(7); + }); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index e5d3ca3..bc6da16 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 latest (6)', () => { + it('user_version reaches latest (7)', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(6); + expect(row.user_version).toBe(7); db.close(); }); -- 2.49.1 From 726d155d04334b93f92980925cdfa562fa3b4bfb Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:19:14 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(v0211):=20rebuildFtsTagsForNote=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20+=20tags=20=EB=B3=80=EA=B2=BD=20path=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20(single=20write)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/repository/NoteRepository.ts | 19 +++++++++++++++++ tests/unit/NoteRepository.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 3567a45..7ed96b0 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -161,6 +161,7 @@ export class NoteRepository { linkTag.run(id, tagRow.id); } this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + this.rebuildFtsTagsForNote(id); }); tx(); } @@ -390,6 +391,7 @@ export class NoteRepository { const row = getOrInsert.get(t) as { id: number }; link.run(id, row.id); } + this.rebuildFtsTagsForNote(id); } }); tx(); @@ -851,6 +853,23 @@ export class NoteRepository { .run(nextRunAt, lastError.slice(0, 500), noteId); } + /** + * v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성. + * 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출. + */ + private rebuildFtsTagsForNote(noteId: string): void { + const row = this.db + .prepare( + `SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = ?` + ) + .get(noteId) as { csv: string }; + this.db + .prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`) + .run(row.csv, noteId); + } + private hydrate(row: Record): Note { const tags = this.db .prepare( diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 3fe82cd..6a59bab 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -1074,3 +1074,33 @@ describe('NoteRepository — note_revisions', () => { expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' }); }); }); + +describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + }); + + it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => { + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: '회의 본문' }); + repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' }); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(id) as { tags: string }; + expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']); + }); + + it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => { + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: '본문' }); + repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' }); + repo.updateUserAiFields(id, { tags: ['new1', 'new2'] }); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(id) as { tags: string }; + expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']); + }); +}); -- 2.49.1 From e60a2a23c85318619c75c90111ed525a81228335 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:24:24 +0900 Subject: [PATCH 04/10] feat(v0211): ftsHelpers + NoteRepository.search + reviewAggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ftsHelpers: sanitizeFtsQuery (FTS5 special char escape) + computeCutoff (period → KST 자정) - search: notes_fts MATCH + status filter + rank order + sanitize + 빈 query → [] - reviewAggregate: period 별 totalCount/recentNotes(50)/tagCounts(DESC)/dueProgress(passed/pending) --- src/main/repository/NoteRepository.ts | 82 +++++++++++++++++++ src/main/repository/ftsHelpers.ts | 32 ++++++++ .../NoteRepository.reviewAggregate.test.ts | 72 ++++++++++++++++ tests/unit/NoteRepository.search.test.ts | 57 +++++++++++++ tests/unit/ftsHelpers.test.ts | 34 ++++++++ 5 files changed, 277 insertions(+) create mode 100644 src/main/repository/ftsHelpers.ts create mode 100644 tests/unit/NoteRepository.reviewAggregate.test.ts create mode 100644 tests/unit/NoteRepository.search.test.ts create mode 100644 tests/unit/ftsHelpers.test.ts diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 7ed96b0..0ed8fe2 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; +import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js'; export interface CreateNoteInput { rawText: string; @@ -600,6 +601,87 @@ export class NoteRepository { return rows.map((r) => this.hydrate(r)); } + /** + * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외. + * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize. + */ + search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { + const sanitized = sanitizeFtsQuery(query); + if (sanitized.length === 0) return []; + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; + const sql = ` + SELECT n.* FROM notes n + JOIN notes_fts f ON n.id = f.note_id + WHERE notes_fts MATCH ? ${statusClause} + ORDER BY rank + LIMIT ? + `; + const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit]; + const rows = this.db.prepare(sql).all(...args) as Record[]; + return rows.map((r) => this.hydrate(r)); + } + + /** + * v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트 + * (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) / + * dueProgress(passed/pending KST today 기준). + */ + reviewAggregate(period: ReviewPeriod, now: Date = new Date()): { + totalCount: number; + recentNotes: Note[]; + tagCounts: Array<{ tag: string; count: number }>; + dueProgress: { total: number; passed: number; pending: number }; + } { + const cutoff = computeCutoff(period, now); + const todayIso = kstTodayIso(now); + + const totalCount = (this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`) + .get(cutoff) as { c: number }).c; + + const recentRows = this.db + .prepare( + `SELECT * FROM notes + WHERE created_at >= ? AND status != 'trashed' + ORDER BY created_at DESC, id DESC LIMIT 50` + ) + .all(cutoff) as Record[]; + const recentNotes = recentRows.map((r) => this.hydrate(r)); + + const tagCounts = this.db + .prepare( + `SELECT t.name AS tag, COUNT(*) AS count + FROM note_tags nt + JOIN notes n ON n.id = nt.note_id + JOIN tags t ON t.id = nt.tag_id + WHERE n.created_at >= ? AND n.status != 'trashed' + GROUP BY t.id + ORDER BY count DESC, t.name ASC` + ) + .all(cutoff) as Array<{ tag: string; count: number }>; + + const dueRow = this.db + .prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending + FROM notes + WHERE created_at >= ? + AND status != 'trashed' + AND due_date IS NOT NULL` + ) + .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null }; + const dueProgress = { + total: dueRow.total, + passed: dueRow.passed ?? 0, + pending: dueRow.pending ?? 0 + }; + + return { totalCount, recentNotes, tagCounts, dueProgress }; + } + /** * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). diff --git a/src/main/repository/ftsHelpers.ts b/src/main/repository/ftsHelpers.ts new file mode 100644 index 0000000..47ccac0 --- /dev/null +++ b/src/main/repository/ftsHelpers.ts @@ -0,0 +1,32 @@ +/** + * v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼. + */ + +const FTS5_SPECIAL_CHARS_RE = /["*():]/g; +const WS_COLLAPSE_RE = /\s+/g; + +/** + * FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리. + * 다중 토큰은 그대로 두어 FTS5 implicit AND 활용. + */ +export function sanitizeFtsQuery(input: string): string { + return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim(); +} + +export type ReviewPeriod = 'daily' | 'weekly' | 'monthly'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * 회고 cutoff = period 시작점의 KST 자정 (UTC ISO). + * daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시. + */ +export function computeCutoff(period: ReviewPeriod, now: Date): string { + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const y = kstNow.getUTCFullYear(); + const m = kstNow.getUTCMonth(); + const d = kstNow.getUTCDate(); + const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS; + const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30; + return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString(); +} diff --git a/tests/unit/NoteRepository.reviewAggregate.test.ts b/tests/unit/NoteRepository.reviewAggregate.test.ts new file mode 100644 index 0000000..75595cd --- /dev/null +++ b/tests/unit/NoteRepository.reviewAggregate.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.reviewAggregate', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + it('daily — 오늘 KST 자정 이후 노트만 카운트', () => { + const now = new Date('2026-05-10T05:00:00Z'); // KST 14:00 + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('today', '오늘 메모', '2026-05-10T00:30:00Z', '2026-05-10T00:30:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('yesterday', '어제 메모', '2026-05-09T10:00:00Z', '2026-05-09T10:00:00Z'); + const r = repo.reviewAggregate('daily', now); + expect(r.totalCount).toBe(1); + expect(r.recentNotes).toHaveLength(1); + expect(r.recentNotes[0]!.id).toBe('today'); + }); + + it('weekly — 7일 전 KST 자정 이후', () => { + const now = new Date('2026-05-10T05:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('5dago', '5일 전', '2026-05-05T00:00:00Z', '2026-05-05T00:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('10dago', '10일 전', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z'); + const r = repo.reviewAggregate('weekly', now); + expect(r.totalCount).toBe(1); + }); + + it('trashed 제외', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: '활성' }); + const b = repo.create({ rawText: '버린' }); + repo.setStatus(b.id, 'trashed', null); + const r = repo.reviewAggregate('monthly', now); + expect(r.recentNotes.map((n) => n.id)).toContain(a.id); + expect(r.recentNotes.map((n) => n.id)).not.toContain(b.id); + }); + + it('tagCounts — period 안 노트의 태그만 DESC', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + repo.updateAiResult(a.id, { title: 't', summary: 's', tags: ['x', 'y'], provider: 'p' }); + repo.updateAiResult(b.id, { title: 't', summary: 's', tags: ['x'], provider: 'p' }); + const r = repo.reviewAggregate('monthly', now); + expect(r.tagCounts[0]).toEqual({ tag: 'x', count: 2 }); + expect(r.tagCounts[1]).toEqual({ tag: 'y', count: 1 }); + }); + + it('dueProgress — passed / pending KST today 기준', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + repo.create({ rawText: 'c' }); // due 없음 → 카운트 X + repo.setDueDate(a.id, '2026-05-01'); // passed + repo.setDueDate(b.id, '2026-05-15'); // pending + const r = repo.reviewAggregate('monthly', now); + expect(r.dueProgress).toEqual({ total: 2, passed: 1, pending: 1 }); + }); +}); diff --git a/tests/unit/NoteRepository.search.test.ts b/tests/unit/NoteRepository.search.test.ts new file mode 100644 index 0000000..06327d6 --- /dev/null +++ b/tests/unit/NoteRepository.search.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.search — FTS5', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + const a = repo.create({ rawText: '오늘 월요일 회의 정리' }); + repo.updateAiResult(a.id, { title: '회의록', summary: '월요일', tags: ['기획', '회의'], provider: 'p' }); + const b = repo.create({ rawText: '결재 요청 본문' }); + repo.updateAiResult(b.id, { title: '결재', summary: '요청서', tags: ['결재'], provider: 'p' }); + const c = repo.create({ rawText: '버려진 메모' }); + repo.setStatus(c.id, 'trashed', null); + }); + + afterEach(() => { db.close(); }); + + it('빈 query → 빈 배열', () => { + expect(repo.search('')).toEqual([]); + expect(repo.search(' ')).toEqual([]); + }); + + it('keyword 매칭 → hydrated Note', () => { + const r = repo.search('월요일'); + expect(r.length).toBeGreaterThan(0); + const titles = r.map((n) => n.aiTitle); + expect(titles).toContain('회의록'); + }); + + it('multi-token implicit AND', () => { + const r1 = repo.search('회의 월요일'); + expect(r1.length).toBeGreaterThan(0); + const r2 = repo.search('회의 결재'); // 동시 매칭 노트 없음 + expect(r2).toEqual([]); + }); + + it('default 는 trashed 제외', () => { + const r = repo.search('버려진'); + expect(r).toEqual([]); + }); + + it('status filter 명시 시 해당 status 만', () => { + const r = repo.search('버려진', { status: 'trashed' }); + expect(r.length).toBe(1); + }); + + it('FTS5 special char 안전 처리', () => { + expect(() => repo.search('"회의*" (월요일):')).not.toThrow(); + }); +}); diff --git a/tests/unit/ftsHelpers.test.ts b/tests/unit/ftsHelpers.test.ts new file mode 100644 index 0000000..daf1f9b --- /dev/null +++ b/tests/unit/ftsHelpers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeFtsQuery, computeCutoff } from '../../src/main/repository/ftsHelpers.js'; + +describe('sanitizeFtsQuery', () => { + it('strips FTS5 special chars', () => { + expect(sanitizeFtsQuery('"기획" *회의*')).toBe('기획 회의'); + expect(sanitizeFtsQuery('foo: (bar)')).toBe('foo bar'); + }); + it('keeps Korean + alphanumeric tokens', () => { + expect(sanitizeFtsQuery('회의 결재 v2')).toBe('회의 결재 v2'); + }); + it('collapses whitespace', () => { + expect(sanitizeFtsQuery(' 회의 ')).toBe('회의'); + }); + it('returns empty string for whitespace-only', () => { + expect(sanitizeFtsQuery(' ')).toBe(''); + }); +}); + +describe('computeCutoff', () => { + // KST = UTC+9. KST 자정 = UTC 전날 15:00. + it('daily — KST 오늘 자정 ISO', () => { + const now = new Date('2026-05-10T05:30:00Z'); // KST 14:30 + expect(computeCutoff('daily', now)).toBe('2026-05-09T15:00:00.000Z'); + }); + it('weekly — 7일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('weekly', now)).toBe('2026-05-02T15:00:00.000Z'); + }); + it('monthly — 30일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('monthly', now)).toBe('2026-04-09T15:00:00.000Z'); + }); +}); -- 2.49.1 From 143684ce8ab5a832c5c2c6ae45ae4b0128b2462b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:27:43 +0900 Subject: [PATCH 05/10] feat(v0211): InboxApi.search + reviewAggregate (types + IPC + preload) --- src/main/ipc/inboxApi.ts | 20 ++++++ src/preload/index.ts | 3 + src/shared/types.ts | 12 ++++ tests/unit/inboxApi-search-review.test.ts | 84 +++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 tests/unit/inboxApi-search-review.test.ts diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index cbc74cf..7f617c7 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -292,6 +292,26 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: false as const, reason: (e as Error).message }; } }); + + // v0.2.11 Cut D — FTS5 검색 + 회고 aggregate. + ipcMain.handle( + 'inbox:search', + (_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) => + deps.repo.search(query, opts) + ); + + ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => { + const VALID = ['daily', 'weekly', 'monthly'] as const; + if (!(VALID as readonly string[]).includes(period)) { + return { + totalCount: 0, + recentNotes: [], + tagCounts: [], + dueProgress: { total: 0, passed: 0, pending: 0 } + }; + } + return deps.repo.reviewAggregate(period); + }); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/preload/index.ts b/src/preload/index.ts index 0606fbe..f59ffe2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,6 +85,9 @@ const api: InklingApi = { updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText), listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId), restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId), + // v0.2.11 Cut D — search + 회고 aggregate. + search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}), + reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 82784b9..cc625bf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -31,6 +31,15 @@ export interface NoteRevision { editedBy: 'user' | 'capture'; } +// v0.2.11 Cut D — 회고 view aggregate. +export type ReviewPeriod = 'daily' | 'weekly' | 'monthly'; +export interface ReviewAggregate { + totalCount: number; + recentNotes: Note[]; + tagCounts: Array<{ tag: string; count: number }>; + dueProgress: { total: number; passed: number; pending: number }; +} + export interface Note { id: string; rawText: string; @@ -170,6 +179,9 @@ export interface InboxApi { updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>; listRevisions(noteId: string): Promise; restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>; + // v0.2.11 Cut D — FTS5 search + 회고 aggregate. + search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise; + reviewAggregate(period: ReviewPeriod): Promise; } export interface InklingApi { diff --git a/tests/unit/inboxApi-search-review.test.ts b/tests/unit/inboxApi-search-review.test.ts new file mode 100644 index 0000000..b318c32 --- /dev/null +++ b/tests/unit/inboxApi-search-review.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } })); +import electron from 'electron'; +import { registerInboxApi } from '../../src/main/ipc/inboxApi.js'; +import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js'; + +function getHandler(channel: string): (...args: unknown[]) => unknown { + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const call = handle.mock.calls.find((c) => c[0] === channel); + if (!call) throw new Error(`channel ${channel} not registered`); + return call[1] as (...args: unknown[]) => unknown; +} + +function makeDeps(overrides: Partial = {}): InboxIpcDeps { + const repo = { + search: vi.fn(() => []), + reviewAggregate: vi.fn(() => ({ totalCount: 0, recentNotes: [], tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 } })), + list: vi.fn(), + listByStatus: vi.fn(), + countByStatus: vi.fn(() => 0), + countByAiStatus: vi.fn(() => 0), + countTrashed: vi.fn(() => 0), + countFailed: vi.fn(() => 0), + listTrashed: vi.fn(() => []), + setStatus: vi.fn(), + requeueDisabled: vi.fn(() => 0), + getAllPendingJobs: vi.fn(() => []), + getPendingCount: vi.fn(() => 0), + countToday: vi.fn(() => 0), + findById: vi.fn(), + listRevisions: vi.fn(() => []), + restoreRevision: vi.fn(), + updateRawText: vi.fn() + } as unknown as InboxIpcDeps['repo']; + return { + repo, + continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'], + capture: {} as InboxIpcDeps['capture'], + health: {} as InboxIpcDeps['health'], + intent: {} as InboxIpcDeps['intent'], + getInboxWindow: () => null, + settings: {} as InboxIpcDeps['settings'], + providerHolder: {} as InboxIpcDeps['providerHolder'], + paths: { profileDir: '/tmp' }, + ...overrides + }; +} + +describe('inboxApi search/review IPC', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + }); + + it('inbox:search — repo.search 호출 결과 반환', async () => { + const deps = makeDeps(); + (deps.repo.search as ReturnType).mockReturnValue([{ id: 'a' }]); + registerInboxApi(deps); + const h = getHandler('inbox:search'); + const r = await h({}, '회의', { status: 'active', limit: 10 }); + expect(deps.repo.search).toHaveBeenCalledWith('회의', { status: 'active', limit: 10 }); + expect(r).toEqual([{ id: 'a' }]); + }); + + it('inbox:review-aggregate — repo.reviewAggregate 호출 결과 반환', async () => { + const deps = makeDeps(); + const fake = { totalCount: 5, recentNotes: [], tagCounts: [{ tag: 'x', count: 2 }], dueProgress: { total: 1, passed: 1, pending: 0 } }; + (deps.repo.reviewAggregate as ReturnType).mockReturnValue(fake); + registerInboxApi(deps); + const h = getHandler('inbox:review-aggregate'); + const r = await h({}, 'weekly'); + expect(deps.repo.reviewAggregate).toHaveBeenCalledWith('weekly'); + expect(r).toEqual(fake); + }); + + it('inbox:review-aggregate — 잘못된 period reject', async () => { + const deps = makeDeps(); + registerInboxApi(deps); + const h = getHandler('inbox:review-aggregate'); + const r = await h({}, 'yearly'); + expect(deps.repo.reviewAggregate).not.toHaveBeenCalled(); + expect(r).toMatchObject({ totalCount: 0 }); + }); +}); -- 2.49.1 From f5e43133bed0156d4082c57a996e489b3970e374 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:31:53 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(v0211):=20store=20=E2=80=94=20search?= =?UTF-8?q?=20+=20reviewData=20state=20+=20actions=20+=20view=20enum=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/inbox/store.ts | 47 +++++++++++++++++++++++++++++-- tests/unit/store.search.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/unit/store.search.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index c8d136a..9cde8c2 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Note, WeeklyContinuity } from '@shared/types'; +import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types'; import { inboxApi } from './api.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js'; @@ -7,7 +7,9 @@ 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 type InboxView = + | 'inbox' | 'completed' | 'archived' | 'trash' | 'settings' + | 'review-daily' | 'review-weekly' | 'review-monthly'; export interface InboxCounts { active: number; @@ -39,6 +41,10 @@ interface InboxState { // v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip. // 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드. ai_enabled: boolean; + // v0.2.11 Cut D — FTS5 search + review aggregate state. + searchQuery: string; + searchResults: Note[] | null; // null = 검색 안 한 상태 + reviewData: ReviewAggregate | null; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -61,6 +67,11 @@ interface InboxState { openRecall: (id: string) => Promise; dismissRecallNote: (id: string) => Promise; snoozeRecall: () => Promise; + // v0.2.11 Cut D — search + review actions. + setSearchQuery: (q: string) => void; + searchNotes: (q: string) => Promise; + clearSearch: () => void; + loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -88,6 +99,9 @@ export const useInbox = create((set, get) => ({ recallCandidate: null, recallSnoozeUntilMs: null, ai_enabled: true, + searchQuery: '', + searchResults: null, + reviewData: null, async loadInitial() { set({ loading: true }); const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ @@ -178,6 +192,10 @@ export const useInbox = create((set, get) => ({ if (view === 'completed' || view === 'archived' || view === 'trash') { void get().loadByView(view); } + // v0.2.11 Cut D — review-* view 진입 시 aggregate 로드. + if (view === 'review-daily') void get().loadReview('daily'); + if (view === 'review-weekly') void get().loadReview('weekly'); + if (view === 'review-monthly') void get().loadReview('monthly'); }, async loadByView(view) { const status = view === 'trash' ? 'trashed' : view; @@ -269,5 +287,30 @@ export const useInbox = create((set, get) => ({ if (candidate) { await inboxApi.emitRecallSnoozed(candidate.id); } + }, + // v0.2.11 Cut D — FTS5 search + review aggregate actions. + setSearchQuery(q) { + set({ searchQuery: q }); + if (q.trim().length === 0) set({ searchResults: null }); + }, + async searchNotes(q) { + if (q.trim().length === 0) { + set({ searchResults: null }); + return; + } + const view = get().view; + // 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색 + const status = view === 'completed' || view === 'archived' || view === 'trash' + ? (view === 'trash' ? 'trashed' : view) + : view === 'inbox' ? 'active' : undefined; + const r = await inboxApi.search(q, status ? { status } : {}); + set({ searchResults: r }); + }, + clearSearch() { + set({ searchQuery: '', searchResults: null }); + }, + async loadReview(period) { + const data = await inboxApi.reviewAggregate(period); + set({ reviewData: data }); } })); diff --git a/tests/unit/store.search.test.ts b/tests/unit/store.search.test.ts new file mode 100644 index 0000000..446fa50 --- /dev/null +++ b/tests/unit/store.search.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + search: vi.fn(), + reviewAggregate: vi.fn(), + listNotes: vi.fn(() => []), + getContinuity: vi.fn(() => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(() => 0), + getOllamaStatus: vi.fn(() => ({ ok: true })), + getTodayCount: vi.fn(() => 0), + getTrashCount: vi.fn(() => 0), + listExpired: vi.fn(() => []), + getFailedCount: vi.fn(() => 0), + listRecallCandidate: vi.fn(() => null), + countsByStatus: vi.fn(() => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + getSettings: vi.fn(() => ({ ai_enabled: true })), + listByStatus: vi.fn(() => []) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; + +describe('store — searchNotes', () => { + beforeEach(() => { + vi.clearAllMocks(); + useInbox.setState({ searchQuery: '', searchResults: null, view: 'inbox' }); + }); + + it('빈 query → searchResults null + IPC 미호출', async () => { + await useInbox.getState().searchNotes(' '); + expect(useInbox.getState().searchResults).toBeNull(); + expect(inboxApi.search).not.toHaveBeenCalled(); + }); + + it('keyword query → IPC 호출 + searchResults set', async () => { + (inboxApi.search as ReturnType).mockResolvedValue([{ id: 'a' }]); + await useInbox.getState().searchNotes('회의'); + expect(inboxApi.search).toHaveBeenCalledWith('회의', { status: 'active' }); + expect(useInbox.getState().searchResults).toEqual([{ id: 'a' }]); + }); + + it('clearSearch — query + results 모두 초기화', () => { + useInbox.setState({ searchQuery: '회의', searchResults: [{ id: 'a' } as never] }); + useInbox.getState().clearSearch(); + expect(useInbox.getState().searchQuery).toBe(''); + expect(useInbox.getState().searchResults).toBeNull(); + }); +}); -- 2.49.1 From be125b8ace739d60af2103049b88a19b8454b907 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:35:34 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(v0211):=20SearchBox=20+=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20mount=20+=20inbox=20=EA=B2=B0=EA=B3=BC=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/inbox/App.tsx | 10 ++++- src/renderer/inbox/components/SearchBox.tsx | 34 +++++++++++++++ tests/unit/SearchBox.test.tsx | 47 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/renderer/inbox/components/SearchBox.tsx create mode 100644 tests/unit/SearchBox.test.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 071b9e1..9fb8f19 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -14,6 +14,7 @@ import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; import { SettingsPage } from './components/SettingsPage.js'; import { OnboardingWizard } from './components/OnboardingWizard.js'; +import { SearchBox } from './components/SearchBox.js'; export function App(): React.ReactElement { const { @@ -28,6 +29,7 @@ export function App(): React.ReactElement { const view = useInbox((s) => s.view); const counts = useInbox((s) => s.counts); const setView = useInbox((s) => s.setView); + const searchResults = useInbox((s) => s.searchResults); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); // v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시. const [showOnboarding, setShowOnboarding] = useState(null); @@ -71,6 +73,7 @@ export function App(): React.ReactElement { const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; const filtered = selectFilteredNotes({ notes, tagFilter }); + const displayed = searchResults !== null ? searchResults : filtered; const tabBtnStyle = (active: boolean): React.CSSProperties => ({ background: active ? '#0a4b80' : 'transparent', @@ -105,6 +108,7 @@ export function App(): React.ReactElement { ))}
+
@@ -155,12 +159,14 @@ export function App(): React.ReactElement { )} {loading && notes.length === 0 ? (
불러오는 중…
+ ) : searchResults !== null && displayed.length === 0 ? ( +
검색 결과가 없습니다.
) : notes.length === 0 ? (
머릿속에 떠다니는 한 줄을 적어보세요. Ctrl+Shift+J
- ) : filtered.length === 0 ? ( + ) : displayed.length === 0 ? (
이 태그의 노트가 없습니다.
) : ( - filtered.map((n) => ( + displayed.map((n) => ( removeNote(n.id)} diff --git a/src/renderer/inbox/components/SearchBox.tsx b/src/renderer/inbox/components/SearchBox.tsx new file mode 100644 index 0000000..2de58a8 --- /dev/null +++ b/src/renderer/inbox/components/SearchBox.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import { useInbox } from '../store.js'; + +export function SearchBox(): React.ReactElement { + const [draft, setDraft] = useState(''); + + useEffect(() => { + const handle = setTimeout(() => { + const trimmed = draft.trim(); + if (trimmed.length === 0) useInbox.getState().clearSearch(); + else void useInbox.getState().searchNotes(trimmed); + }, 200); + return () => clearTimeout(handle); + }, [draft]); + + return ( + setDraft(e.target.value)} + aria-label="노트 검색" + style={{ + marginLeft: 12, + padding: '4px 8px', + fontSize: 12, + border: '1px solid #bbb', + borderRadius: 4, + width: 200 + }} + /> + ); +} diff --git a/tests/unit/SearchBox.test.tsx b/tests/unit/SearchBox.test.tsx new file mode 100644 index 0000000..9292ae7 --- /dev/null +++ b/tests/unit/SearchBox.test.tsx @@ -0,0 +1,47 @@ +// @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 React from 'react'; + +const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({ + mockSearchNotes: vi.fn(), + mockClearSearch: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: Object.assign( + (selector?: (s: { searchQuery: string }) => unknown) => { + const state = { searchQuery: '' }; + return selector ? selector(state) : state; + }, + { getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) } + ) +})); + +import { SearchBox } from '../../src/renderer/inbox/components/SearchBox'; + +describe('SearchBox', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.useFakeTimers(); + }); + + it('타이핑 → 200ms debounce 후 searchNotes 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '회의' } }); + expect(mockSearchNotes).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(mockSearchNotes).toHaveBeenCalledWith('회의'); + }); + + it('빈 값 → clearSearch 호출', () => { + render(); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: '' } }); + vi.advanceTimersByTime(200); + expect(mockClearSearch).toHaveBeenCalled(); + }); +}); -- 2.49.1 From 9feb712c60ebcb5b930f68c4c3d5fbcbbe0815fd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:39:36 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat(v0211):=20ReviewView=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=BC/=EC=A3=BC/=EC=9B=94=20=ED=9A=8C=EA=B3=A0=20+=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20dropdown=20=EC=A7=84=EC=9E=85=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/inbox/App.tsx | 20 ++++++ src/renderer/inbox/components/ReviewView.tsx | 56 +++++++++++++++++ tests/unit/ReviewView.test.tsx | 64 ++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/renderer/inbox/components/ReviewView.tsx create mode 100644 tests/unit/ReviewView.test.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 9fb8f19..691fef6 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -15,6 +15,8 @@ import { RecallBanner } from './components/RecallBanner.js'; import { SettingsPage } from './components/SettingsPage.js'; import { OnboardingWizard } from './components/OnboardingWizard.js'; import { SearchBox } from './components/SearchBox.js'; +import { ReviewView } from './components/ReviewView.js'; +import type { InboxView } from './store.js'; export function App(): React.ReactElement { const { @@ -69,6 +71,10 @@ export function App(): React.ReactElement { if (showOnboarding === null) return <>; if (showOnboarding) return setShowOnboarding(false)} />; + if (view === 'review-daily') return ; + if (view === 'review-weekly') return ; + if (view === 'review-monthly') return ; + if (showSettings) return ; const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; @@ -108,6 +114,20 @@ export function App(): React.ReactElement { ))}
+
diff --git a/src/renderer/inbox/components/ReviewView.tsx b/src/renderer/inbox/components/ReviewView.tsx new file mode 100644 index 0000000..78be1e0 --- /dev/null +++ b/src/renderer/inbox/components/ReviewView.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useInbox } from '../store.js'; +import { NoteCard } from './NoteCard.js'; + +interface Props { + period: 'daily' | 'weekly' | 'monthly'; +} + +const periodLabel: Record = { + daily: '일간', + weekly: '주간', + monthly: '월간' +}; + +export function ReviewView({ period }: Props): React.ReactElement { + const reviewData = useInbox((s) => s.reviewData); + if (!reviewData) { + return
불러오는 중…
; + } + const max = reviewData.tagCounts[0]?.count ?? 1; + return ( +
+

{periodLabel[period]} 회고

+
+ 총 {reviewData.totalCount}건 +
+
+

태그 분포

+ {reviewData.tagCounts.length === 0 && ( +
태그 없음
+ )} + {reviewData.tagCounts.slice(0, 10).map((t) => ( +
+ {t.tag} +
+
+
+ {t.count} +
+ ))} +
+
+

마감 진행

+
+ 완료 {reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · 대기 {reviewData.dueProgress.pending} +
+
+
+

최근 노트 ({reviewData.recentNotes.length})

+ {reviewData.recentNotes.map((n) => ( + {}} /> + ))} +
+
+ ); +} diff --git a/tests/unit/ReviewView.test.tsx b/tests/unit/ReviewView.test.tsx new file mode 100644 index 0000000..e94268a --- /dev/null +++ b/tests/unit/ReviewView.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; + +const baseState = { + reviewData: { + totalCount: 12, + recentNotes: [], + tagCounts: [{ tag: '회의', count: 5 }, { tag: '결재', count: 3 }], + dueProgress: { total: 10, passed: 4, pending: 6 } + } +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + openMedia: vi.fn(), + deleteNote: vi.fn(), + restoreNote: vi.fn(), + permanentDeleteNote: vi.fn(), + updateAiFields: vi.fn(), + setDueDate: vi.fn(), + setIntent: vi.fn(), + dismissIntent: vi.fn(), + setStatus: vi.fn(async () => ({ ok: true as const })), + classifyStatus: vi.fn(async () => ({ recommended: 'archived' as const, rationale: 'stub' })), + updateRawText: vi.fn(async () => ({ ok: true as const })), + listRevisions: vi.fn(async () => []), + getRevision: vi.fn() + } +})); + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: Object.assign( + (selector?: (s: typeof baseState) => unknown) => (selector ? selector(baseState) : baseState), + { getState: () => baseState } + ) +})); + +import { ReviewView } from '../../src/renderer/inbox/components/ReviewView'; + +describe('ReviewView', () => { + beforeEach(() => { cleanup(); }); + + it('daily — 라벨 + totalCount + tagBar + dueProgress 렌더', () => { + render(); + expect(screen.getByText(/일간/)).toBeInTheDocument(); + expect(screen.getByText(/총.*12건/)).toBeInTheDocument(); + expect(screen.getByText('회의')).toBeInTheDocument(); + expect(screen.getByText('결재')).toBeInTheDocument(); + expect(screen.getByText(/4.*\/.*10/)).toBeInTheDocument(); + }); + + it('weekly — 라벨 weekly', () => { + render(); + expect(screen.getByText(/주간/)).toBeInTheDocument(); + }); + + it('monthly — 라벨 monthly', () => { + render(); + expect(screen.getByText(/월간/)).toBeInTheDocument(); + }); +}); -- 2.49.1 From 5801a98a00c37b136e34d2232bab07e200b09037 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:41:42 +0900 Subject: [PATCH 09/10] =?UTF-8?q?chore(release):=20v0.2.11=20=E2=80=94=20C?= =?UTF-8?q?ut=20D=20(FTS5=20search=20+=20=ED=9A=8C=EA=B3=A0=20view)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F19 promoted (✅ v0.2.11 Cut D — A+D 옵션) - version 0.2.10 → 0.2.11 (package.json + package-lock.json) - 단위 569 → 606 (m007 6 + tags sync 2 + ftsHelpers 7 + search 6 + reviewAggregate 5 + IPC 3 + store 3 + SearchBox 2 + ReviewView 3 = 37 신규) - typecheck 0 errors --- docs/superpowers/specs/2026-04-25-dogfood-feedback.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index 5ba3c1a..8bbe3ea 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1423,9 +1423,9 @@ app.on('activate', () => { --- -## F19. 획기적 recall 메커니즘 (🌱 raw — v0.2.8+ 큰 영역, 본질 재설계 가능) +## F19. 획기적 recall 메커니즘 (✅ promoted v0.2.11 Cut D — A+D 옵션) -**진행 상태:** 🌱 raw — 핵심 가치 영역. v0.2.8 brainstorm 시 별도 spec 후보 (recall 만 단독 cut 가치). +**진행 상태:** ✅ promoted v0.2.11 Cut D — A (FTS5 search) + D (일/주/월 회고 view) 적용. m007 마이그레이션 + `NoteRepository.search` + `reviewAggregate` + SearchBox + ReviewView. B/C/E/F 옵션은 v0.3+ deferred. **발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. "메모의 빠른 기록도 중요하지만 적절한 recall 도 훨씬 중요" — 본인 표현. diff --git a/package-lock.json b/package-lock.json index 0231076..5b9e9a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.10", + "version": "0.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.10", + "version": "0.2.11", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 7f08beb..549ca97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.10", + "version": "0.2.11", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", -- 2.49.1 From 735d5494f2d07a18c09296f47e359a0b1bf13499 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:46:58 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(v0211):=20importNote=20=EA=B0=80=20re?= =?UTF-8?q?buildFtsTagsForNote=20=ED=98=B8=EC=B6=9C=20(final=20review=20fi?= =?UTF-8?q?x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit final code review 발견: F5 import path 가 note_tags INSERT 후 notes_fts.tags 갱신 안 해서 import 한 노트의 tag 가 keyword 검색에서 매칭 안 되는 회귀. Cut C 의 importNote capture revision 누락 패턴과 동일 — single write path 정책 (Cut D 도입) 의 강제 검사 누락. importNote transaction 끝에서 호출하도록 fix + 회귀 test 2건 (insert path / fork path) 추가. NoteRepository 안 note_tags INSERT path 는 updateAiResult / updateUserAiFields / importNote 3곳, 셋 다 rebuildFtsTagsForNote 호출 보장 — invariant 회복. --- src/main/repository/NoteRepository.ts | 7 ++++ tests/unit/NoteRepository.test.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 0ed8fe2..7f76045 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -780,6 +780,11 @@ export class NoteRepository { * 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user * edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견). * + * v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId) + * 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는 + * notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서 + * 매칭 안 되는 회귀 (final review 발견). + * * deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선 * (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신. * insert/fork 는 source 의 deletedAt 그대로 보존. @@ -850,6 +855,8 @@ export class NoteRepository { if (t.source === 'ai') linkAi.run(finalId, row.id); else linkUser.run(finalId, row.id); } + // v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path). + this.rebuildFtsTagsForNote(finalId); } }); tx(); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 6a59bab..82002ff 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -1103,4 +1103,57 @@ describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => { .get(id) as { tags: string }; expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']); }); + + it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => { + const repo = new NoteRepository(db); + const r = repo.importNote({ + id: '00000000-0000-0000-0000-000000000010', + rawText: 'imported with tags', + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', + aiTitle: 'imported title', + aiSummary: 'imported summary', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'p', + aiGeneratedAt: '2026-04-01T00:00:00Z', + userIntent: null, + intentPromptedAt: null, + tags: [ + { name: '기획', source: 'ai' }, + { name: '회의', source: 'user' } + ] + }); + expect(r.status).toBe('inserted'); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(r.id) as { tags: string }; + expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']); + }); + + it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => { + const repo = new NoteRepository(db); + const existing = repo.create({ rawText: 'v1' }); + const r = repo.importNote({ + id: existing.id, + rawText: 'imported v2 with tags', + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', + aiTitle: null, + aiSummary: null, + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: null, + aiGeneratedAt: null, + userIntent: null, + intentPromptedAt: null, + tags: [{ name: '결재', source: 'user' }] + }); + expect(r.status).toBe('forked'); + expect(r.id).not.toBe(existing.id); + const row = db + .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) + .get(r.id) as { tags: string }; + expect(row.tags).toBe('결재'); + }); }); -- 2.49.1