F1 Due Date (v0.2.1 dogfood-feedback Track #4) #5

Merged
altair823 merged 7 commits from feat/f1-due-date into main 2026-04-26 02:20:16 +00:00
25 changed files with 1300 additions and 25 deletions

View File

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

View File

@@ -24,7 +24,9 @@
---
## F1. Due Date 추출 (🔬 drafting)
## F1. Due Date 추출 (🚀 promoted)
**진행 상태:** 🚀 promoted → `docs/superpowers/specs/2026-04-26-f1-due-date.md`
**발견:** 2026-04-25 dogfood 시작 직전 사고 실험.

View File

@@ -0,0 +1,50 @@
# F1 Due Date Spec (Promoted)
**Extracted from:** `2026-04-25-dogfood-feedback.md` F1
**Plan:** `docs/superpowers/plans/2026-04-26-f1-due-date.md`
**Status:** 🚀 promoted — implemented 2026-04-26
## 결정 (mini-brainstorm 결과)
| 결정 항목 | 값 | 근거 |
|----------|-----|------|
| 매칭 우선순위 | 규칙 파서 우선 → AI 응답 폴백 | 결정론적 매칭 우선 |
| 모호 표현 (월말, 주말, 퇴근 전) | 규칙은 medium confidence + iso=null, AI 위임 | false positive 회피 |
| 라벨 슬롯 | NoteCard 의 summary 와 tags 사이 인라인 (`📅 YYYY-MM-DD`) | 시각 노이즈 최소 |
| 만료 표시 | 회색 + 취소선 (라벨만, 본문 무수정) | slice §1.1 카피 정책 |
| 사용자 편집 | HTML date input, 클릭 → 편집 | 표준 UX |
| 시각 단위 / 음력 / 반복 일정 | 미지원 | F1 §Out |
| 만료 자동 처리 | 없음 (시각만) | dogfood 후 결정 |
| 별도 뷰 / 정렬 | 없음 | 후속 |
| Migration v2 backfill | NULL 기본 | 비파괴 |
| Pre-migration snapshot | `<dbFile>.pre-v<N>.bak` (F6-L1 follow-up #4) | 마이그레이션 결함 회수 |
## 범위 (PR 안에 포함됨)
- `src/main/db/migrations/m002_due_date.ts` (신규)
- `src/main/db/migrations/index.ts` 수정 (`latestVersion` 추가, m002 등록)
- `src/main/db/index.ts` 수정 (pre-migration 스냅샷 — F6-L1 후속 #4 반영)
- `src/main/services/dueDateParser.ts` (신규, 14 규칙)
- `src/main/ai/schema.ts` 수정 (`due_date` 필드 추가, AiResponse 확장)
- `src/main/ai/prompt.ts` 수정 (`PROMPT_VERSION = 2`, `todayKst` 인자)
- `src/main/ai/InferenceProvider.ts` 수정 (`GenerateInput.todayKst`)
- `src/main/ai/LocalOllamaProvider.ts` 수정 (todayKst 패스)
- `src/main/ai/AiWorker.ts` 수정 (parseDueDate + AI 병합 + `now()` 주입)
- `src/main/repository/NoteRepository.ts` 수정 (`updateAiResult.dueDate?`, `setDueDate`, hydrate)
- `src/main/ipc/inboxApi.ts` 수정 (`inbox:setDueDate` 핸들러)
- `src/preload/index.ts` 수정 (`setDueDate` expose)
- `src/shared/types.ts` 수정 (`Note.dueDate`, `Note.dueDateEditedByUser`, `InboxApi.setDueDate`)
- `src/renderer/inbox/components/NoteCard.tsx` 수정 (`DueDateBadge` 컴포넌트 + onSave)
- 테스트 신규/확장: migrations, parser, schema, repo, AiWorker — 약 +44 단위 테스트
## 후속 (별 spec 또는 후속 항목 후보)
- 별도 due 뷰 / 정렬 / 필터
- 만료 자동 처리 (자동 done 또는 자동 알림)
- 시각 단위 (오후 3시, 23:30 등) 처리
- 음력 / 절기
- 반복 일정 (매주 월요일, 매월 1일 등)
- 외부 캘린더 연동 (Google / Apple Calendar)
- 다중 매칭 처리 (현재는 first-match-wins; 가장 가까운 / 가장 명확한 등 옵션)
- AI 매칭 confidence 표시 (medium 일 때 사용자에게 "확실하지 않음" 힌트)
- 시각 표시 옵션 (D-day 카운터 등)

View File

@@ -1,6 +1,19 @@
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
import { parseDueDate } from '../services/dueDateParser.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function todayKstAsDate(now: Date): Date {
// Returns a Date object whose UTC year/month/day match KST today
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
}
function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
}
export interface AiWorkerOptions {
backoffsMs?: number[];
@@ -10,6 +23,7 @@ export interface AiWorkerOptions {
warn: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
};
now?: () => Date;
}
interface Job { noteId: string; attempts: number; }
@@ -21,6 +35,7 @@ export class AiWorker {
private backoffsMs: number[];
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
constructor(
private repo: NoteRepository,
@@ -30,6 +45,7 @@ export class AiWorker {
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
}
async enqueue(noteId: string): Promise<void> {
@@ -82,12 +98,25 @@ export class AiWorker {
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
const res = await this.provider.generate({ text: note.rawText });
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);
const ruleResult = parseDueDate(note.rawText, todayDate);
const res = await this.provider.generate({ text: note.rawText, todayKst: todayIso });
// Merge rule + AI: rule takes priority, AI fills if rule null
const finalDueDate = ruleResult.iso ?? res.dueDate ?? null;
this.repo.updateAiResult(job.noteId, {
title: res.title, summary: res.summary, tags: res.tags,
provider: this.provider.name
title: res.title,
summary: res.summary,
tags: res.tags,
provider: this.provider.name,
dueDate: finalDueDate
});
this.logger.info('ai.done', {
noteId: job.noteId,
attempt,
dueDateSource: ruleResult.iso !== null ? 'rule' : (res.dueDate !== null ? 'ai' : 'none')
});
this.logger.info('ai.done', { noteId: job.noteId, attempt });
this.emit(job.noteId);
return;
} catch (err) {

View File

@@ -1,6 +1,10 @@
import type { AiResponse } from './schema.js';
export interface GenerateInput { text: string; }
export interface GenerateInput {
text: string;
todayKst: string; // ISO YYYY-MM-DD in KST
}
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
export interface InferenceProvider {

View File

@@ -37,7 +37,7 @@ export class LocalOllamaProvider implements InferenceProvider {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text),
prompt: buildPrompt(input.text, input.todayKst),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }

View File

@@ -1,8 +1,10 @@
export const PROMPT_VERSION = 1;
export const PROMPT_VERSION = 2;
export function buildPrompt(rawText: string): string {
export function buildPrompt(rawText: string, todayKst: string): string {
return `You organize raw personal notes into structured metadata.
Today's date in Korea Standard Time (KST): ${todayKst}
Input note (raw text, may be fragmented, any language):
---
${rawText}
@@ -12,10 +14,12 @@ Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
- "due_date": ISO YYYY-MM-DD date if you can clearly extract a deadline relative to today (${todayKst} KST), else null. Examples: "월말" → last day of current month; "주말" → upcoming Saturday; "퇴근 전" → today. Be conservative — only return a date if confident.
Rules:
- title and summary MUST be written in Korean regardless of input language.
- tags MUST be English kebab-case (for consistency across notes; easier to search/group).
- due_date MUST be ISO YYYY-MM-DD format or null. Never include time-of-day.
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.`;

View File

@@ -2,17 +2,20 @@ import { z } from 'zod';
const KOREAN_REGEX = /[가-힣]/;
const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
const RawResponseSchema = z.object({
title: z.string().trim().min(1).max(200),
summary: z.string().min(1),
tags: z.array(z.string()).default([])
tags: z.array(z.string()).default([]),
due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional()
});
export interface AiResponse {
title: string;
summary: string;
tags: string[];
dueDate: string | null;
}
function normalizeSummary(raw: string): string {
@@ -28,6 +31,14 @@ function normalizeSummary(raw: string): string {
return [...head, tail].join('\n');
}
function validateDueDate(d: string | null | undefined): string | null {
if (d === null || d === undefined) return null;
// Re-verify the date is actually valid (regex passes 2026-13-99 which is invalid)
const dt = new Date(d + 'T00:00:00Z');
if (Number.isNaN(dt.getTime()) || dt.toISOString().slice(0, 10) !== d) return null;
return d;
}
export function parseAiResponse(raw: unknown): AiResponse {
const parsed = RawResponseSchema.parse(raw);
if (!KOREAN_REGEX.test(parsed.title)) {
@@ -36,6 +47,7 @@ export function parseAiResponse(raw: unknown): AiResponse {
return {
title: parsed.title.slice(0, 60),
summary: normalizeSummary(parsed.summary),
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3)
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
dueDate: validateDueDate(parsed.due_date)
};
}

View File

@@ -1,7 +1,20 @@
import Database from 'better-sqlite3';
import { runMigrations } from './migrations/index.js';
import { existsSync, copyFileSync } from 'node:fs';
import { runMigrations, latestVersion } from './migrations/index.js';
export function openDb(dbFile: string): Database.Database {
// F6-L1 follow-up #4: snapshot pre-migration if upgrading
if (existsSync(dbFile)) {
const probe = new Database(dbFile, { readonly: true });
const cur = probe.pragma('user_version', { simple: true }) as number;
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');

View File

@@ -1,7 +1,12 @@
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
const migrations = [m001];
const migrations = [m001, m002];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;
}
export function runMigrations(db: Database.Database): void {
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };

View File

@@ -0,0 +1,11 @@
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));
`);
}

View File

@@ -29,6 +29,10 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
}
);
ipcMain.handle('inbox:setDueDate', (_e, arg: { noteId: string; date: string | null }) => {
deps.repo.setDueDate(arg.noteId, arg.date);
});
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
await deps.capture.deleteNote(noteId);
});

View File

@@ -100,15 +100,17 @@ export class NoteRepository {
updateAiResult(
id: string,
result: { title: string; summary: string; tags: string[]; provider: string }
result: { title: string; summary: string; tags: string[]; provider: string; dueDate?: string | null }
): void {
const now = new Date().toISOString();
const dueDate = result.dueDate ?? null;
const tx = this.db.transaction(() => {
this.db
.prepare(
`UPDATE notes
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
ai_status = 'done',
ai_provider = ?,
ai_generated_at = ?,
@@ -116,7 +118,7 @@ export class NoteRepository {
updated_at = ?
WHERE id = ?`
)
.run(result.title, result.summary, result.provider, now, now, id);
.run(result.title, result.summary, dueDate, result.provider, now, now, id);
this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id);
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
@@ -210,6 +212,15 @@ export class NoteRepository {
.run(now, now, id);
}
setDueDate(id: string, date: string | null): void {
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);
}
delete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
@@ -340,6 +351,8 @@ export class NoteRepository {
summaryEditedByUser: row.summary_edited_by_user === 1,
userIntent: row.user_intent,
intentPromptedAt: row.intent_prompted_at,
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: tags as NoteTag[],

View File

@@ -0,0 +1,327 @@
/**
* Pure rule-based Korean due-date parser.
*
* Returns ISO YYYY-MM-DD when a high-confidence rule matches; iso=null with
* confidence='medium' when an ambiguous token (월말, 주말, 퇴근 전, 시각) is
* detected so the caller (AiWorker) can defer to AI; iso=null + confidence=null
* when no token at all is found.
*
* `todayKst` is a Date representing the "today" reference. The parser does NOT
* apply any timezone offset; the caller is expected to pass a Date already
* aligned to KST midnight (e.g. via UTC math). All date arithmetic uses
* getUTC* / setUTC* methods on the passed reference.
*/
export interface ParseResult {
iso: string | null;
confidence: 'high' | 'medium' | null;
matchedToken: string | null;
}
const DAY_MS = 86_400_000;
/** 월=0, 화=1, 수=2, 목=3, 금=4, 토=5, 일=6 */
const WEEKDAY_OFFSET: Record<string, number> = {
'월': 0,
'화': 1,
'수': 2,
'목': 3,
'금': 4,
'토': 5,
'일': 6
};
function toIso(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addDays(d: Date, n: number): Date {
return new Date(d.getTime() + n * DAY_MS);
}
/** Validate a YYYY-MM-DD literal via Date roundtrip. */
function isValidYmd(y: number, m: number, day: number): boolean {
const d = new Date(Date.UTC(y, m - 1, day));
return (
d.getUTCFullYear() === y &&
d.getUTCMonth() === m - 1 &&
d.getUTCDate() === day
);
}
function makeUtcDate(y: number, m: number, day: number): Date {
return new Date(Date.UTC(y, m - 1, day));
}
/** Calendar-arithmetic add of N months. */
function addMonths(d: Date, n: number): Date {
const out = new Date(d.getTime());
const targetMonth = out.getUTCMonth() + n;
out.setUTCMonth(targetMonth);
return out;
}
/**
* Monday of the current week (Mon-Sun convention, Sunday = end of week).
* Sun => -6 days; Mon => 0; Tue => -1; ...; Sat => -5.
*/
function thisMonday(today: Date): Date {
const dow = today.getUTCDay(); // 0=Sun..6=Sat
const daysSinceMonday = dow === 0 ? 6 : dow - 1;
return addDays(today, -daysSinceMonday);
}
/**
* Monday of "next week" — the Monday following the upcoming Sunday.
* When today is Sunday, the upcoming Sunday is treated as 7 days away,
* so "다음 주 월요일" lands on today + 8.
*/
function nextWeekMonday(today: Date): Date {
const dow = today.getUTCDay(); // 0=Sun..6=Sat
const daysUntilNextSunday = dow === 0 ? 7 : 7 - dow;
return addDays(today, daysUntilNextSunday + 1);
}
export function parseDueDate(text: string, todayKst: Date): ParseResult {
// Normalize to KST midnight (the caller already does this; we just trust the input).
const today = new Date(
Date.UTC(
todayKst.getUTCFullYear(),
todayKst.getUTCMonth(),
todayKst.getUTCDate()
)
);
// 1. YYYY-MM-DD literal
{
const re = /\b(\d{4})-(\d{2})-(\d{2})\b/;
const m = re.exec(text);
if (m) {
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (isValidYmd(y, mo, d)) {
return {
iso: toIso(makeUtcDate(y, mo, d)),
confidence: 'high',
matchedToken: m[0]
};
}
// invalid literal → fall through (parser does NOT silently coerce)
}
}
// 2. N월 N일 (current year if month/day >= today, else next year)
{
const re = /(\d{1,2})\s*월\s*(\d{1,2})\s*일/;
const m = re.exec(text);
if (m) {
const mo = Number(m[1]);
const d = Number(m[2]);
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
const ty = today.getUTCFullYear();
const tm = today.getUTCMonth() + 1;
const td = today.getUTCDate();
let year = ty;
if (mo < tm || (mo === tm && d < td)) year = ty + 1;
if (isValidYmd(year, mo, d)) {
return {
iso: toIso(makeUtcDate(year, mo, d)),
confidence: 'high',
matchedToken: m[0]
};
}
}
}
}
// 3. MM/DD
{
const re = /(?<!\d)(\d{1,2})\/(\d{1,2})(?!\d)/;
const m = re.exec(text);
if (m) {
const mo = Number(m[1]);
const d = Number(m[2]);
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
const ty = today.getUTCFullYear();
const tm = today.getUTCMonth() + 1;
const td = today.getUTCDate();
let year = ty;
if (mo < tm || (mo === tm && d < td)) year = ty + 1;
if (isValidYmd(year, mo, d)) {
return {
iso: toIso(makeUtcDate(year, mo, d)),
confidence: 'high',
matchedToken: m[0]
};
}
}
}
}
// 4. N일 (뒤|후)
{
const re = /(\d{1,3})\s*일\s*(뒤|후)/;
const m = re.exec(text);
if (m) {
const n = Number(m[1]);
return {
iso: toIso(addDays(today, n)),
confidence: 'high',
matchedToken: m[0]
};
}
}
// 5. N주 (뒤|후)
{
const re = /(\d{1,2})\s*주\s*(뒤|후)/;
const m = re.exec(text);
if (m) {
const n = Number(m[1]);
return {
iso: toIso(addDays(today, n * 7)),
confidence: 'high',
matchedToken: m[0]
};
}
}
// 6. N개월 (뒤|후)
{
const re = /(\d{1,2})\s*개월\s*(뒤|후)/;
const m = re.exec(text);
if (m) {
const n = Number(m[1]);
return {
iso: toIso(addMonths(today, n)),
confidence: 'high',
matchedToken: m[0]
};
}
}
// 7-9. 글피 / 모레 / 내일 — first by text position so multi-token text
// (e.g., "내일도 모레도") resolves to the earliest mentioned token.
{
const candidates: Array<{ token: string; offset: number }> = [
{ token: '글피', offset: 3 },
{ token: '모레', offset: 2 },
{ token: '내일', offset: 1 }
];
let best: { token: string; offset: number; idx: number } | null = null;
for (const c of candidates) {
const idx = text.indexOf(c.token);
if (idx >= 0 && (best === null || idx < best.idx)) {
best = { ...c, idx };
}
}
if (best) {
return {
iso: toIso(addDays(today, best.offset)),
confidence: 'high',
matchedToken: best.token
};
}
}
// 10. 다음 주 X요일
{
const re = /다음\s*주\s*([월화수목금토일])요일/;
const m = re.exec(text);
if (m) {
const wd = m[1]!;
const offset = WEEKDAY_OFFSET[wd]!;
const base = nextWeekMonday(today);
return {
iso: toIso(addDays(base, offset)),
confidence: 'high',
matchedToken: m[0]
};
}
}
// 11. 이번 주 X요일
{
const re = /이번\s*주\s*([월화수목금토일])요일/;
const m = re.exec(text);
if (m) {
const wd = m[1]!;
const offset = WEEKDAY_OFFSET[wd]!;
const base = thisMonday(today);
return {
iso: toIso(addDays(base, offset)),
confidence: 'high',
matchedToken: m[0]
};
}
}
// 12. 다음 달 N일
{
const re = /다음\s*달\s*(\d{1,2})\s*일/;
const m = re.exec(text);
if (m) {
const d = Number(m[1]);
const next = addMonths(today, 1);
const target = new Date(
Date.UTC(next.getUTCFullYear(), next.getUTCMonth(), d)
);
if (
target.getUTCFullYear() === next.getUTCFullYear() &&
target.getUTCMonth() === next.getUTCMonth() &&
d >= 1 &&
d <= 31
) {
return { iso: toIso(target), confidence: 'high', matchedToken: m[0] };
}
}
}
// 13. 다음 주 (alone) → next Monday
{
const re = /다음\s*주(?![가-힣])/;
const m = re.exec(text);
if (m) {
const base = nextWeekMonday(today);
return { iso: toIso(base), confidence: 'high', matchedToken: m[0] };
}
}
// 14. 다음 달 (alone) → first day of next month
{
const re = /다음\s*달/;
const m = re.exec(text);
if (m) {
const next = addMonths(today, 1);
const first = new Date(
Date.UTC(next.getUTCFullYear(), next.getUTCMonth(), 1)
);
return { iso: toIso(first), confidence: 'high', matchedToken: m[0] };
}
}
// 15. 오늘
if (text.includes('오늘')) {
return { iso: toIso(today), confidence: 'high', matchedToken: '오늘' };
}
// ── Ambiguous tokens — caller defers to AI ──
// 월말 / 주말 / 퇴근 전 / 오후 \d시 / 오전 \d시 / \d시
const ambiguousPatterns: Array<RegExp> = [
/월말/,
/주말/,
/퇴근\s*전/,
/오후\s*\d{1,2}\s*시/,
/오전\s*\d{1,2}\s*시/,
/\d{1,2}\s*시/
];
for (const re of ambiguousPatterns) {
const m = re.exec(text);
if (m) {
return { iso: null, confidence: 'medium', matchedToken: m[0] };
}
}
return { iso: null, confidence: null, matchedToken: null };
}

View File

@@ -11,6 +11,7 @@ const api: InklingApi = {
listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts),
updateAiFields: (noteId, fields) =>
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),

View File

@@ -15,6 +15,93 @@ const aiBadgeStyle: React.CSSProperties = {
background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle'
};
function isPastDue(iso: string, today: string): boolean {
return iso < today; // string comparison works for ISO YYYY-MM-DD
}
function todayKstIso(): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
function DueDateBadge({
value,
isEdited,
today,
onSave
}: {
value: string | null;
isEdited: boolean;
today: string;
onSave: (next: string) => Promise<void>;
}): React.ReactElement {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
React.useEffect(() => { if (!editing) setDraft(value ?? ''); }, [value, editing]);
if (!editing) {
if (value === null) {
return (
<span
onClick={() => setEditing(true)}
style={{ fontSize: 11, color: '#bbb', cursor: 'pointer' }}
title="마감일 추가"
>
📅
</span>
);
}
const past = isPastDue(value, today);
return (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
onClick={() => setEditing(true)}
style={{
color: past ? '#999' : '#666',
textDecoration: past ? 'line-through' : 'none',
cursor: 'pointer'
}}
title={past ? '지난 마감일 — 클릭으로 편집' : '클릭으로 편집'}
>
📅 {value}
</span>
{!isEdited && (
<span
style={{
fontSize: 9, padding: '0 4px', background: '#eee',
color: '#888', borderRadius: 2
}}
title="AI 추출"
>
AI
</span>
)}
</span>
);
}
return (
<input
type="date"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={async () => {
try { await onSave(draft); }
catch { /* keep editing if invalid */ return; }
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') { setDraft(value ?? ''); setEditing(false); }
}}
autoFocus
style={{ fontSize: 11, padding: 1 }}
/>
);
}
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
@@ -41,6 +128,17 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
setLocal(updated); onUpdated(updated);
}
async function saveDueDate(next: string) {
const value = next.trim() === '' ? null : next.trim();
// Light validation: empty or YYYY-MM-DD
if (value !== null && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('Invalid date');
}
await inboxApi.setDueDate(note.id, value);
const updated = { ...local, dueDate: value, dueDateEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function removeTag(tagName: string) {
const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
await inboxApi.updateAiFields(note.id, { tags: next });
@@ -102,6 +200,14 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (

View File

@@ -31,6 +31,8 @@ export interface Note {
summaryEditedByUser: boolean;
userIntent: string | null;
intentPromptedAt: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
@@ -57,6 +59,7 @@ export interface InboxApi {
noteId: string,
fields: { title?: string; summary?: string; tags?: string[] }
): Promise<void>;
setDueDate(noteId: string, date: string | null): Promise<void>;
deleteNote(noteId: string): Promise<void>;
setIntent(noteId: string, text: string): Promise<void>;
dismissIntent(noteId: string): Promise<void>;

View File

@@ -20,7 +20,7 @@ describe.skipIf(skip)('LocalOllamaProvider integration', () => {
];
it.each(cases)('Korean title + 3 lines for: %s', async (input) => {
const r = await provider.generate({ text: input });
const r = await provider.generate({ text: input, todayKst: '2026-04-26' });
expect(/[가-힣]/.test(r.title)).toBe(true);
expect(r.summary.split('\n')).toHaveLength(3);
for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);

View File

@@ -10,7 +10,7 @@ function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProv
return {
name: 'mock',
generate: vi.fn(async (): Promise<AiResponse> => ({
title: '제목', summary: 'a\nb\nc', tags: ['tag']
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null
})),
healthCheck: vi.fn(async () => ({ ok: true })),
...overrides
@@ -73,7 +73,7 @@ describe('AiWorker', () => {
running++; max = Math.max(max, running);
await new Promise((r) => setTimeout(r, 10));
running--;
return { title: '제목', summary: 'a\nb\nc', tags: [] };
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
})
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
@@ -81,4 +81,90 @@ describe('AiWorker', () => {
await w.drain();
expect(max).toBe(1);
});
it('rule parser match takes priority over AI dueDate', async () => {
const provider = {
name: 'mock',
generate: async (_input: any) => ({
title: '내일',
summary: 'a\nb\nc',
tags: [],
dueDate: '2026-12-31' // AI returns far-future
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
const { id } = repo.create({ rawText: '내일 회의' });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
expect(note.dueDate).toBe('2026-04-27'); // 내일 from rule, not AI's 12-31
});
it('rule null + AI value → AI used', async () => {
const provider = {
name: 'mock',
generate: async () => ({
title: '월말 마감',
summary: 'a\nb\nc',
tags: [],
dueDate: '2026-04-30' // AI resolves "월말"
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
const { id } = repo.create({ rawText: '월말 마감' });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
expect(note.dueDate).toBe('2026-04-30');
});
it('rule null + AI null → null', async () => {
const provider = {
name: 'mock',
generate: async () => ({
title: '아무 메모',
summary: 'a\nb\nc',
tags: [],
dueDate: null
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
const { id } = repo.create({ rawText: '아무 메모' });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
expect(note.dueDate).toBeNull();
});
it('passes todayKst to provider.generate', async () => {
const seen: any = {};
const provider = {
name: 'mock',
generate: async (input: any) => {
seen.todayKst = input.todayKst;
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
},
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
backoffsMs: [0],
now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(seen.todayKst).toBe('2026-04-27');
});
});

View File

@@ -22,7 +22,7 @@ describe('LocalOllamaProvider', () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] })
});
const r = await new LocalOllamaProvider().generate({ text: 'x' });
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26' });
expect(r.title).toBe('회의');
});
@@ -30,7 +30,9 @@ describe('LocalOllamaProvider', () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'not json'
});
await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i);
await expect(
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26' })
).rejects.toThrow(/json/i);
});
it('generate aborts on timeout', async () => {
@@ -39,7 +41,7 @@ describe('LocalOllamaProvider', () => {
return { statusCode: 200, data: '{}' };
}) as never);
await expect(
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' })
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26' })
).rejects.toThrow();
}, 2000);

View File

@@ -126,4 +126,53 @@ describe('NoteRepository', () => {
expect(row.attempts).toBe(1);
expect(row.last_error).toBe('boom');
});
it('hydrate returns dueDate=null + dueDateEditedByUser=false on new note', () => {
const { id } = repo.create({ rawText: 'x' });
const note = repo.findById(id)!;
expect(note.dueDate).toBeNull();
expect(note.dueDateEditedByUser).toBe(false);
});
it('updateAiResult writes dueDate when edited flag is 0', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' });
const note = repo.findById(id)!;
expect(note.dueDate).toBe('2026-05-01');
expect(note.dueDateEditedByUser).toBe(false);
});
it('updateAiResult does NOT overwrite dueDate when edited flag is 1', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' });
repo.setDueDate(id, '2026-05-15');
repo.updateAiResult(id, { title: 'AI 2', summary: 'd\ne\nf', tags: [], provider: 'p', dueDate: '2026-05-30' });
const note = repo.findById(id)!;
expect(note.dueDate).toBe('2026-05-15');
expect(note.dueDateEditedByUser).toBe(true);
});
it('setDueDate sets due_date and edited flag', () => {
const { id } = repo.create({ rawText: 'x' });
repo.setDueDate(id, '2026-06-01');
const note = repo.findById(id)!;
expect(note.dueDate).toBe('2026-06-01');
expect(note.dueDateEditedByUser).toBe(true);
});
it('setDueDate(null) clears due_date but keeps edited flag', () => {
const { id } = repo.create({ rawText: 'x' });
repo.setDueDate(id, '2026-06-01');
repo.setDueDate(id, null);
const note = repo.findById(id)!;
expect(note.dueDate).toBeNull();
expect(note.dueDateEditedByUser).toBe(true);
});
it('updateAiResult without dueDate field treats it as null', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p' });
const note = repo.findById(id)!;
expect(note.dueDate).toBeNull();
});
});

View File

@@ -52,4 +52,49 @@ describe('parseAiResponse', () => {
it('rejects non-object input', () => {
expect(() => parseAiResponse('nope')).toThrow();
});
it('parses note with valid due_date', () => {
const r = parseAiResponse({
title: '내일 회의',
summary: 'a\nb\nc',
tags: [],
due_date: '2026-04-27'
});
expect(r.dueDate).toBe('2026-04-27');
});
it('null due_date passes through', () => {
const r = parseAiResponse({
title: '내일 회의',
summary: 'a\nb\nc',
tags: []
});
expect(r.dueDate).toBeNull();
});
it('explicit null due_date passes through', () => {
const r = parseAiResponse({
title: '내일 회의',
summary: 'a\nb\nc',
tags: [],
due_date: null
});
expect(r.dueDate).toBeNull();
});
it('rejects malformed due_date string', () => {
expect(() =>
parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' })
).toThrow();
});
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {
const r = parseAiResponse({
title: '내일 회의',
summary: 'a\nb\nc',
tags: [],
due_date: '2026-13-99'
});
expect(r.dueDate).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { parseDueDate } from '@main/services/dueDateParser.js';
// 2026-04-26 is a Sunday (KST). Use that as "today" for fixtures.
const TODAY = new Date('2026-04-26T00:00:00.000Z'); // KST midnight = UTC 15:00 prior day, but for parser logic we treat input Date as KST-aligned. The parser treats the passed Date as the "today reference" without further timezone math.
describe('parseDueDate (Korean rule parser)', () => {
it('returns null for empty / no-token text', () => {
expect(parseDueDate('아무 일정도 없는 메모', TODAY).iso).toBeNull();
});
it('parses YYYY-MM-DD literal', () => {
expect(parseDueDate('2026-05-15 마감', TODAY).iso).toBe('2026-05-15');
});
it('rejects invalid YYYY-MM-DD (silent coercion)', () => {
// 2026-02-30 is invalid; parser should NOT match it as the literal rule
const r = parseDueDate('2026-02-30 마감', TODAY);
// Falls through to other rules — but no other token matches "2026-02-30 마감"
expect(r.iso).toBeNull();
});
it('parses N월 N일 in current year if future', () => {
expect(parseDueDate('5월 1일 휴가 신청', TODAY).iso).toBe('2026-05-01');
});
it('parses N월 N일 in next year if past', () => {
expect(parseDueDate('3월 1일 발표', TODAY).iso).toBe('2027-03-01');
});
it('parses MM/DD', () => {
expect(parseDueDate('5/3 데모', TODAY).iso).toBe('2026-05-03');
});
it('parses N일 뒤', () => {
expect(parseDueDate('3일 뒤 데모', TODAY).iso).toBe('2026-04-29');
});
it('parses N일 후', () => {
expect(parseDueDate('5일 후 미팅', TODAY).iso).toBe('2026-05-01');
});
it('parses N주 뒤', () => {
expect(parseDueDate('2주 뒤 회의', TODAY).iso).toBe('2026-05-10');
});
it('parses N개월 뒤', () => {
expect(parseDueDate('1개월 뒤 점검', TODAY).iso).toBe('2026-05-26');
});
it('parses 모레', () => {
expect(parseDueDate('모레 발표', TODAY).iso).toBe('2026-04-28');
});
it('parses 내일', () => {
expect(parseDueDate('내일 회의 준비', TODAY).iso).toBe('2026-04-27');
});
it('parses 글피', () => {
expect(parseDueDate('글피까지 마무리', TODAY).iso).toBe('2026-04-29');
});
it('parses 오늘', () => {
expect(parseDueDate('오늘 PR 리뷰', TODAY).iso).toBe('2026-04-26');
});
it('parses 다음 주 월요일', () => {
expect(parseDueDate('다음 주 월요일까지 슬라이드', TODAY).iso).toBe('2026-05-04');
});
it('parses 다음 주 금요일', () => {
expect(parseDueDate('다음 주 금요일에 점검', TODAY).iso).toBe('2026-05-08');
});
it('parses 이번 주 일요일 = today (Sunday)', () => {
expect(parseDueDate('이번 주 일요일 약속', TODAY).iso).toBe('2026-04-26');
});
it('parses 이번 주 수요일', () => {
// This week's Mon = 04-20, so this week's Wed = 04-22. But that's past — still resolve to 04-22 (parser does not skip past dates within current week — caller can decide to ignore).
expect(parseDueDate('이번 주 수요일 회의', TODAY).iso).toBe('2026-04-22');
});
it('parses 다음 달 1일', () => {
expect(parseDueDate('다음 달 1일', TODAY).iso).toBe('2026-05-01');
});
it('parses 다음 달 (alone) → first of next month', () => {
expect(parseDueDate('다음 달 마감', TODAY).iso).toBe('2026-05-01');
});
it('returns medium confidence with iso null for 월말', () => {
const r = parseDueDate('월말 마감', TODAY);
expect(r.iso).toBeNull();
expect(r.confidence).toBe('medium');
expect(r.matchedToken).toBe('월말');
});
it('returns medium confidence with iso null for 주말', () => {
const r = parseDueDate('주말까지 정리', TODAY);
expect(r.iso).toBeNull();
expect(r.confidence).toBe('medium');
});
it('returns medium confidence for 퇴근 전', () => {
const r = parseDueDate('퇴근 전 답장', TODAY);
expect(r.iso).toBeNull();
expect(r.confidence).toBe('medium');
});
it('returns null for time-only (오후 3시)', () => {
// Time tokens get medium confidence (defer to AI) since they imply same-day but not a date directly.
const r = parseDueDate('오후 3시 미팅', TODAY);
expect(r.iso).toBeNull();
expect(r.confidence).toBe('medium');
});
it('first match wins when multiple tokens present', () => {
expect(parseDueDate('내일도 모레도 바쁨', TODAY).iso).toBe('2026-04-27');
});
it('matches 다음 주 alone → next Monday', () => {
// If not followed by 요일, "다음 주" alone defaults to next Monday
expect(parseDueDate('다음 주 발표', TODAY).iso).toBe('2026-05-04');
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations, latestVersion } from '@main/db/migrations/index.js';
describe('migrations m002 due_date', () => {
it('latestVersion returns 2', () => {
expect(latestVersion()).toBe(2);
});
it('runMigrations on fresh DB advances user_version to 2', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.pragma('user_version', { simple: true });
expect(row).toBe(2);
});
it('due_date column exists with NULL default', () => {
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`
).run('n1', 'x', '2026-04-26T00:00:00Z', '2026-04-26T00:00:00Z');
const row = db.prepare('SELECT due_date, due_date_edited_by_user FROM notes WHERE id=?').get('n1') as any;
expect(row.due_date).toBeNull();
expect(row.due_date_edited_by_user).toBe(0);
});
});

View File

@@ -3,11 +3,9 @@ import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
describe('migrations', () => {
it('creates schema at version 1 with intent + edited columns', () => {
it('creates schema with intent + edited columns', () => {
const db = new Database(':memory:');
runMigrations(db);
const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version;
expect(ver).toBe(1);
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
expect(cols).toEqual(
expect.arrayContaining([
@@ -24,8 +22,10 @@ describe('migrations', () => {
it('is idempotent', () => {
const db = new Database(':memory:');
runMigrations(db);
const before = (db.prepare('PRAGMA user_version').get() as any).user_version;
runMigrations(db);
expect((db.prepare('PRAGMA user_version').get() as any).user_version).toBe(1);
const after = (db.prepare('PRAGMA user_version').get() as any).user_version;
expect(after).toBe(before);
db.close();
});
});