298 lines
11 KiB
Markdown
298 lines
11 KiB
Markdown
# v0.2.11 — Cut D Design (FTS5 search + 회고 view)
|
|
|
|
**작성일:** 2026-05-09
|
|
**선행 문서:**
|
|
|
|
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F19)
|
|
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut D
|
|
|
|
**Cut 라벨:** v0.2.11
|
|
|
|
---
|
|
|
|
## 1. Cut 정체성
|
|
|
|
recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A (FTS5 search) + D (회고 view)** 2개. B/C/E/F 는 v0.3+ deferred.
|
|
|
|
---
|
|
|
|
## 2. 범위
|
|
|
|
| 항목 | 결정 |
|
|
|---|---|
|
|
| **F19-A** | SQLite FTS5 인덱스 + inbox 헤더 search box |
|
|
| **F19-D** | 일/주/월 회고 라우트 — aggregate query + N건 list + tag distribution + due 진행 |
|
|
|
|
---
|
|
|
|
## 3. F19-A 디테일 (FTS5)
|
|
|
|
### 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,
|
|
ai_title,
|
|
ai_summary,
|
|
tags,
|
|
tokenize='unicode61'
|
|
);
|
|
|
|
-- 기존 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` 컬럼 = 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 (notes 컬럼 한정)
|
|
|
|
`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, 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
|
|
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
|
END;
|
|
|
|
CREATE TRIGGER notes_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;
|
|
```
|
|
|
|
Cut C 의 `updateRawText` 가 `notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
|
|
|
|
`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[] {
|
|
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 ? [ftsQuery, opts.status, limit] : [ftsQuery, limit];
|
|
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
|
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
|
|
|
|
기존 헤더 (Inbox/완료/보관/휴지통 탭) 옆에 search input:
|
|
|
|
```tsx
|
|
<input
|
|
type="search"
|
|
placeholder="검색..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
style={{ ... }}
|
|
/>
|
|
```
|
|
|
|
debounce 200ms → store action `searchNotes(query)` → `inboxApi.search(query, { status: currentView })` → result list 갱신.
|
|
|
|
빈 query → 기본 inbox list 복귀.
|
|
|
|
### 3-5. IPC
|
|
|
|
```ts
|
|
'inbox:search': (query: string, opts: { status?: NoteStatus; limit?: number }) => Promise<Note[]>
|
|
```
|
|
|
|
---
|
|
|
|
## 4. F19-D 디테일 (회고 view)
|
|
|
|
### 4-1. 라우트 추가
|
|
|
|
`useInbox.view` enum 에 `'review-daily' | 'review-weekly' | 'review-monthly'` 추가. 진입점:
|
|
|
|
- 헤더 메뉴: "📅 회고" 버튼 → 드롭다운 (일/주/월)
|
|
- 또는 별도 라우트 (Settings 옆)
|
|
|
|
### 4-2. 회고 view 컴포넌트
|
|
|
|
```tsx
|
|
// src/renderer/inbox/components/ReviewView.tsx
|
|
export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly' }): ReactElement {
|
|
const data = useReviewData(period); // store action — aggregate query 결과
|
|
return (
|
|
<div>
|
|
<h2>{periodLabel(period)} 회고</h2>
|
|
<div>총 N건 • 오늘 N건 • 평균 일 N건</div>
|
|
<TagDistributionChart tags={data.tagCounts} />
|
|
<DueProgressChart due={data.dueProgress} />
|
|
<NoteList notes={data.recentNotes} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 4-3. Aggregate query
|
|
|
|
NoteRepository:
|
|
|
|
```ts
|
|
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); // 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<string, unknown>[];
|
|
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 자정 (오늘 시작) 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
|
|
|
|
간단한 bar list (CSS — chart 라이브러리 X):
|
|
|
|
```tsx
|
|
{data.tagCounts.slice(0, 10).map(t => (
|
|
<div key={t.tag}>
|
|
<span>{t.tag}</span>
|
|
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8 }} />
|
|
<span>{t.count}</span>
|
|
</div>
|
|
))}
|
|
```
|
|
|
|
### 4-5. Due progress
|
|
|
|
```
|
|
완료 (passed): 12 / 25
|
|
대기 (pending): 13
|
|
이번 주 due: 3건
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 테스트 전략
|
|
|
|
| 영역 | 단위 |
|
|
|---|---|
|
|
| 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 결과 렌더 + period 라벨 |
|
|
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) |
|
|
| `computeCutoff` | daily/weekly/monthly KST 자정 ISO |
|
|
|
|
**목표**: 단위 569 → 약 595 (+26), typecheck 0.
|
|
|
|
---
|
|
|
|
## 6. Risk
|
|
|
|
| Risk | 대응 |
|
|
|---|---|
|
|
| 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). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 |
|
|
| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 |
|
|
| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
|
|
|
|
---
|
|
|
|
## 7. v0.2.11 후
|
|
|
|
**Cut E** (v0.3.0) — F21 양방향 sync.
|
|
|
|
**dogfood verify**:
|
|
|
|
1. search 일 사용 빈도 (가설: ≥ 일 1회면 가치 있음)
|
|
2. 회고 view 사용 빈도 (월요일 자동 prompt 추가 검토 — v0.3+)
|
|
3. FTS5 한국어 token 정확도 (사용자 query 결과 만족도)
|