From cfd34c352b82781a1d5f17f2cf71a097adccba63 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:01:37 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20F1=20due=20date=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D=20(6=20tasks,=20migration=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-26-f1-due-date.md | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-f1-due-date.md diff --git a/docs/superpowers/plans/2026-04-26-f1-due-date.md b/docs/superpowers/plans/2026-04-26-f1-due-date.md new file mode 100644 index 0000000..a2a4222 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-f1-due-date.md @@ -0,0 +1,355 @@ +# F1 Due Date Implementation Plan + +**Goal:** Extract `due_date` (ISO YYYY-MM-DD) from Korean note text. Hybrid: rule parser first (regex + KST), AI fallback in same generate response. Store on notes table, render as inline grey label on Inbox card, editable. + +**Architecture:** Pure rule parser (regex + KST math) → AiWorker injects rule result + TODAY_KST into AI prompt → AI returns `due_date` field; final value = rule match if any else AI value. NoteRepository v2 schema + edited-flag invariant (mirrors title/summary pattern). + +**Tech Stack:** TypeScript, vitest, zod 4, no new deps. + +## File Structure + +**Create:** +- `src/main/db/migrations/m002_due_date.ts` — schema migration +- `src/main/services/dueDateParser.ts` — pure rule parser +- `tests/unit/dueDateParser.test.ts` — golden fixtures +- `tests/unit/migrations.due_date.test.ts` — migration test +- `docs/superpowers/specs/2026-04-26-f1-due-date.md` — promoted spec + +**Modify:** +- `src/main/db/migrations/index.ts` — register m002 +- `src/main/db/migrations/m001_initial.ts` — version export consistency check (already at 1) +- `src/main/repository/NoteRepository.ts` — extend `updateAiResult`, new `setDueDate`, `hydrate` returns dueDate fields +- `src/shared/types.ts` — `Note.dueDate`, `Note.dueDateEditedByUser` +- `src/main/ai/schema.ts` — zod `due_date` field, AiResponse extends +- `src/main/ai/prompt.ts` — inject `{{TODAY_KST}}` + add due_date instruction +- `src/main/ai/AiWorker.ts` — call rule parser, merge with AI response +- `src/main/ipc/inboxApi.ts` — extend `inbox:updateAi` to accept `dueDate?`, new `inbox:setDueDate` +- `src/preload/index.ts` — expose `setDueDate` +- `src/renderer/inbox/store.ts` — wire setDueDate +- `src/renderer/inbox/components/NoteCard.tsx` — render due_date badge inline next to title +- `src/renderer/inbox/components/EditableField.tsx` — reuse for date editing OR add small DateField +- `tests/unit/NoteRepository.test.ts` — due_date insertion + edited-flag guard cases +- `tests/unit/ai-schema.test.ts` — due_date field validation +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F1 status update + +**Schema change: YES (migration v2). No new dependencies.** + +## Decisions + +| 결정 항목 | 값 | 근거 | +|----------|-----|------| +| 매칭 우선순위 | 규칙 파서 우선 → 매칭 없으면 AI 응답 사용 | 결정론적 우선 | +| 모호 표현 | AI 위임 (e.g., "월말", "주말") | 규칙 파서 false positive 회피 | +| 만료된 노트 | 회색 + 취소선 (라벨만, 노트 본문 무수정) | slice §1.1 카피 정책 | +| 라벨 슬롯 | 제목 옆 인라인 (`📅 YYYY-MM-DD` 작은 회색) | 시각 노이즈 최소 | +| 사용자 편집 | EditableField 재사용 + date input | 일관성 | +| 시각 단위 | strip (시간 정보 무시, 날짜만) | F1 spec §Out | +| 음력 / 절기 | 미지원 | F1 spec §Out | +| 반복 일정 | 미지원 | F1 spec §Out | +| 만료 자동 처리 | 없음 (시각 표시만) | dogfood 피드백 후 결정 | +| 별도 뷰 / 정렬 | 없음 | 후속 | +| Migration v2 backfill | 없음 (NULL 기본값) | 비파괴 | + +## Pre-migration snapshot (F6-L1 follow-up #4) + +`runMigrations()` is invoked inside `openDb()` at startup, BEFORE BackupService is instantiated. To honor the F6-L1 spec's silent invariant ("데이터 손실 0회"), this PR adds a pre-migration snapshot in `openDb`: + +```typescript +// Pseudocode (inside openDb after opening, before runMigrations) +if (currentVersion < TARGET_VERSION) { + // Take a snapshot of the pre-migration state + await dbBackupSync(db, `${dbFile}.pre-v${TARGET_VERSION}.bak`); +} +runMigrations(db); +``` + +Adjustment: since `openDb` returns sync, use `db.backup()` followed by `await` only inside an async wrapper, OR copy the file with `fs.copyFileSync` BEFORE opening the WAL DB. Choose the simplest path: copy `dbFile` to `dbFile.pre-v2.bak` via `fs.copyFileSync` if `pre-v2.bak` doesn't exist AND `user_version < 2`. Pure sync, no async refactor, no double-WAL concerns. + +## Task Breakdown + +### Task 1: Migration v2 + NoteRepository + +**Files:** +- Create `src/main/db/migrations/m002_due_date.ts`: + +```typescript +import type Database from 'better-sqlite3'; + +export const version = 2; + +export function up(db: Database.Database): void { + db.exec(` + ALTER TABLE notes ADD COLUMN due_date TEXT; + ALTER TABLE notes ADD COLUMN due_date_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (due_date_edited_by_user IN (0,1)); + `); +} +``` + +- Modify `src/main/db/migrations/index.ts` to register m002: + +```typescript +import * as m001 from './m001_initial.js'; +import * as m002 from './m002_due_date.js'; +const migrations = [m001, m002]; +``` + +- Modify `src/main/db/index.ts` to add pre-migration snapshot: + +```typescript +import Database from 'better-sqlite3'; +import { existsSync, copyFileSync } from 'node:fs'; +import { runMigrations, latestVersion } from './migrations/index.js'; + +export function openDb(dbFile: string): Database.Database { + // Pre-migration snapshot if upgrading + if (existsSync(dbFile)) { + const probe = new Database(dbFile, { readonly: true }); + const cur = (probe.pragma('user_version', { simple: true }) as number) ?? 0; + probe.close(); + if (cur < latestVersion()) { + const bak = `${dbFile}.pre-v${latestVersion()}.bak`; + if (!existsSync(bak)) { + copyFileSync(dbFile, bak); + } + } + } + const db = new Database(dbFile); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + return db; +} +``` + +Add `latestVersion()` to migrations/index.ts: + +```typescript +export function latestVersion(): number { + return migrations[migrations.length - 1]!.version; +} +``` + +- Modify `src/main/repository/NoteRepository.ts`: + - Hydrate dueDate fields + - Extend `updateAiResult` signature to take `{ dueDate?: string | null }` and add to UPDATE SET with edited-flag guard + - Add `setDueDate(id: string, date: string | null): void` (mirrors updateUserAiFields pattern, sets `due_date_edited_by_user = 1`) + +```typescript +// hydrate addition +return { + ...other fields, + dueDate: row.due_date, + dueDateEditedByUser: row.due_date_edited_by_user === 1, +}; + +// updateAiResult signature +updateAiResult(id, result: { title; summary; tags; provider; dueDate?: string | null }) + +// New SET clause inside UPDATE notes: +ai_due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END + +// Wait — column name is due_date not ai_due_date. Use due_date directly. + +// New method +setDueDate(id, date) { + const now = new Date().toISOString(); + this.db.prepare( + `UPDATE notes SET due_date = ?, due_date_edited_by_user = 1, updated_at = ? WHERE id = ?` + ).run(date, now, id); +} +``` + +- Modify `src/shared/types.ts`: + - Add `dueDate: string | null` and `dueDateEditedByUser: boolean` to Note interface + +- Test in `tests/unit/migrations.due_date.test.ts`: + 1. Fresh DB has user_version = 2 after migrations + 2. due_date column exists, defaults NULL + 3. due_date_edited_by_user defaults 0 + +- Test additions in `tests/unit/NoteRepository.test.ts`: + 1. Created note has dueDate=null, dueDateEditedByUser=false + 2. updateAiResult with dueDate sets due_date when edited flag = 0 + 3. updateAiResult does NOT overwrite due_date when edited flag = 1 + 4. setDueDate sets due_date and edited flag + +Commit Task 1: +``` +feat(db): migration v2 — due_date columns + pre-migration snapshot + +- ALTER TABLE notes ADD due_date TEXT, due_date_edited_by_user INTEGER +- openDb takes .pre-v.bak before migrations (F6-L1 follow-up #4) +- NoteRepository updateAiResult(dueDate?) + setDueDate + hydrate +- Note type extended with dueDate / dueDateEditedByUser + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +### Task 2: Pure rule parser + +**File:** `src/main/services/dueDateParser.ts` + +```typescript +export interface ParseResult { + iso: string | null; // YYYY-MM-DD or null if no match + confidence: 'high' | 'medium' | null; + matchedToken: string | null; +} + +export function parseDueDate(text: string, todayKst: Date): ParseResult { ... } +``` + +`todayKst` is the KST midnight Date (caller computes via `+9h offset` at call site). + +Rules (try in order, first match wins): +1. **YYYY-MM-DD literal** — `\d{4}-\d{2}-\d{2}` → high +2. **N월 N일** — `(\d{1,2})월\s*(\d{1,2})일` → resolve to current year if same/future, else next year. high. +3. **MM/DD** — `(\d{1,2})\/(\d{1,2})` → same logic. high. +4. **N일 뒤 / N일 후** — `(\d+)\s*일\s*(뒤|후)` → today + N. high. +5. **N주 뒤 / N주 후** — `(\d+)\s*주\s*(뒤|후)` → today + N*7. high. +6. **N개월 뒤** — `(\d+)\s*개월\s*(뒤|후)` → calendar add. high. +7. **모레** — today + 2. high. +8. **내일** — today + 1. high. +9. **글피** — today + 3. high. +10. **다음 주 X요일** — `다음\s*주\s*([월화수목금토일])요일` → KST Monday of next week + offset. high. +11. **이번 주 X요일** — `이번\s*주\s*([월화수목금토일])요일` → KST Monday of current week + offset. high. +12. **다음 주말** / **주말** / **월말** — null (defer to AI). medium-tier match info but iso=null. +13. **오늘** — today. high. (Yes ambiguous — but regex parser commits to today; user can edit if wrong.) + +Tests in `tests/unit/dueDateParser.test.ts` — golden fixtures (≥ 25): + +```typescript +const TODAY_KST = new Date('2026-04-26T00:00:00+09:00'); +expect(parseDueDate('내일 회의', TODAY_KST).iso).toBe('2026-04-27'); +expect(parseDueDate('모레 발표', TODAY_KST).iso).toBe('2026-04-28'); +expect(parseDueDate('3일 뒤 데모', TODAY_KST).iso).toBe('2026-04-29'); +expect(parseDueDate('5월 1일 휴가', TODAY_KST).iso).toBe('2026-05-01'); +expect(parseDueDate('다음 주 월요일까지 슬라이드', TODAY_KST).iso).toBe('2026-05-04'); // anchor next Monday +expect(parseDueDate('이번 주 일요일', TODAY_KST).iso).toBe('2026-04-26'); // already Sun? actually 04-26 is Sun, so this Sunday = today +expect(parseDueDate('다음 달 1일', TODAY_KST).iso).toBe('2026-05-01'); +expect(parseDueDate('월말 마감', TODAY_KST).iso).toBeNull(); // defer +expect(parseDueDate('주말까지', TODAY_KST).iso).toBeNull(); // defer +expect(parseDueDate('오늘 PR 리뷰', TODAY_KST).iso).toBe('2026-04-26'); +expect(parseDueDate('오후 3시 미팅', TODAY_KST).iso).toBeNull(); // time-only, not a date token +expect(parseDueDate('퇴근 전', TODAY_KST).iso).toBeNull(); // defer +expect(parseDueDate('하루 동안 잘 잤다', TODAY_KST).iso).toBeNull(); // no token +expect(parseDueDate('2026-05-15 마감', TODAY_KST).iso).toBe('2026-05-15'); +expect(parseDueDate('5/3 데모', TODAY_KST).iso).toBe('2026-05-03'); +expect(parseDueDate('3월 1일 발표', TODAY_KST).iso).toBe('2027-03-01'); // next year (past in current) +expect(parseDueDate('2주 뒤 회의', TODAY_KST).iso).toBe('2026-05-10'); +expect(parseDueDate('1개월 뒤 점검', TODAY_KST).iso).toBe('2026-05-26'); +expect(parseDueDate('내일도 모레도 바쁨', TODAY_KST).iso).toBe('2026-04-27'); // first match +``` + +Commit Task 2: +``` +feat(due-date): pure rule parser for Korean date expressions + +Regex + KST math, returns ISO YYYY-MM-DD or null. 13 rule categories, +first match wins. Ambiguous tokens (월말, 주말, 퇴근 전) return null +to defer to AI. 25+ golden fixtures cover normal + edge cases. + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +### Task 3: AI schema + prompt + +**Files:** `src/main/ai/schema.ts`, `src/main/ai/prompt.ts` + +- schema.ts: extend RawResponseSchema with `due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional()`. Update AiResponse interface to add `dueDate: string | null`. +- prompt.ts: add `{{TODAY_KST}}` template + due_date instruction. Add second function or extend signature: + +```typescript +export function buildPrompt(rawText: string, todayKst: string): string { + return `... existing ... +- "due_date": ISO YYYY-MM-DD if you can extract a clear deadline from "${rawText}" relative to today (${todayKst} KST), else null. + ...`; +} +``` + +Tests in `tests/unit/ai-schema.test.ts` (extend): +- due_date null accepted +- due_date YYYY-MM-DD accepted +- due_date `2026-13-99` rejected (regex fails) +- due_date `tomorrow` rejected + +Commit Task 3: +``` +feat(ai): zod due_date field + prompt {{TODAY_KST}} injection + +AiResponse extends with dueDate: string|null. Prompt now takes +todayKst arg, instructs ISO YYYY-MM-DD or null. Schema uses regex +for round-trip safety. + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +### Task 4: AiWorker integration + +**Files:** `src/main/ai/AiWorker.ts`, `src/main/ai/LocalOllamaProvider.ts` + +- LocalOllamaProvider.generate accepts `todayKst: string` from caller (or computes internally — but for testability, take it as arg or via injected clock) +- AiWorker: + 1. Compute `todayKst` from `now()` (KST helper inline) + 2. Run `parseDueDate(rawText, todayKst)` — get rule result + 3. Call provider.generate(rawText, todayKst) — gets AI response with optional due_date + 4. Final dueDate: `ruleResult.iso ?? aiResponse.dueDate ?? null` + 5. Pass to `repo.updateAiResult({ ..., dueDate: finalDueDate })` + +Tests in existing AiWorker.test.ts (mock provider): +- Rule match takes precedence over AI +- Rule null + AI value → AI used +- Rule null + AI null → null + +Commit Task 4: +``` +feat(ai): AiWorker merges rule parser + AI due_date + +Rule parser called first. If matched, override AI's due_date +(rule = deterministic). Else use AI's due_date. Always passes +todayKst to provider for prompt injection. + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +### Task 5: Renderer (NoteCard + EditableField + IPC + store) + +Read `src/renderer/inbox/components/NoteCard.tsx` first. Add a `📅 YYYY-MM-DD` badge inline next to the title. Style: small grey text. If `note.dueDate < today`: grey + line-through. Click badge → open inline date input (or open small EditableField). + +IPC: +- `inboxApi.ts` `inbox:setDueDate` handler → `repo.setDueDate(noteId, date)` +- preload `inkling.inbox.setDueDate(noteId, date|null)` +- store `setDueDate(noteId, date|null)` action + +Renderer test: keep minimal. Mostly visual. + +Commit Task 5: +``` +feat(due-date): NoteCard badge + edit + IPC + +Inline 📅 YYYY-MM-DD badge next to title. Click to edit. Past +dates show with line-through (회색 + 취소선). New IPC +inbox:setDueDate, store action, preload bridge. + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +### Task 6: Promote F1 + +Create `docs/superpowers/specs/2026-04-26-f1-due-date.md` with mini-brainstorm decisions table. + +Update `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` F1 header from `🔬 drafting` → `🚀 promoted`. + +Commit Task 6: +``` +docs(spec): promote F1 due date + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +## End-state verification + +- typecheck 0 errors +- tests 132 → ~170+ (rule parser ~25, schema ~4, repo ~4, migration ~3, AiWorker ~3) +- e2e 1/1