F1 Due Date (v0.2.1 dogfood-feedback Track #4) #5
355
docs/superpowers/plans/2026-04-26-f1-due-date.md
Normal file
355
docs/superpowers/plans/2026-04-26-f1-due-date.md
Normal 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
|
||||
@@ -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 시작 직전 사고 실험.
|
||||
|
||||
|
||||
50
docs/superpowers/specs/2026-04-26-f1-due-date.md
Normal file
50
docs/superpowers/specs/2026-04-26-f1-due-date.md
Normal 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 카운터 등)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
11
src/main/db/migrations/m002_due_date.ts
Normal file
11
src/main/db/migrations/m002_due_date.ts
Normal 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));
|
||||
`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
327
src/main/services/dueDateParser.ts
Normal file
327
src/main/services/dueDateParser.ts
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]+)*$/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
126
tests/unit/dueDateParser.test.ts
Normal file
126
tests/unit/dueDateParser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
28
tests/unit/migrations.due_date.test.ts
Normal file
28
tests/unit/migrations.due_date.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user