Files
inkling/docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md

52 KiB

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.tssanitizeFtsQuery, 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.tsReviewAggregate 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.tsupdateAiResult / updateUserAiFields 가 FTS tags 컬럼 sync 회귀 1건 추가
  • package.json — version 0.2.100.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:
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:

// 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 갱신:
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 errorsnpm run typecheck.

  • Step 8: commit:

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 안에:
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 메서드 근처):

/**
 * 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 다음) 에:

this.rebuildFtsTagsForNote(id);

updateUserAiFieldsif (fields.tags !== undefined) 블록 안 for-loop 종료 후:

this.rebuildFtsTagsForNote(id);
  • Step 4: test PASS 확인npx vitest run tests/unit/NoteRepository.test.ts.

  • Step 5: commit:

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:
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:

/**
 * 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 PASSnpx vitest run tests/unit/ftsHelpers.test.ts. Expected: 7/7.

  • Step 5: commit:

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:
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 FAILrepo.search 미정의.

  • Step 3: NoteRepository.search 구현src/main/repository/NoteRepository.ts 의 import 에 ftsHelpers 추가:

import { sanitizeFtsQuery } from './ftsHelpers.js';

새 메서드 (다른 list 메서드 근처):

/**
 * 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<string, unknown>[];
  return rows.map((r) => this.hydrate(r));
}
  • Step 4: test PASSnpx vitest run tests/unit/NoteRepository.search.test.ts.

  • Step 5: commit:

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:
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 FAILrepo.reviewAggregate 미정의.

  • Step 3: 구현src/main/repository/NoteRepository.ts 의 import:

import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
import { kstTodayIso } from '../../shared/util/kstDate.js';

kstTodayIso 는 이미 import 되어 있으면 추가 불필요. 신규 메서드:

/**
 * 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<string, unknown>[];
  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:

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

InboxApisearch / reviewAggregate + ReviewAggregate 타입 추가. IPC handler 등록 + preload bridge.

  • Step 1: types 추가src/shared/types.tsNoteRevision 아래:
// 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 의 마지막에:

  // v0.2.11 Cut D — FTS5 search + 회고 aggregate.
  search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
  reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
  • Step 2: failing test 작성tests/unit/inboxApi-search-review.test.ts (Cut C inboxApi-revisions.test.ts 패턴 mirror):
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<typeof vi.fn> }).handle;
  const call = handle.mock.calls.find((c) => c[0] === channel);
  if (!call) throw new Error(`channel ${channel} not registered`);
  return call[1] as (...args: unknown[]) => unknown;
}

function makeDeps(overrides: Partial<InboxIpcDeps> = {}): 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<typeof vi.fn> }).handle.mockClear();
  });

  it('inbox:search — repo.search 호출 결과 반환', async () => {
    const deps = makeDeps();
    (deps.repo.search as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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 다음:

  // 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 bridgesrc/preload/index.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:
npm run typecheck
npx vitest run tests/unit/inboxApi-search-review.test.ts
  • Step 7: commit:
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:
export type InboxView =
  | 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
  | 'review-daily' | 'review-weekly' | 'review-monthly';

state 에:

  searchQuery: string;
  searchResults: Note[] | null;  // null = 검색 안 한 상태
  reviewData: ReviewAggregate | null;

(import 에 ReviewAggregate 추가)

initial state:

  searchQuery: '',
  searchResults: null,
  reviewData: null,

actions:

  setSearchQuery: (q: string) => void;
  searchNotes: (q: string) => Promise<void>;
  clearSearch: () => void;
  loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;

구현:

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 안에 추가:

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 (간단):
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<typeof vi.fn>).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:
npm run typecheck
npx vitest run tests/unit/store.search.test.ts
  • Step 4: commit:
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:
// @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(<SearchBox />);
    const input = screen.getByRole('searchbox');
    fireEvent.change(input, { target: { value: '회의' } });
    expect(mockSearchNotes).not.toHaveBeenCalled();
    vi.advanceTimersByTime(200);
    expect(mockSearchNotes).toHaveBeenCalledWith('회의');
  });

  it('빈 값 → clearSearch 호출', () => {
    render(<SearchBox />);
    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:

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 (
    <input
      type="search"
      role="searchbox"
      placeholder="검색…"
      value={draft}
      onChange={(e) => 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:

import { SearchBox } from './components/SearchBox.js';

헤더의 탭 버튼 다음 (<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', ... }}> 직전):

<SearchBox />

main 의 list 렌더 분기:

const filtered = selectFilteredNotes({ notes, tagFilter }); 다음에:

const searchResults = useInbox((s) => s.searchResults);
const displayed = searchResults !== null ? searchResults : filtered;

(이 변수는 inbox view 에서만 사용. trash/settings/review-* view 는 분기 별도.)

기존 inbox view 의 filtered.map(...) 부분을 displayed.map(...) 으로 변경 + 빈 결과 안내 분기 보강:

{searchResults !== null && displayed.length === 0 ? (
  <div className="empty">검색 결과가 없습니다.</div>
) : displayed.length === 0 ? (
  <div className="empty">머릿속에 떠다니는  줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
) : (
  displayed.map((n) => (
    <NoteCard
      key={n.id} note={n} mode="inbox"
      onDeleted={() => removeNote(n.id)}
      onUpdated={(u) => upsertNote(u)}
    />
  ))
)}

(기존 filtered.length === 0 ? 분기는 유지 — tagFilter 케이스 보존. displayed 도입은 notes.length === 0 빈 inbox 분기와 함께 점검.)

  • Step 5: tests + typecheck PASS:
npm run typecheck
npx vitest run tests/unit/SearchBox.test.tsx
npx vitest run tests/unit/App.test.tsx  # 회귀 — 기존 App test 유지
  • Step 6: commit:
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:
// @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(<ReviewView period="daily" />);
    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(<ReviewView period="weekly" />);
    expect(screen.getByText(/주간/)).toBeInTheDocument();
  });

  it('monthly — 라벨 monthly', () => {
    render(<ReviewView period="monthly" />);
    expect(screen.getByText(/월간/)).toBeInTheDocument();
  });
});
  • Step 2: test FAIL — module not found.

  • Step 3: 구현src/renderer/inbox/components/ReviewView.tsx:

import React from 'react';
import { useInbox } from '../store.js';
import { NoteCard } from './NoteCard.js';

interface Props {
  period: 'daily' | 'weekly' | 'monthly';
}

const periodLabel: Record<Props['period'], string> = {
  daily: '일간',
  weekly: '주간',
  monthly: '월간'
};

export function ReviewView({ period }: Props): React.ReactElement {
  const reviewData = useInbox((s) => s.reviewData);
  if (!reviewData) {
    return <div style={{ padding: 16, fontSize: 13, color: '#666' }}>불러오는 중…</div>;
  }
  const max = reviewData.tagCounts[0]?.count ?? 1;
  return (
    <div style={{ padding: 16 }}>
      <h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} 회고</h2>
      <div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
         {reviewData.totalCount}
      </div>
      <section style={{ marginTop: 16 }}>
        <h3 style={{ fontSize: 14, marginBottom: 4 }}>태그 분포</h3>
        {reviewData.tagCounts.length === 0 && (
          <div style={{ fontSize: 12, color: '#888' }}>태그 없음</div>
        )}
        {reviewData.tagCounts.slice(0, 10).map((t) => (
          <div key={t.tag} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
            <span style={{ fontSize: 12, width: 80 }}>{t.tag}</span>
            <div style={{ flex: 1, background: '#eee', height: 8, borderRadius: 2 }}>
              <div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8, borderRadius: 2 }} />
            </div>
            <span style={{ fontSize: 12, color: '#666', width: 30, textAlign: 'right' }}>{t.count}</span>
          </div>
        ))}
      </section>
      <section style={{ marginTop: 16 }}>
        <h3 style={{ fontSize: 14, marginBottom: 4 }}>마감 진행</h3>
        <div style={{ fontSize: 13, color: '#444' }}>
          완료 {reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · 대기 {reviewData.dueProgress.pending}
        </div>
      </section>
      <section style={{ marginTop: 16 }}>
        <h3 style={{ fontSize: 14, marginBottom: 4 }}>최근 노트 ({reviewData.recentNotes.length})</h3>
        {reviewData.recentNotes.map((n) => (
          <NoteCard key={n.id} note={n} mode="inbox" onUpdated={() => {}} />
        ))}
      </section>
    </div>
  );
}
  • Step 4: App.tsx 헤더 dropdown + main 분기

헤더 탭 버튼 다음에 회고 드롭다운 (단순 <select>):

<select
  aria-label="회고 기간"
  value={view.startsWith('review-') ? view.replace('review-', '') : ''}
  onChange={(e) => {
    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' }}
>
  <option value="">📅 회고…</option>
  <option value="daily">일간</option>
  <option value="weekly">주간</option>
  <option value="monthly">월간</option>
</select>

main 분기 — if (showSettings) return <SettingsPage />; 직전 또는 직후에:

if (view === 'review-daily') return <ReviewView period="daily" />;
if (view === 'review-weekly') return <ReviewView period="weekly" />;
if (view === 'review-monthly') return <ReviewView period="monthly" />;

import:

import { ReviewView } from './components/ReviewView.js';
import type { InboxView } from './store.js';
  • Step 5: tests + typecheck PASS:
npm run typecheck
npx vitest run tests/unit/ReviewView.test.tsx
  • Step 6: commit:
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 옵션) 추가. 본문 마지막에:

**상태**: ✅ 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:

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:

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 검증