Files
inkling/docs/superpowers/plans/2026-04-26-f1-due-date.md

15 KiB

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

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

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)
// 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 <dbFile>.pre-v<N>.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) <noreply@anthropic.com>

Task 2: Pure rule parser

File: src/main/services/dueDateParser.ts

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

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) <noreply@anthropic.com>

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:
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) <noreply@anthropic.com>

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) <noreply@anthropic.com>

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) <noreply@anthropic.com>

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) <noreply@anthropic.com>

End-state verification

  • typecheck 0 errors
  • tests 132 → ~170+ (rule parser ~25, schema ~4, repo ~4, migration ~3, AiWorker ~3)
  • e2e 1/1