diff --git a/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md b/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md new file mode 100644 index 0000000..3ed8d01 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md @@ -0,0 +1,1133 @@ +# v0.2.6 Bugs + Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute task-by-task. Steps use checkbox (`- [ ]`). + +**Goal:** Backlog 16건 (4 bug + 12 cleanup → 13 task) 통합 처리. dogfood UX 마찰 해소 + 코드베이스 cleanup. + +**Architecture:** Bug fix 4건은 isolated. Cleanup 은 cluster (KST 통합 / TrayCallbacks 객체 / Banner shared) + microfix 묶음. 신규 architecture 변경 거의 없음 — 기존 구조 정리. + +**Tech Stack:** TypeScript strict, vitest 4, better-sqlite3 12.9, React 19, Electron 41. + +**Spec:** `docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md` + +--- + +## File Structure + +| File | Action | +|------|--------| +| `src/main/repository/NoteRepository.ts` | B1 restoreNote + pending_jobs / B2 countTrashed / C6 hydrate cleanup | +| `src/main/ipc/inboxApi.ts` | B2 IPC handler 갱신 / C9 inbox:trash rename | +| `src/main/index.ts` | B3 autostart fix / B4 additionalData / C2 createTray 호출 | +| `src/main/tray.ts` | B3 autostart args / C2 TrayCallbacks 객체화 / C3 singleton 제거 | +| `src/shared/util/kstDate.ts` | C1 신규 — KST helper 통합 | +| `src/main/services/TelemetryService.ts` | C1 callsite migrate | +| `src/main/services/telemetryStats.ts` | C1 callsite migrate / C8 exhaustiveness | +| `src/main/ai/AiWorker.ts` | C1 callsite migrate / C4 AiFailedReason import | +| `src/renderer/inbox/store.ts` | C1 snoozeExpired / snoozeRecall migrate | +| `src/main/services/telemetryEvents.ts` | C4 AiFailedReason 통합 | +| `src/main/services/TelemetryService.ts` | C4 EmitInput 의 reason type / C5 hasNoteId | +| `src/renderer/inbox/components/Banner.tsx` | C7 신규 shared component | +| `src/renderer/inbox/components/{Expiry,Ollama,Failed,Recall}Banner.tsx` + `OllamaSettingsModal.tsx` | C7 migrate | +| `src/main/ai/AiWorker.ts` | C9 VOCAB_TOP_N const | +| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | C9 URL pre-check | +| `src/main/services/telemetryStats.ts` (또는 callsite) | C9 휴지통 회수율 코멘트 | +| `package.json` + `package-lock.json` | T13 0.2.5 → 0.2.6 | + +총 신규 단위 +14 (추정): B1 (3), B2 (2), B3 (1), B4 (1), C1 (2), C5 (2), C8 (1), C9 (1), 나머지 refactor 0. + +--- + +## 작업 순서 (subagent dispatch 순서) + +순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → closure** + +이유 (spec §9): B3 (위험) cleanup 시작 직전. C2+C3 묶음. C7 (UI cleanup) 마지막 그룹. + +--- + +## Task B1: #10 restoreNote + pending_jobs 재생성 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +**Bug 설명**: 사용자가 노트 trash → AI fail 발생 → restore 시 `ai_status='failed'` 그대로, pending_jobs 미재생성. AI 재처리 path 없어 영구 fail. + +- [ ] **Step 1: Write failing test** + +Append to `tests/unit/NoteRepository.test.ts`: + +```typescript +it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.markAiFailed(id, 'unreachable'); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('failed'); + + repo.restoreNote(id); + + const after = repo.findById(id)!; + expect(after.deletedAt).toBeNull(); + expect(after.aiStatus).toBe('pending'); + expect(after.aiError).toBeNull(); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); +}); + +it('restoreNote does not re-enqueue done note', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('done'); + + repo.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('done'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeUndefined(); +}); + +it('restoreNote does not re-enqueue pending note (already enqueued)', () => { + const id = repo.create({ rawText: 'x' }).id; + // pending 상태로 trash + db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id); // 인공적으로 cleanup + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('pending'); + + repo.restoreNote(id); + + // pending 그대로 + pending_jobs 재생성 (없으면) + expect(repo.findById(id)!.aiStatus).toBe('pending'); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- NoteRepository` +Expected: 1+ FAIL — restoreNote 가 ai_status reset 안 함. + +- [ ] **Step 3: Implement** + +`src/main/repository/NoteRepository.ts` — `restoreNote` 메서드 찾아 변경: + +```typescript +restoreNote(id: string): void { + const tx = this.db.transaction(() => { + const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); + + // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 + if (before?.ai_status === 'failed') { + this.db.prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ).run(now, id); + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } else if (before?.ai_status === 'pending') { + // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } + // done 노트는 재처리 안 함 (이미 결과 있음) + }); + tx(); +} +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `npm test -- NoteRepository` +Expected: PASS — 3 new cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +fix(v026): #10 restoreNote 가 failed 노트 시 pending_jobs 재생성 + +restore 가 deleted_at = NULL 만 했음 → ai_status='failed' 인 노트 는 +영구 fail 상태로 복구. atomic transaction 안에서 ai_status='pending' ++ pending_jobs INSERT OR IGNORE. + +- failed → pending 재처리 path 복구 +- done 은 영향 X (이미 결과 있음) +- pending 은 pending_jobs 재생성 (정상화) +- 단위 +3 cases (failed/done/pending 각 케이스) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B2: #12 trashCount cap dialog 정확도 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` (`countTrashed`) +- Modify: `src/main/ipc/inboxApi.ts` (handler 갱신) +- Test: `tests/unit/NoteRepository.test.ts` + +**Bug 설명**: emptyTrash dialog 가 "200개 영구 삭제" 표시하지만 실제 350 trash 시 350 모두 삭제 → silent undercount. + +- [ ] **Step 1: Read current `inbox:trashCount` handler** + +In `src/main/ipc/inboxApi.ts`, find current `inbox:trashCount` 또는 비슷한 IPC. 현재 limit 200 으로 capped 되어 있음 확인. + +- [ ] **Step 2: Write failing test** + +Append to `tests/unit/NoteRepository.test.ts`: + +```typescript +it('countTrashed returns accurate count (>200 not capped)', () => { + const now = new Date().toISOString(); + for (let i = 0; i < 250; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.trash(id, now); + } + expect(repo.countTrashed()).toBe(250); +}); + +it('countTrashed returns 0 for empty trash', () => { + expect(repo.countTrashed()).toBe(0); +}); +``` + +- [ ] **Step 3: Run, verify fail** + +Run: `npm test -- NoteRepository` +Expected: FAIL — countTrashed 메서드 없음. + +- [ ] **Step 4: Implement countTrashed** + +In NoteRepository.ts add (near countToday / countFailed): + +```typescript +countTrashed(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`) + .get() as { c: number }; + return row.c; +} +``` + +- [ ] **Step 5: Update IPC handler** + +Find existing IPC that returns trash count (likely `inbox:trashCount` or similar). Replace cap-200 query with `deps.repo.countTrashed()` call. UI 의 emptyTrash confirm dialog 가 이 N 사용. + +```typescript +ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed()); +``` + +(만약 기존 handler 가 list 일부만 가져와 length 반환하면 그 부분 제거 + countTrashed 사용) + +- [ ] **Step 6: Run all tests** + +Run: `npm test` +Expected: 413 → 415 pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/repository/NoteRepository.ts src/main/ipc/inboxApi.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +fix(v026): #12 trashCount cap → countTrashed() 정확 N (silent undercount 해소) + +기존 UI 가 list 200 limit 후 length 사용 → 350개 trash 시 dialog +"200개 영구 삭제" 표시되지만 실제 350 모두 삭제. 사용자 혼동. + +- NoteRepository.countTrashed() 신규 — SELECT COUNT(*) WHERE deleted_at IS NOT NULL +- IPC handler inbox:trashCount 가 countTrashed 사용 +- emptyTrash confirm dialog 의 N 정확화 +- 단위 +2 cases (>200 not capped, empty 0) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B4: #46 hidden-start race (additionalData) + +**Files:** +- Modify: `src/main/index.ts` (single-instance lock 부분) + +**Bug 설명**: NSIS installer 가 설치 직후 사용자 클릭 (`inkling.exe`) + autostart (`inkling.exe --hidden`) 두 instance 짧은 간격 시 — 첫 lock 보유자에 따라 visible 여부 race. + +- [ ] **Step 1: Read current single-instance lock code** + +`src/main/index.ts` line 36-58 영역 (PR #23 으로 추가된 lock + second-instance handler). + +- [ ] **Step 2: Modify lock + handler** + +Replace existing block: + +```typescript +const HIDDEN_ARG = '--hidden'; +const startedHidden = process.argv.includes(HIDDEN_ARG); + +// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46). +// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창 +// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로. +const additionalData = { hidden: startedHidden }; +const gotLock = app.requestSingleInstanceLock(additionalData); +if (!gotLock) { + app.quit(); +} else { + app.on('second-instance', (_e, _argv, _cwd, secondData) => { + const data = secondData as { hidden?: boolean } | undefined; + // 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음 + if (data?.hidden === true) return; + // 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게 + const win = getInboxWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } + }); +} +``` + +- [ ] **Step 3: Run typecheck + tests** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 415/415 (no test impact — defensive fix). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/index.ts +git commit -m "$(cat <<'EOF' +fix(v026): #46 hidden-start race — additionalData 로 두 번째 hidden 구분 + +PR #23 single-instance lock 의 second-instance handler 가 무조건 inbox 창 +띄움. NSIS installer 직후 사용자 클릭 + autostart --hidden 동시 시도 시 +첫 lock 보유자가 hidden 이면 두 번째 (visible 사용자 클릭) 가 띄움 — OK. +첫 lock 이 visible (사용자) 이면 두 번째 (hidden autostart) 가 또 inbox 띄움 +→ 의도 위반. additionalData 로 두 번째의 hidden flag 전달 → hidden 이면 skip. + +PR #23 round 1 reviewer Important 처리. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B3: #45 autostart 풀림 (Windows registry) + +**Files:** +- Modify: `src/main/tray.ts` (autostart toggle) +- Modify: `src/main/index.ts` (firstRun autostart args 정합) + +**Risky task** — Windows registry 디버깅. Fallback: 진단 로그만 추가 + backlog 유지. + +- [ ] **Step 1: Read current autostart code** + +`src/main/tray.ts:47-58` 영역. `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` + `app.getLoginItemSettings()` (args 미전달). + +- [ ] **Step 2: Apply 2-part fix** + +In `src/main/tray.ts`: + +```typescript +if (app.isPackaged) { + // v0.2.6 #45 — args 명시 전달 → openAtLogin 비교 정확도 + const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] }); + items.push({ + label: '윈도우 시작 시 자동 실행', + type: 'checkbox', + checked: openAtLogin, + click: (item) => { + app.setLoginItemSettings({ + openAtLogin: item.checked, + args: ['--hidden'] + }); + } + }); + items.push({ type: 'separator' }); +} +``` + +In `src/main/index.ts` (firstRun autostart 영역): + +```typescript +if (app.isPackaged && process.platform === 'win32') { + const initFlag = join(paths.profileDir, '.autostart-init'); + if (!existsSync(initFlag)) { + app.setLoginItemSettings({ openAtLogin: true, args: [HIDDEN_ARG] }); + writeFileSync(initFlag, new Date().toISOString()); + logger.info('autostart.enabled.firstRun'); + } + // v0.2.6 #45 — 진단: args 전달 / 미전달 비교 + path 노출 + const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] }); + const noArgs = app.getLoginItemSettings(); + logger.info('autostart.state', { + withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: noArgs.openAtLogin }, + expectedArgs: [HIDDEN_ARG] + }); +} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 415/415. + +- [ ] **Step 4: Commit (fallback acceptable)** + +If real fix isn't possible (registry 동작 모호), commit 진단 로그만 + backlog #45 유지: + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "$(cat <<'EOF' +fix(v026): #45 autostart 풀림 — args 비교 정확도 + 진단 로그 + +추정 원인 (a)/(b)/(c): args 비교 mismatch / electron path canonicalization / +registry path vs 현재 process path. + +Fix: +- getLoginItemSettings({ args: ['--hidden'] }) — 트레이 메뉴 checked 상태가 + 실제 args 와 일치하는지 정확 비교 +- index.ts firstRun 후 autostart.state 진단 로그 (withArgs vs noArgs 비교) +- dogfood 에서 로그 확인 후 v0.2.7 에서 deeper fix 검토 가능 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C1: #3+#19+#34 KST helper 통합 + +**Files:** +- Create: `src/shared/util/kstDate.ts` +- Test: `tests/unit/kstDate.test.ts` (확장 기존 또는 신규) +- Modify: `src/main/services/TelemetryService.ts` (todayKstIso callsite) +- Modify: `src/main/services/telemetryStats.ts` (kstDate callsite) +- Modify: `src/main/ai/AiWorker.ts` (todayKstAsDate/Iso callsite) +- Modify: `src/renderer/inbox/store.ts` (snoozeExpired/snoozeRecall callsite) + +**기존 `tests/unit/kstDate.test.ts` 가 있다면 확장. 없으면 신규.** + +- [ ] **Step 1: Check existing kstDate** + +```bash +ls c:/Users/rlaxo/inkling/src/main/util/kstDate.ts 2>&1 +ls c:/Users/rlaxo/inkling/src/shared/util/ 2>&1 +``` + +기존 `@main/util/kstDate.ts` 있으면 그 코드 가져와 `src/shared/util/kstDate.ts` 로 이동 + alias 삭제. + +- [ ] **Step 2: Create/move kstDate.ts to shared** + +`src/shared/util/kstDate.ts`: + +```typescript +export const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** KST 자정 기준 today YYYY-MM-DD. */ +export function kstTodayIso(now: Date = new Date()): string { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) + .toISOString().slice(0, 10); +} + +/** 다음 KST 자정의 ms timestamp (UTC). */ +export function nextKstMidnightMs(now: Date = new Date()): number { + const nowMs = now.getTime(); + const kstNow = nowMs + KST_OFFSET_MS; + const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS; + const nextKstMidnight = kstMidnightFloor + DAY_MS; + return nextKstMidnight - KST_OFFSET_MS; +} + +/** KST today (00:00 KST 의 UTC Date). AiWorker 의 candidate parsing 용. */ +export function kstTodayAsDate(now: Date = new Date()): Date { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} +``` + +- [ ] **Step 3: Tests** + +`tests/unit/kstDate.test.ts` (신규 또는 확장): + +```typescript +import { describe, it, expect } from 'vitest'; +import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate, KST_OFFSET_MS, DAY_MS } from '@shared/util/kstDate.js'; + +describe('kstDate', () => { + it('kstTodayIso returns YYYY-MM-DD format in KST', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30 + expect(kstTodayIso(utcDate)).toBe('2026-05-05'); + }); + + it('nextKstMidnightMs returns next KST midnight (UTC ms)', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30 + const next = nextKstMidnightMs(utcDate); + // next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC + expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 4: Migrate callsites** + +Each callsite: import from `@shared/util/kstDate.js` 대체. + +- `src/main/services/TelemetryService.ts`: 기존 `todayKstIso(now)` → `kstTodayIso(now)`. KST_OFFSET_MS 로컬 const 제거. +- `src/main/services/telemetryStats.ts`: `kstDate(ts)` → `kstTodayIso(new Date(ts))`. KST_OFFSET_MS 로컬 const 제거. +- `src/main/ai/AiWorker.ts`: `todayKstAsDate(now)` → `kstTodayAsDate(now)`. `todayKstAsIso(now)` → `kstTodayIso(now)`. KST_OFFSET_MS 로컬 const 제거. +- `src/renderer/inbox/store.ts`: `snoozeExpired` + `snoozeRecall` 의 inline KST 계산 → `nextKstMidnightMs()` 호출. + +각 파일 변경 후: +```bash +npm run typecheck +npm test +``` + +Expected: 415 → 417 (+2 from kstDate.test). + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/util/kstDate.ts tests/unit/kstDate.test.ts \ + src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts \ + src/main/ai/AiWorker.ts src/renderer/inbox/store.ts +# 기존 src/main/util/kstDate.ts 가 있다면 git rm 추가 +git commit -m "$(cat <<'EOF' +refactor(v026): #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts + +main + renderer 양쪽에서 import 가능한 단일 util. +- kstTodayIso(now): YYYY-MM-DD KST 자정 기준 +- nextKstMidnightMs(now): 다음 KST 자정 UTC ms +- kstTodayAsDate(now): KST 자정의 UTC Date +- KST_OFFSET_MS, DAY_MS 상수 노출 + +Migrate 4 callsite: +- TelemetryService.todayKstIso +- telemetryStats.kstDate +- AiWorker.todayKstAsDate/Iso +- store.snoozeExpired + snoozeRecall (inline KST 계산) + +단위 +2 cases (kstTodayIso, nextKstMidnightMs). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C4: #5 AiFailedReason union 통합 + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` (zod enum 단일 export) +- Modify: `src/main/services/TelemetryService.ts` (EmitInput reason type) +- Modify: `src/main/ai/AiWorker.ts` (classifier + emitter) + +- [ ] **Step 1: Export AiFailedReason from telemetryEvents.ts** + +In `src/main/services/telemetryEvents.ts`: + +```typescript +export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']); +export type AiFailedReason = z.infer; +``` + +기존 `const AiFailedReason = z.enum(...)` 를 위 export 로 교체. + +- [ ] **Step 2: Migrate callsites** + +`src/main/services/TelemetryService.ts` EmitInput: +```typescript +import type { AiFailedReason } from './telemetryEvents.js'; +// ... +| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } } +``` + +`src/main/ai/AiWorker.ts`: +- `classifyReason` 의 반환 type → `AiFailedReason` +- `AiTelemetryEmitter` interface 의 `reason` field type → `AiFailedReason` + +기존 inline `'unreachable' | 'schema' | 'timeout' | 'other'` literal 모두 `AiFailedReason` 으로 교체. + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: 417/417 (refactor only). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/ai/AiWorker.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #5 AiFailedReason union 단일 export 통합 + +기존 'unreachable' | 'schema' | 'timeout' | 'other' literal 이 3곳 (zod enum, +EmitInput, AiWorker classifier+emitter) 에 분산. zod enum z.infer 통해 +type 파생, 단일 export AiFailedReason 으로 통합. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C5: #21 hasNoteId type predicate + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` (또는 `src/main/services/telemetryGuards.ts` 신규) +- Modify: `tests/unit/TelemetryService.test.ts` (narrowing 체인 단축) +- Test: `tests/unit/telemetryEvents.test.ts` 또는 신규 + +- [ ] **Step 1: Add hasNoteId predicate** + +In `src/main/services/telemetryEvents.ts` (또는 신규 `telemetryGuards.ts`): + +```typescript +import type { TelemetryEvent } from './telemetryEvents.js'; + +const NO_NOTE_ID_KINDS = new Set([ + 'empty_trash', 'expired_banner_shown', 'expired_batch_trash', + 'ollama_unreachable', 'ollama_recovered', 'ollama_recheck_manual', + 'ai_retry_manual', 'tag_vocab_hit', 'tag_vocab_miss' +]); + +export function hasNoteId(ev: TelemetryEvent): ev is Extract { + return !NO_NOTE_ID_KINDS.has(ev.kind); +} +``` + +- [ ] **Step 2: Migrate `tests/unit/TelemetryService.test.ts`** + +Find 4-line narrowing chain (`e.kind !== 'empty_trash' && ...`) — replace with `hasNoteId(e)`: + +```typescript +import { hasNoteId } from '@main/services/telemetryEvents.js'; +// ... +expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']); +// ... +if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a'); +``` + +- [ ] **Step 3: Add hasNoteId tests** + +Append to `tests/unit/telemetryEvents.test.ts`: + +```typescript +describe('hasNoteId', () => { + it('returns true for noteId-bearing events (capture / ai_succeeded / trash etc)', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); + expect(hasNoteId(e1)).toBe(true); + }); + + it('returns false for noteId-less events (empty_trash / tag_vocab_hit etc)', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } }); + const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } }); + expect(hasNoteId(e1)).toBe(false); + expect(hasNoteId(e2)).toBe(false); + }); +}); +``` + +- [ ] **Step 4: Run, verify pass** + +```bash +npm test +``` + +Expected: 417 → 419 (+2). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/telemetryEvents.ts tests/unit/TelemetryService.test.ts tests/unit/telemetryEvents.test.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #21 hasNoteId type predicate helper + +기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...) +이 union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합. +NO_NOTE_ID_KINDS Set lookup. Migration: TelemetryService.test.ts 의 +2 callsite + 신규 단위 +2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C6: #22 hydrate as any[] cleanup + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` + +기존 `db.prepare().all() as any[]` 또는 `as unknown[]` 패턴을 `as Record[]` 로 통일. `hydrate()` 메서드 signature 도 `Record` 받도록. + +- [ ] **Step 1: Find all `as any[]` / `as unknown[]` in NoteRepository** + +```bash +grep -n "as any\[\]\|as unknown\[\]" c:/Users/rlaxo/inkling/src/main/repository/NoteRepository.ts +``` + +- [ ] **Step 2: Replace with `as Record[]`** + +Each occurrence — change cast to `as Record[]`. hydrate() signature 가 `Record` 받도록 통일 (이미 그럴 가능성 높음). + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test -- NoteRepository +``` + +Expected: PASS — refactor only. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/repository/NoteRepository.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #22 NoteRepository hydrate row type 통일 + +db.prepare().all() 의 row type cast `as any[]` / `as unknown[]` → +`as Record[]` 일괄 통일. hydrate() signature 도 동일. +- TS strict 환경 친화 +- 향후 explicit row interface 로 추가 narrowing 시 base 형 명확 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C8: #8 stats.md exhaustiveness check + +**Files:** +- Modify: `src/main/services/telemetryStats.ts` + +- [ ] **Step 1: Add exhaustive `else { _: never }`** + +In `aggregateStats` 의 if/else if 체인 끝에: + +```typescript +} else if (ev.kind === 'recall_snoozed') { + row.recall_snoozed += 1; + recallSnoozedCount += 1; +} else { + // exhaustiveness check — 새 kind 추가 시 컴파일 단계 catch + const _exhaustive: never = ev; + void _exhaustive; +} +``` + +- [ ] **Step 2: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/services/telemetryStats.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #8 telemetryStats.aggregateStats exhaustiveness check + +if/else if 체인 끝에 const _exhaustive: never = ev — 새 telemetry kind +추가 시 본 함수 분기 누락을 컴파일 단계에서 catch. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C2+C3: #4+#23+#26+#27 TrayCallbacks 객체화 + +**Files:** +- Modify: `src/main/tray.ts` +- Modify: `src/main/index.ts` (createTray 호출) + +- [ ] **Step 1: Define TrayCallbacks interface** + +In `src/main/tray.ts`: + +```typescript +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + runBackup: () => void; + runExport: () => void; + runImport: () => void; + runSync: () => void; + runExportTelemetry: () => void; + runOllamaRecheck: () => void; + runRetryAllFailed: () => void; + runOpenOllamaSettings: () => void; +} + +export interface TrayState { + ollamaOk: boolean; + todayCount: number; + failedCount: number; +} +``` + +- [ ] **Step 2: Refactor createTray + module state** + +기존 module-level `_showInbox`, `_failedCount`, `_ollamaOk` 등 → 단일 module-scoped `_callbacks: TrayCallbacks | null` + `_state: TrayState` 로 통합. + +```typescript +let _callbacks: TrayCallbacks | null = null; +let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 }; + +export function createTray(callbacks: TrayCallbacks): void { + _callbacks = callbacks; + // ... 기존 tray 생성 + buildMenu 호출 ... +} + +export function refreshTray(state: Partial): void { + _state = { ..._state, ...state }; + if (tray) tray.setContextMenu(buildMenu()); +} + +// buildMenu 안에서 _callbacks!.showInbox 등 호출, _state 참조 +``` + +기존 `refreshTrayOllama(ok)`, `refreshTrayFailedCount(n)` → 모두 `refreshTray({ ollamaOk: ok })`, `refreshTray({ failedCount: n })` 로 호출자 변경. + +- [ ] **Step 3: Migrate index.ts** + +In `src/main/index.ts`, find existing `createTray(...10 args...)` call — replace with object: + +```typescript +createTray({ + showInbox: () => { ... }, + showCapture: () => { ... }, + runBackup: () => { ... }, + // ... 10 callbacks ... + runOpenOllamaSettings: () => { + const win = getInboxWindow(); + if (win) win.webContents.send('inbox:openOllamaSettings'); + } +}); +``` + +기존 `refreshTrayOllama(status.ok)` 호출 → `refreshTray({ ollamaOk: status.ok })`. +기존 `refreshTrayFailedCount(repo.countFailed())` 호출 → `refreshTray({ failedCount: repo.countFailed() })`. +기존 `refreshTray(repo.countToday())` 호출 → `refreshTray({ todayCount: repo.countToday() })`. + +- [ ] **Step 4: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS — refactor only. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #4+#23+#26+#27 TrayCallbacks 객체화 + state 통합 + +createTray(callbacks: TrayCallbacks) 1-arg signature. +TrayState 통합 (ollamaOk, todayCount, failedCount). +기존 refreshTrayOllama / refreshTrayFailedCount → refreshTray({ ... }). +module-scoped 개별 state 변수 (_failedCount 등) 제거. + +backlog #4 (positional 폭주) / #23 (8 callbacks) / #26 (9 callbacks) / +#27 (refreshTrayFailedCount singleton) 일괄 처리. 다음 menu item 추가 +시 callback 추가만 — readability blocker 해소. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C7: #24+#41 Banner shared component + +**Files:** +- Create: `src/renderer/inbox/components/Banner.tsx` +- Modify: `ExpiryBanner.tsx`, `OllamaBanner.tsx`, `FailedBanner.tsx`, `RecallBanner.tsx`, `OllamaSettingsModal.tsx` + +- [ ] **Step 1: Create Banner.tsx** + +```typescript +import React from 'react'; + +const THEMES = { + warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' }, + error: { bg: '#fce4e4', border: '#a33', text: '#a33' }, + info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' } +}; + +interface Props { + severity: 'warning' | 'error' | 'info'; + children: React.ReactNode; +} + +export function Banner({ severity, children }: Props): React.ReactElement { + const t = THEMES[severity]; + return ( +
+ {children} +
+ ); +} +``` + +- [ ] **Step 2: Migrate 4 banners** + +각 `*Banner.tsx` 의 outer `
` → `` 로 교체. 내부 children 유지. + +매핑: +- ExpiryBanner: warning (황색) +- OllamaBanner: warning (황색) +- FailedBanner: error (적색) +- RecallBanner: info (청색) + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: PASS — UI styling 만 변경, behavior 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/inbox/components/Banner.tsx \ + src/renderer/inbox/components/ExpiryBanner.tsx \ + src/renderer/inbox/components/OllamaBanner.tsx \ + src/renderer/inbox/components/FailedBanner.tsx \ + src/renderer/inbox/components/RecallBanner.tsx +git commit -m "$(cat <<'EOF' +refactor(v026): #24+#41 Banner shared component (severity prop) + +4 banner inline style 중복 (warning 황색 / error 적색 / info 청색) +→ wrapper. THEMES map 단일 source. +- ExpiryBanner: warning +- OllamaBanner: warning +- FailedBanner: error +- RecallBanner: info + +OllamaSettingsModal 은 modal 형식이라 별개 — banner 와 분리. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C9: microfixes (#15 #29 #42 #9) + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts`, `src/preload/index.ts`, `src/shared/types.ts` (`inbox:delete` → `inbox:trash`) +- Modify: `src/main/ai/AiWorker.ts` (`VOCAB_TOP_N` const) +- Modify: `src/renderer/inbox/components/OllamaSettingsModal.tsx` (URL pre-check) +- Modify: `src/main/services/telemetryStats.ts` (휴지통 회수율 ratio 코멘트) + +- [ ] **Step 1: #15 channel rename `inbox:delete` → `inbox:trash`** + +Search + replace `'inbox:delete'` → `'inbox:trash'` in: `src/main/ipc/inboxApi.ts`, `src/preload/index.ts`, `src/shared/types.ts`. Renderer 호출자도 메서드명 변경 (또는 InboxApi 의 method name 확인). + +만약 channel + method name 이 의미상 일치하면 method name 도 `deleteNote` → `trashNote` 변경 검토. 단 backward compat 측면 (renderer 호출자 영향) — minimal scope 면 channel만. + +- [ ] **Step 2: #29 VOCAB_TOP_N const** + +In `src/main/ai/AiWorker.ts`: + +```typescript +// 모듈 상단 +const VOCAB_TOP_N = 20; + +// processJob 내 +const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N); +``` + +- [ ] **Step 3: #42 Modal URL pre-check** + +In `src/renderer/inbox/components/OllamaSettingsModal.tsx`: + +```typescript +// imports +import { z } from 'zod'; +const EndpointSchema = z.string().url(); + +// handleSave 안, save IPC 직전: +async function handleSave() { + if (saving) return; + setSaving(true); + setError(null); + try { + // v0.2.6 #42 — client-side URL validation + const parseResult = EndpointSchema.safeParse(endpoint); + if (!parseResult.success) { + setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)'); + return; + } + if (model.trim().length === 0) { + setError('모델명을 입력하세요'); + return; + } + // 기존 inboxApi.saveOllamaSettings 호출 + const r = await inboxApi.saveOllamaSettings({ endpoint, model }); + // ... +``` + +- [ ] **Step 4: #9 휴지통 회수율 ratio 코멘트** + +In `src/main/services/telemetryStats.ts`, find `trashRecoveryRate` 계산 영역 — 1줄 코멘트 추가: + +```typescript +// 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시 100% 가능, +// unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분). +const trashRecoveryRate = trashCount === 0 ? 'N/A' : ...; +``` + +- [ ] **Step 5: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS — 4 microfix 모두 minor. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts \ + src/main/ai/AiWorker.ts src/renderer/inbox/components/OllamaSettingsModal.tsx \ + src/main/services/telemetryStats.ts +git commit -m "$(cat <<'EOF' +refactor(v026): C9 microfixes — #15 #29 #42 #9 + +- #15: IPC channel inbox:delete → inbox:trash (semantic = soft delete) +- #29: getTopUsedTags(20) → VOCAB_TOP_N const (튜닝 자체는 telemetry 후) +- #42: OllamaSettingsModal client-side URL validation (zod safeParse pre-check) +- #9: 휴지통 회수율 ratio 의미 1줄 코멘트 (event-level, unique-note 아님) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task T13 (Closure): Version bump + final gates + +**Files:** +- Modify: `package.json`, `package-lock.json` (0.2.5 → 0.2.6) + +- [ ] **Step 1: Final gates** + +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: typecheck 0 / 단위 ~427 / e2e 1. + +- [ ] **Step 2: Version bump** + +```json +"version": "0.2.6", +``` + +In both `package.json` + `package-lock.json` (top + nested `""` entry). + +- [ ] **Step 3: Commit closure** + +```bash +git add package.json package-lock.json +git commit -m "$(cat <<'EOF' +chore(release): v0.2.6 — bugs + cleanup (16 backlog 항목 처리) + +bugs (4): #10 restore + pending_jobs / #12 trashCount cap / #45 autostart 풀림 / + #46 hidden-start race +cleanup (12 → 9 cluster): KST helper 통합 / TrayCallbacks 객체화 + singleton 제거 / + AiFailedReason union / hasNoteId predicate / hydrate as any[] / + Banner shared component / exhaustiveness check / microfixes (channel rename + + VOCAB_TOP_N + Modal URL pre-check + ratio 코멘트) + +게이트: typecheck 0 / 단위 ~427 / e2e 1 +잔여 backlog: 14건 (telemetry data-dependent, v0.2.7 brainstorm) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review + +**1. Spec coverage**: +- ✅ Bug 4건 all (B1~B4) +- ✅ Cleanup 12건 → 9 cluster all +- ✅ Telemetry data-dependent + 별도 brainstorm 영역 명시 out of scope + +**2. Placeholder scan**: +- T13 step 2 의 lock file 위치 — 기존 패턴대로 (top + nested ""). 명시적. +- B3 fallback 처리 — 진단 로그 추가 시점 명시. + +**3. Type consistency**: +- `kstTodayIso(now: Date = new Date()): string` — C1 정의, callsite migration 모두 사용 +- `AiFailedReason = 'unreachable' | ...` — C4 정의, EmitInput + AiWorker 일관 +- `hasNoteId(ev): ev is ...` — C5 정의, TelemetryService.test.ts callsite 일관 +- `TrayCallbacks` interface — C2 정의, index.ts 호출자 일관 + +--- + +## Roadmap relation + +- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 patch/hotfix 와 별개) +- 머지 후 binary v0.2.6 빌드 (Windows + Mac) + Gitea release +- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage +- backlog file 본 cut 후 prune (16 건 처리 완료) + rename 검토 diff --git a/docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md b/docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md new file mode 100644 index 0000000..9708f31 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md @@ -0,0 +1,133 @@ +# v0.2.6 Bugs + Cleanup — Design Spec + +> 작성: 2026-05-05 · 정식 v0.2.6 cut. backlog 16건 (bug 4 + cleanup 12, 13 task 로 cluster) 통합 처리. dogfood telemetry 미수집 영역 (#7/#16/#18/#25/#33/#35/#36/#39/#40 등 14건) 은 v0.2.7 brainstorm 영역으로 별도. + +## 1. Goal + +dogfood UX 마찰 (autostart 풀림, trashCount 부정확, restore 시 AI 미재처리) 즉시 해소 + 코드베이스 cleanup (KST helper 통합, TrayCallbacks 객체화, AiFailedReason union 통합 등) 으로 v0.2.7 brainstorm 시 신규 feature 작업 friction 제거. + +## 2. Scope (16 backlog 항목 → 13 task) + +### Bug fixes (B1~B4) + +| Task | 항목 | 작업 요약 | +|---|---|---| +| **B1** | #10 | `NoteRepository.restoreNote(id)` 가 `ai_status='failed'` 인 노트 복구 시 `ai_status='pending'` reset + `pending_jobs INSERT` | +| **B2** | #12 | `NoteRepository.countTrashed()` 추가 + IPC `inbox:trashCount` 가 SQL 정확 N 반환 (UI 200 cap 제거) | +| **B3** | #45 | autostart 풀림: `app.getLoginItemSettings({ args: ['--hidden'] })` (args 비교 정확도) + path canonicalization 검토. fallback: 진단 로그만 추가 시 backlog 유지 | +| **B4** | #46 | `app.requestSingleInstanceLock(additionalData)` + `second-instance(event, argv, cwd, additionalData)` 에서 hidden flag 체크 → 두 번째 hidden 이면 inbox 창 안 띄움 | + +### Cleanup refactor (C1~C9) + +| Task | 항목 (cluster) | 작업 요약 | +|---|---|---| +| **C1** | #3 + #19 + #34 | KST helper 통합 → `src/shared/util/kstDate.ts`. 4 callsite migrate (`TelemetryService.todayKstIso`, `telemetryStats.kstDate`, `AiWorker.todayKstAsDate/Iso`, store `snoozeExpired/snoozeRecall`) | +| **C2** | #4 + #23 + #26 | `interface TrayCallbacks` + `createTray(callbacks: TrayCallbacks)` 1-arg refactor. positional 10개 → object | +| **C3** | #27 | `refreshTrayFailedCount` module-scoped state 제거 → TrayCallbacks 객체 안 reactive 함수 또는 store-driven 패턴 | +| **C4** | #5 | `export type AiFailedReason = 'unreachable' \| 'schema' \| 'timeout' \| 'other'` 단일 export + zod `z.enum` 의 `z.infer` 로 type 파생. 3 callsite migrate | +| **C5** | #21 | `hasNoteId(ev: TelemetryEvent): ev is TelemetryEventWithNoteId` type predicate helper → `tests/unit/TelemetryService.test.ts` 의 4-line narrowing 체인 단축 | +| **C6** | #22 | NoteRepository hydrate 의 `as any[]` → `Record[]` (또는 explicit row interface) 일괄 cleanup | +| **C7** | #24 + #41 | `` shared component → ExpiryBanner / OllamaBanner / FailedBanner / RecallBanner / OllamaSettingsModal 5 callsite migrate | +| **C8** | #8 | `telemetryStats.aggregateStats` if/else if 끝에 `else { const _: never = ev; }` exhaustiveness check | +| **C9** | #15 + #29 + #42 + #9 | microfixes 묶음: `inbox:delete`→`inbox:trash` rename / `getTopUsedTags(20)` → `VOCAB_TOP_N` const / `OllamaSettingsModal` zod URL pre-check / 휴지통 회수율 ratio 코멘트 1줄 | + +## 3. Out of scope + +- Telemetry 데이터 필요 (14건): #7 reason 분포 / #16 permanent_delete 빈도 / #18 loadExpired consumer / #20 telemetry .catch silent / #25 HealthChecker dedup / #28 unreachableBackoffStep / #29 top-N 튜닝값 (extract 만 본 cut, 튜닝은 v0.2.7) / #30 LIMIT-then-filter 정책 / #31 vocabSet COLLATE / #32 per-tag emit 병렬화 / #33 promptVersion payload / #35 recall_shown lifetime / #36 IPC handle vs on / #39 ollama reason PII / #40 Settings race flicker +- 별도 brainstorm 영역 (3건): #11 restoreNote precondition / #14 ARIA 패턴 / #17 dialog 버튼 순서 / #37 NoteCard id ref-forwarding + +## 4. Architecture changes + +대부분 cosmetic refactor 또는 isolated bug fix. 주목할 architecture-level 변경: + +### 4.1 KST helper 통합 (C1) +- 신규 `src/shared/util/kstDate.ts` (main + renderer 양쪽 import 가능) +- 기존 4 callsite 의 inline KST 계산 제거 +- API: `kstTodayIso(now?: Date): string`, `nextKstMidnightMs(now?: Date): number` +- KST_OFFSET_MS 상수 단일 + +### 4.2 TrayCallbacks 객체화 (C2 + C3) +- `interface TrayCallbacks` — 10+ 개 callback + state getter +- `createTray(callbacks: TrayCallbacks): void` — 1-arg signature +- module state (_failedCount, _todayCount, _ollamaOk) 는 TrayCallbacks 의 reactive getter / setter 패턴 또는 explicit refresh 함수 (`refreshTray(state: { todayCount, failedCount, ollamaOk })`) + +### 4.3 Banner shared component (C7) +- `` — wrapping/styling 일원화 +- 5 callsite 가 themed inline style 제거 → severity prop +- CSS variables 또는 hardcoded theme map (single source) + +### 4.4 NoteRepository.restoreNote behavior change (B1) +- 기존: `UPDATE notes SET deleted_at = NULL WHERE id = ?` +- 변경: 추가로 `ai_status='failed'` 였을 경우 → `ai_status='pending'` reset + `INSERT OR IGNORE INTO pending_jobs` +- atomic transaction +- AiWorker 가 자동으로 다음 loop iteration 에서 처리 + +## 5. Tests + +추정 +17 cases (413 → 430): + +| Task | 신규 단위 | +|---|---| +| B1 | +3 (restore failed note re-enqueues, restore done note 영향 X, restore cancelled note 영향 X) | +| B2 | +2 (countTrashed 정확, dialog message 정확 N) | +| B3 | +1-2 (autostart args 비교, 가능하다면 mock electron app) | +| B4 | +1 (additionalData hidden flag 가 second-instance 에 전달, mock test) | +| C1 | +2 (kstTodayIso, nextKstMidnightMs) — 기존 4 callsite test 가 자동 검증 | +| C2 | refactor only, 기존 tray 테스트 유지 | +| C3 | refactor only | +| C4 | refactor only | +| C5 | +2 (hasNoteId predicate) | +| C6 | refactor only | +| C7 | refactor only (UI 컴포넌트 unit test X 패턴) | +| C8 | +1 (exhaustive guard 컴파일 단계) | +| C9 | +1 (Modal URL pre-check), 나머지 refactor only | + +총 신규: ~13-15 (보수적). 단위 413 → **~426-428** 예상. + +## 6. Privacy invariant + +- B1/B2: telemetry 영향 없음 +- B3/B4: telemetry emit 없음 (autostart event 미수집) +- C 시리즈: 모두 cosmetic refactor — invariant 영향 0 +- 본 cut 에서 신규 telemetry kind 추가 0 + +## 7. Gates (roadmap §3.1) + +- typecheck 0 +- 단위 413 → ~427 (+13~15) +- e2e 1/1 +- backward compat: 기존 사용자 데이터 + UI 동작 영향 0 (단 B1 은 의도적 동작 추가, B2 는 UI N 표시 정확화) + +## 8. Risk + Fallback + +### B3 (autostart 풀림) 진단 불확실 +가장 risky. Windows registry 디버깅 결과 깨끗한 fix 안 나올 수 있음. **Fallback 정책**: +- 진단 절차 적용해도 fix 안 되면 → 진단 로그만 추가 (`logger.info('autostart.state', { stored, current, mismatch })`) → backlog #45 유지 → 본 cut 에서 task drop +- 다른 task 영향 없음 (각 task 독립적) + +### C1 KST helper 의 alias 경계 +`src/shared/util/kstDate.ts` 가 main + renderer 양쪽에서 import 되어야. 기존 `@main/util/kstDate.ts` 는 renderer 에서 import 불가 (alias 분리). `src/shared/` 가 양쪽 가능 패턴. 검증 필요. + +### C2 TrayCallbacks 객체화 의 backward compat +기존 createTray 호출자 (index.ts 1곳) 한 군데만 변경 → 안전. tray 테스트 영향 최소. + +## 9. 작업 순서 + +순서대로 subagent dispatch. 의존성: +- B1, B2: 독립 +- B3: 독립 (Windows-specific, mock 어려움) +- B4: 독립 +- C1 → 다른 task 영향 X (shared util 추가) +- C2 → C3 (TrayCallbacks 객체에 refreshTrayFailedCount 흡수) +- C4, C5, C6, C7, C8, C9: 독립 + +권장 순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2 → C3 → C7 → C9**. + +이유: B3 (위험) 을 cleanup 시작 직전에 두어 fail 시 빠르게 회피. C2/C3 cluster 는 묶어서. C7 (Banner shared) 는 isolated UI cleanup, 마지막 그룹. + +## 10. Roadmap relation + +- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 는 patch / hotfix) +- 머지 후 binary 빌드 v0.2.6 (Windows + Mac) + Gitea release +- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후, 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage +- backlog file 본 cut 후 prune (16 건 처리 완료 표기) + rename 검토 (`v027-backlog.md` 또는 `feature-backlog.md`) diff --git a/package-lock.json b/package-lock.json index a6ef275..1dec720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 32a1d94..763c812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.5", + "version": "0.2.6", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b8bd651..17e9206 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -1,22 +1,15 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { Note } from '@shared/types'; +import type { AiFailedReason } from '../services/telemetryEvents.js'; import { ProviderHolder } from './ProviderHolder.js'; import { parseAllCandidates } from '../services/dueDateParser.js'; import { ZodError } from 'zod'; +import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js'; -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출). +const VOCAB_TOP_N = 20; -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); -} - -function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' { +function classifyReason(err: unknown): AiFailedReason { if (err instanceof ZodError) return 'schema'; const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) { @@ -31,7 +24,7 @@ function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'o export interface AiTelemetryEmitter { emit(input: | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } - | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } } | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } ): Promise; @@ -131,10 +124,10 @@ export class AiWorker { const note = this.repo.findById(job.noteId); if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return; const nowDate = this.now(); - const todayDate = todayKstAsDate(nowDate); - const todayIso = todayKstAsIso(nowDate); + const todayDate = kstTodayAsDate(nowDate); + const todayIso = kstTodayIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); - const vocab = this.repo.getTopUsedTags(20); + const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N); const res = await this.holder.get().generate({ text: note.rawText, todayKst: todayIso, diff --git a/src/main/index.ts b/src/main/index.ts index c892a87..e38197f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,7 @@ import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow } from './windows/quickCaptureWindow.js'; -import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js'; +import { createTray, refreshTray } from './tray.js'; import { MediaGc } from './services/MediaGc.js'; import { BackupService } from './services/BackupService.js'; import { ExportService } from './services/ExportService.js'; @@ -36,16 +36,19 @@ import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constan const HIDDEN_ARG = '--hidden'; const startedHidden = process.argv.includes(HIDDEN_ARG); -// CRITICAL — single-instance lock. 두 번째 .exe 실행 시 즉시 종료. -// 미적용 시 SQLite 동시 접근 + AiWorker 중복 처리 + HealthChecker 중복 polling 등 -// 데이터 corruption 위험. activate (Mac) / second-instance (Win/Linux) 이벤트로 -// 기존 inbox 창에 focus. -const gotLock = app.requestSingleInstanceLock(); +// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46). +// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창 +// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로. +const additionalData = { hidden: startedHidden }; +const gotLock = app.requestSingleInstanceLock(additionalData); if (!gotLock) { app.quit(); } else { - app.on('second-instance', () => { - // 새 .exe 실행 또는 트레이 외 entry 시 기존 inbox 창 보이게 + app.on('second-instance', (_e, _argv, _cwd, secondData) => { + const data = secondData as { hidden?: boolean } | undefined; + // 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음 + if (data?.hidden === true) return; + // 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게 const win = getInboxWindow(); if (win) { if (win.isMinimized()) win.restore(); @@ -80,6 +83,14 @@ app.whenReady().then(async () => { writeFileSync(initFlag, new Date().toISOString()); logger.info('autostart.enabled.firstRun'); } + // v0.2.6 #45 진단 — 실제 LoginItem 상태 확인 (args 전달 vs 미전달 차이) + const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] }); + const noArgs = app.getLoginItemSettings(); + logger.info('autostart.state', { + withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: noArgs.openAtLogin, executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin }, + expectedArgs: [HIDDEN_ARG] + }); } const db = openDb(paths.dbFile); const repo = new NoteRepository(db); @@ -109,7 +120,7 @@ app.whenReady().then(async () => { onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); pushOllamaStatus(getInboxWindow, status); - refreshTrayOllama(status.ok); + refreshTray({ ollamaOk: status.ok }); }, onTelemetry: (ev) => { if (ev.kind === 'ollama_unreachable') { @@ -127,8 +138,7 @@ app.whenReady().then(async () => { onUpdate: (note) => { pushNoteUpdated(getInboxWindow, note); // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. - refreshTray(repo.countToday()); - refreshTrayFailedCount(repo.countFailed()); + refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() }); }, logger, telemetry @@ -212,10 +222,10 @@ app.whenReady().then(async () => { }); }); - createTray( - () => createInboxWindow(), - () => showQuickCapture(), - async () => { + createTray({ + showInbox: () => createInboxWindow(), + showCapture: () => showQuickCapture(), + runBackup: async () => { try { const r = await backup.runDaily(); new Notification({ @@ -234,7 +244,7 @@ app.whenReady().then(async () => { }).show(); } }, - async () => { + runExport: async () => { const win = getInboxWindow(); const dialogOpts: Electron.OpenDialogOptions = { title: '내보낼 폴더 선택', @@ -268,7 +278,7 @@ app.whenReady().then(async () => { }).show(); } }, - async () => { + runImport: async () => { const win = getInboxWindow(); const dirOpts: Electron.OpenDialogOptions = { title: '복원할 백업 폴더 선택', @@ -330,7 +340,7 @@ app.whenReady().then(async () => { }).show(); } }, - async () => { + runSync: async () => { // runSync — 트레이 "지금 동기화" try { const r = await syncSvc.sync(); @@ -353,7 +363,7 @@ app.whenReady().then(async () => { new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show(); } }, - /* runExportTelemetry */ async () => { + runExportTelemetry: async () => { const win = getInboxWindow(); const dialogOpts: Electron.OpenDialogOptions = { title: '사용 로그를 내보낼 폴더 선택', @@ -382,21 +392,20 @@ app.whenReady().then(async () => { }).show(); } }, - /* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); }, - /* runRetryAllFailed */ () => { void capture.retryAllFailed(); }, - /* runOpenOllamaSettings */ () => { + runOllamaRecheck: () => { void health.runOnce({ manual: true }); }, + runRetryAllFailed: () => { void capture.retryAllFailed(); }, + runOpenOllamaSettings: () => { const win = getInboxWindow(); if (win) win.webContents.send('inbox:openOllamaSettings'); } - ); + }); // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. // 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거. // cleanup 은 위 통합 before-quit 핸들러에서 처리. - refreshTray(repo.countToday()); - refreshTrayFailedCount(repo.countFailed()); + refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() }); trayInterval = setInterval(() => { - refreshTray(repo.countToday()); + refreshTray({ todayCount: repo.countToday() }); }, 60_000); app.on('activate', () => { diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 97a9819..16d1b81 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -39,7 +39,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void { deps.repo.setDueDate(arg.noteId, arg.date); }); - ipcMain.handle('inbox:delete', async (_e, noteId: string) => { + ipcMain.handle('inbox:trash', async (_e, noteId: string) => { await deps.capture.deleteNote(noteId); }); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 8ec8a2b..3d47050 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,7 +1,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; -import { todayInKstString } from '../util/kstDate.js'; +import { kstTodayIso } from '../../shared/util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -78,7 +78,7 @@ export class NoteRepository { } findById(id: string): Note | null { - const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any; + const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record; if (!row) return null; return this.hydrate(row); } @@ -92,21 +92,21 @@ export class NoteRepository { WHERE deleted_at IS NULL AND created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?` ) - .all(opts.cursor, limit) as any[]) + .all(opts.cursor, limit) as Record[]) : (this.db .prepare( `SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ?` ) - .all(limit) as any[]); + .all(limit) as Record[]); return rows.map((r) => this.hydrate(r)); } listAll(): Note[] { const rows = this.db .prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`) - .all() as any[]; + .all() as Record[]; return rows.map((r) => this.hydrate(r)); } @@ -410,6 +410,31 @@ export class NoteRepository { .run(now, id); } + restoreNote(id: string): void { + const tx = this.db.transaction(() => { + const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); + + // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 + if (before?.ai_status === 'failed') { + this.db.prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ).run(now, id); + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } else if (before?.ai_status === 'pending') { + // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent) + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } + // done 노트는 재처리 안 함 (이미 결과 있음) + }); + tx(); + } + permanentDelete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } @@ -428,7 +453,7 @@ export class NoteRepository { const limit = Math.max(1, Math.min(200, opts.limit)); const rows = this.db .prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`) - .all(limit) as any[]; + .all(limit) as Record[]; return rows.map((r) => this.hydrate(r)); } @@ -576,7 +601,7 @@ export class NoteRepository { * Caller may inject `now` for testability; defaults to `new Date()`. */ findExpiredCandidates(now: Date = new Date()): Note[] { - const today = todayInKstString(now); + const today = kstTodayIso(now); const rows = this.db .prepare( `SELECT * FROM notes @@ -586,18 +611,18 @@ export class NoteRepository { AND ai_status = 'done' ORDER BY created_at DESC, id DESC` ) - .all(today) as any[]; + .all(today) as Record[]; return rows.map((r) => this.hydrate(r)); } getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { const rows = this.db .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) - .all() as any[]; + .all() as Record[]; return rows.map((r) => ({ - noteId: r.note_id, - attempts: r.attempts, - nextRunAt: r.next_run_at + noteId: r.note_id as string, + attempts: r.attempts as number, + nextRunAt: r.next_run_at as string })); } @@ -613,39 +638,39 @@ export class NoteRepository { .run(nextRunAt, lastError.slice(0, 500), noteId); } - private hydrate(row: any): Note { + private hydrate(row: Record): Note { const tags = this.db .prepare( `SELECT t.name, nt.source FROM note_tags nt JOIN tags t ON t.id = nt.tag_id WHERE nt.note_id = ? ORDER BY t.name` ) - .all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>; + .all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>; const media = this.db .prepare( `SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?` ) - .all(row.id) as NoteMedia[]; + .all(row.id as string) as NoteMedia[]; return { - id: row.id, - rawText: row.raw_text, - aiTitle: row.ai_title, - aiSummary: row.ai_summary, - aiStatus: row.ai_status, - aiError: row.ai_error, - aiProvider: row.ai_provider, - aiGeneratedAt: row.ai_generated_at, - titleEditedByUser: row.title_edited_by_user === 1, - 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, - deletedAt: row.deleted_at ?? null, - lastRecalledAt: row.last_recalled_at ?? null, - recallDismissedAt: row.recall_dismissed_at ?? null, - createdAt: row.created_at, - updatedAt: row.updated_at, + id: row.id as string, + rawText: row.raw_text as string, + aiTitle: row.ai_title as string | null, + aiSummary: row.ai_summary as string | null, + aiStatus: row.ai_status as 'pending' | 'done' | 'failed', + aiError: row.ai_error as string | null, + aiProvider: row.ai_provider as string | null, + aiGeneratedAt: row.ai_generated_at as string | null, + titleEditedByUser: (row.title_edited_by_user as number) === 1, + summaryEditedByUser: (row.summary_edited_by_user as number) === 1, + userIntent: row.user_intent as string | null, + intentPromptedAt: row.intent_prompted_at as string | null, + dueDate: (row.due_date as string | null) ?? null, + dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1, + deletedAt: (row.deleted_at as string | null) ?? null, + lastRecalledAt: (row.last_recalled_at as string | null) ?? null, + recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, tags: tags as NoteTag[], media }; diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index e67ae3e..4dc03e2 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -88,9 +88,14 @@ export class CaptureService { async restoreNote(noteId: string): Promise { // 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. - const note = this.repo.findById(noteId); - if (!note || note.deletedAt === null) return; - this.repo.restore(noteId); + const before = this.repo.findById(noteId); + if (!before || before.deletedAt === null) return; + // v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성) + this.repo.restoreNote(noteId); + // v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X + if (before.aiStatus === 'failed' || before.aiStatus === 'pending') { + await this.deps.enqueue(noteId); + } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); } diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index fe7abd1..ecd147b 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -1,16 +1,9 @@ import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { validateEvent, TelemetryEvent } from './telemetryEvents.js'; +import type { AiFailedReason } from './telemetryEvents.js'; import { aggregateStats } from './telemetryStats.js'; - -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; -const DAY_MS = 24 * 60 * 60 * 1000; - -function todayKstIso(now: Date): string { - const k = new Date(now.getTime() + KST_OFFSET_MS); - return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) - .toISOString().slice(0, 10); -} +import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js'; export interface TelemetryServiceOptions { silent?: boolean; @@ -19,7 +12,7 @@ export interface TelemetryServiceOptions { export type EmitInput = | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } - | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } } | { kind: 'trash'; payload: { noteId: string } } | { kind: 'restore'; payload: { noteId: string } } | { kind: 'permanent_delete'; payload: { noteId: string } } @@ -54,7 +47,7 @@ export class TelemetryService { return { removed }; } const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS); - const cutoffIso = todayKstIso(cutoff); // KST 일자 비교 + const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교 for (const name of entries) { const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name); if (!m) continue; @@ -77,7 +70,7 @@ export class TelemetryService { const nowDate = this.now(); const ts = nowDate.toISOString(); const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); - const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`); + const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`); try { await mkdir(this.dir, { recursive: true }); await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8'); @@ -96,7 +89,7 @@ export class TelemetryService { return events; } const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS; - const cutoffIso = todayKstIso(new Date(cutoffMs)); + const cutoffIso = kstTodayIso(new Date(cutoffMs)); // 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로 // 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨. const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/; diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 67d194f..8be0da1 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -12,11 +12,12 @@ const AiSucceededPayload = z.object({ attempts: z.number().int().nonnegative() }).strict(); -const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']); +export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']); +export type AiFailedReason = z.infer; const AiFailedPayload = z.object({ noteId: z.string().min(1), - reason: AiFailedReason, + reason: AiFailedReasonSchema, attempts: z.number().int().nonnegative() }).strict(); @@ -92,3 +93,23 @@ export type TelemetryKind = TelemetryEvent['kind']; export function validateEvent(raw: unknown): TelemetryEvent { return TelemetryEventSchema.parse(raw); } + +/** + * v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow. + * union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신. + */ +const NO_NOTE_ID_KINDS = new Set([ + 'empty_trash', + 'expired_banner_shown', + 'expired_batch_trash', + 'ollama_unreachable', + 'ollama_recovered', + 'ollama_recheck_manual', + 'ai_retry_manual', + 'tag_vocab_hit', + 'tag_vocab_miss' +]); + +export function hasNoteId(ev: TelemetryEvent): ev is Extract { + return !NO_NOTE_ID_KINDS.has(ev.kind); +} diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 0054fe7..8c99cc3 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -1,12 +1,8 @@ import type { TelemetryEvent } from './telemetryEvents.js'; - -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +import { kstTodayIso } from '../../shared/util/kstDate.js'; function kstDate(ts: string): string { - const d = new Date(ts); - const k = new Date(d.getTime() + KST_OFFSET_MS); - return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) - .toISOString().slice(0, 10); + return kstTodayIso(new Date(ts)); } interface DailyRow { @@ -133,12 +129,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta } else if (ev.kind === 'recall_snoozed') { row.recall_snoozed += 1; recallSnoozedCount += 1; + } else { + // v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch. + const _exhaustive: never = ev; + void _exhaustive; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); const aiTotal = aiSucceeded + aiFailed; const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`; const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`; + // v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시 + // 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분). const trashRecoveryRate = trashCount === 0 ? 'N/A' : `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`; diff --git a/src/main/tray.ts b/src/main/tray.ts index 3f02373..38d8425 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -32,49 +32,70 @@ function showAboutDialog(): void { }); } -let tray: TrayType | null = null; -let _showInbox: () => void = () => {}; -let _showCapture: () => void = () => {}; -let _runBackup: () => void = () => {}; -let _runExport: () => void = () => {}; -let _runImport: () => void = () => {}; -let _runSync: () => void = () => {}; -let _runExportTelemetry: () => void = () => {}; -let _runOllamaRecheck: () => void = () => {}; -let _ollamaOk = true; -let _todayCount = 0; -let _runRetryAllFailed: () => void = () => {}; -let _failedCount = 0; -let _runOpenOllamaSettings: () => void = () => {}; +/** + * v0.2.6 C2 — 트레이 메뉴 콜백 묶음. createTray 가 1-arg 로 받음. + */ +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + runBackup: () => void; + runExport: () => void; + runImport: () => void; + runSync: () => void; + runExportTelemetry: () => void; + runOllamaRecheck: () => void; + runRetryAllFailed: () => void; + runOpenOllamaSettings: () => void; +} -function buildMenu() { +/** + * v0.2.6 C3 — 메뉴 라벨/활성화에 영향 주는 reactive state. refreshTray() 로 partial 갱신. + */ +export interface TrayState { + ollamaOk: boolean; + todayCount: number; + failedCount: number; +} + +let tray: TrayType | null = null; +let _callbacks: TrayCallbacks | null = null; +let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 }; + +function buildMenu(): electron.Menu { const items: MenuItemConstructorOptions[] = []; + const cb = _callbacks; + if (!cb) { + // createTray 호출 전이면 빈 메뉴 (defensive) + return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]); + } // F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작. - if (_todayCount > 0) { - items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false }); + if (_state.todayCount > 0) { + items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false }); items.push({ type: 'separator' }); } - items.push({ label: '보관한 메모 보기', click: _showInbox }); - items.push({ label: '한 줄 적기', click: _showCapture }); + items.push({ label: '보관한 메모 보기', click: cb.showInbox }); + items.push({ label: '한 줄 적기', click: cb.showCapture }); items.push({ type: 'separator' }); - items.push({ label: '지금 백업', click: _runBackup }); - items.push({ label: '내보내기...', click: _runExport }); - items.push({ label: '백업에서 복원...', click: _runImport }); - items.push({ label: '지금 동기화', click: _runSync }); - items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry }); + items.push({ label: '지금 백업', click: cb.runBackup }); + items.push({ label: '내보내기...', click: cb.runExport }); + items.push({ label: '백업에서 복원...', click: cb.runImport }); + items.push({ label: '지금 동기화', click: cb.runSync }); + items.push({ label: '사용 로그 내보내기...', click: cb.runExportTelemetry }); items.push({ label: 'Ollama 재확인', - enabled: !_ollamaOk, - click: _runOllamaRecheck + enabled: !_state.ollamaOk, + click: cb.runOllamaRecheck }); items.push({ - label: `지금 AI 처리 (실패 ${_failedCount}건)`, - enabled: _failedCount > 0, - click: _runRetryAllFailed + label: `지금 AI 처리 (실패 ${_state.failedCount}건)`, + enabled: _state.failedCount > 0, + click: cb.runRetryAllFailed }); - items.push({ label: 'Ollama 설정...', click: () => _runOpenOllamaSettings() }); + items.push({ label: 'Ollama 설정...', click: cb.runOpenOllamaSettings }); if (app.isPackaged) { - const { openAtLogin } = app.getLoginItemSettings(); + // v0.2.6 #45 — args 명시 전달로 openAtLogin 비교 정확도. setLoginItemSettings 가 + // args 와 함께 LoginItem 등록하므로 read 시도 같은 args 로 비교해야 매치됨. + const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] }); items.push({ label: '윈도우 시작 시 자동 실행', type: 'checkbox', @@ -95,62 +116,32 @@ function buildMenu() { return Menu.buildFromTemplate(items); } -export function createTray( - showInbox: () => void, - showCapture: () => void, - runBackup: () => void, - runExport: () => void, - runImport: () => void, - runSync: () => void, - runExportTelemetry: () => void, - runOllamaRecheck: () => void, - runRetryAllFailed: () => void, - runOpenOllamaSettings: () => void -): TrayType { - _showInbox = showInbox; - _showCapture = showCapture; - _runBackup = runBackup; - _runExport = runExport; - _runImport = runImport; - _runSync = runSync; - _runExportTelemetry = runExportTelemetry; - _runOllamaRecheck = runOllamaRecheck; - _runRetryAllFailed = runRetryAllFailed; - _runOpenOllamaSettings = runOpenOllamaSettings; +/** + * v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기. + */ +export function createTray(callbacks: TrayCallbacks): TrayType { + _callbacks = callbacks; const icon = nativeImage.createEmpty(); tray = new Tray(icon); - tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); + tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`); tray.setContextMenu(buildMenu()); - tray.on('click', showInbox); + tray.on('click', callbacks.showInbox); return tray; } /** - * F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신. - * `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출. + * v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드. + * + * Replaces: refreshTrayOllama(ok), refreshTrayFailedCount(count), 기존 refreshTray(todayCount). + * + * 호출 예: + * refreshTray({ todayCount: 5 }); + * refreshTray({ ollamaOk: false }); + * refreshTray({ failedCount: 2 }); */ -export function refreshTray(todayCount: number): void { - _todayCount = todayCount; - if (tray === null) return; - tray.setToolTip(`Inkling — 오늘 ${todayCount}`); - tray.setContextMenu(buildMenu()); -} - -/** - * v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출. - * 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신. - */ -export function refreshTrayOllama(ok: boolean): void { - _ollamaOk = ok; - if (tray === null) return; - tray.setContextMenu(buildMenu()); -} - -/** - * v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신. - */ -export function refreshTrayFailedCount(count: number): void { - _failedCount = count; +export function refreshTray(state: Partial): void { + _state = { ..._state, ...state }; if (tray === null) return; + tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`); tray.setContextMenu(buildMenu()); } diff --git a/src/main/util/kstDate.ts b/src/main/util/kstDate.ts deleted file mode 100644 index 8e8e776..0000000 --- a/src/main/util/kstDate.ts +++ /dev/null @@ -1,28 +0,0 @@ -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - -/** - * Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant. - * - * v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against - * notes.due_date (also stored as YYYY-MM-DD per slice §F1). - */ -export function todayInKstString(now: Date): string { - const k = new Date(now.getTime() + KST_OFFSET_MS); - return new Date( - Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()) - ).toISOString().slice(0, 10); -} - -/** - * Epoch ms of the next 00:00 KST strictly after `now`. - * - * v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze - * deadline ("오늘 그만"). - */ -export function nextKstMidnightMs(now: number): number { - const kstNow = now + KST_OFFSET_MS; - // Floor to KST midnight, then add one day. - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - return nextKstMidnight - KST_OFFSET_MS; -} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1ba817c..4281f5a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,7 +12,7 @@ const api: InklingApi = { 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), + deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId), setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }), dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId), getContinuity: () => ipcRenderer.invoke('inbox:continuity'), diff --git a/src/renderer/inbox/components/Banner.tsx b/src/renderer/inbox/components/Banner.tsx new file mode 100644 index 0000000..a63e592 --- /dev/null +++ b/src/renderer/inbox/components/Banner.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +/** + * v0.2.6 #24+#41 — 4 banner 의 inline style 중복 제거. severity 별 theme map. + */ +const THEMES = { + warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' }, + error: { bg: '#fce4e4', border: '#a33', text: '#a33' }, + info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' } +} as const; + +interface Props { + severity: 'warning' | 'error' | 'info'; + children: React.ReactNode; +} + +export function Banner({ severity, children }: Props): React.ReactElement { + const t = THEMES[severity]; + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/inbox/components/ExpiryBanner.tsx b/src/renderer/inbox/components/ExpiryBanner.tsx index bed77be..a08bcf9 100644 --- a/src/renderer/inbox/components/ExpiryBanner.tsx +++ b/src/renderer/inbox/components/ExpiryBanner.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import type { Note } from '@shared/types'; import { useInbox } from '../store.js'; +import { Banner } from './Banner.js'; export function ExpiryBanner(): React.ReactElement | null { const candidates = useInbox((s) => s.expiredCandidates); @@ -72,10 +73,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React } return ( -
+
오늘 기준 만료 {candidates.length}개
+
); } diff --git a/src/renderer/inbox/components/FailedBanner.tsx b/src/renderer/inbox/components/FailedBanner.tsx index 254c3ad..a41ccc2 100644 --- a/src/renderer/inbox/components/FailedBanner.tsx +++ b/src/renderer/inbox/components/FailedBanner.tsx @@ -1,32 +1,31 @@ import React from 'react'; import { useInbox } from '../store.js'; +import { Banner } from './Banner.js'; export function FailedBanner(): React.ReactElement | null { const count = useInbox((s) => s.failedCount); const retryAllFailed = useInbox((s) => s.retryAllFailed); if (count === 0) return null; return ( -
- ❌ AI 처리 실패 {count} - -
+ +
+ ❌ AI 처리 실패 {count} + +
+
); } diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx index 2de6fa8..0ad9120 100644 --- a/src/renderer/inbox/components/OllamaBanner.tsx +++ b/src/renderer/inbox/components/OllamaBanner.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useInbox } from '../store.js'; +import { Banner } from './Banner.js'; interface OllamaBannerProps { onOpenSettings?: () => void; @@ -14,7 +15,8 @@ export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React. ? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.' : 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.'; return ( -
+ +
⚠ {message}
+
+
); } diff --git a/src/renderer/inbox/components/OllamaSettingsModal.tsx b/src/renderer/inbox/components/OllamaSettingsModal.tsx index 5eadc22..8400e59 100644 --- a/src/renderer/inbox/components/OllamaSettingsModal.tsx +++ b/src/renderer/inbox/components/OllamaSettingsModal.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useState } from 'react'; +import { z } from 'zod'; import { inboxApi } from '../api.js'; import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js'; +const EndpointSchema = z.string().url(); + interface Props { open: boolean; onClose: () => void; @@ -32,6 +35,16 @@ export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElemen setSaving(true); setError(null); try { + // v0.2.6 #42 — client-side URL validation, server-side healthCheck 전에 명확한 메시지 + const parseResult = EndpointSchema.safeParse(endpoint); + if (!parseResult.success) { + setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)'); + return; + } + if (model.trim().length === 0) { + setError('모델명을 입력하세요'); + return; + } const r = await inboxApi.saveOllamaSettings({ endpoint, model }); if (r.ok) { onClose(); diff --git a/src/renderer/inbox/components/RecallBanner.tsx b/src/renderer/inbox/components/RecallBanner.tsx index c0436d8..e770169 100644 --- a/src/renderer/inbox/components/RecallBanner.tsx +++ b/src/renderer/inbox/components/RecallBanner.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useInbox } from '../store.js'; import { inboxApi } from '../api.js'; +import { Banner } from './Banner.js'; export function RecallBanner(): React.ReactElement | null { const candidate = useInbox((s) => s.recallCandidate); @@ -47,10 +48,7 @@ export function RecallBanner(): React.ReactElement | null { } return ( -
+
💭 오늘 회상해볼 노트 @@ -90,7 +88,7 @@ export function RecallBanner(): React.ReactElement | null { 더 이상
-
+ ); } diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 8e7c708..bf88933 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { Note, WeeklyContinuity } from '@shared/types'; import { inboxApi } from './api.js'; +import { nextKstMidnightMs } from '@shared/util/kstDate.js'; export { selectFilteredNotes } from './selectFilteredNotes.js'; @@ -177,12 +178,7 @@ export const useInbox = create((set, get) => ({ }); }, snoozeExpired() { - const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - const now = Date.now(); - const kstNow = now + KST_OFFSET_MS; - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) }); }, async recheckOllama() { const status = await inboxApi.ollamaRecheck(); @@ -212,12 +208,7 @@ export const useInbox = create((set, get) => ({ set({ recallCandidate, recallSnoozeUntilMs: null }); }, async snoozeRecall() { - const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - const now = Date.now(); - const kstNow = now + KST_OFFSET_MS; - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) }); // m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시 // snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적). const candidate = get().recallCandidate; diff --git a/src/shared/util/kstDate.ts b/src/shared/util/kstDate.ts new file mode 100644 index 0000000..57b658e --- /dev/null +++ b/src/shared/util/kstDate.ts @@ -0,0 +1,42 @@ +/** + * KST timezone helpers — main + renderer 양쪽에서 import 가능. + * v0.2.6 C1: backlog #3+#19+#34 통합 (기존 src/main/util/kstDate.ts 이동). + */ + +export const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * KST 자정 기준 today YYYY-MM-DD. + * + * 기존 todayInKstString (NoteRepository.findExpiredCandidates), + * TelemetryService.todayKstIso, telemetryStats.kstDate, AiWorker.todayKstAsIso + * 4 callsite 통합. + */ +export function kstTodayIso(now: Date = new Date()): string { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) + .toISOString().slice(0, 10); +} + +/** + * 다음 KST 자정의 epoch ms (UTC). + * + * 기존 nextKstMidnightMs (store.snoozeExpired) + store.snoozeRecall inline 통합. + */ +export function nextKstMidnightMs(now: number = Date.now()): number { + const kstNow = now + KST_OFFSET_MS; + const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS; + const nextKstMidnight = kstMidnightFloor + DAY_MS; + return nextKstMidnight - KST_OFFSET_MS; +} + +/** + * KST today (00:00 KST 의 UTC Date 객체). AiWorker 의 dueDateParser 가 candidate 비교용. + * + * 기존 AiWorker.todayKstAsDate 통합. + */ +export function kstTodayAsDate(now: Date = new Date()): Date { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 51e19e8..513f9fc 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -324,6 +324,52 @@ describe('CaptureService.trashExpiredBatch', () => { }); }); +describe('CaptureService.restoreNote — enqueue on failed/pending (#10 production path)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let enqueued: string[]; + let svc: CaptureService; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-restore-')); + store = new MediaStore(tmp); + enqueued = []; + svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {} + }); + }); + + it('restoreNote calls worker.enqueue when restoring failed note', async () => { + const { id } = repo.create({ rawText: 'x' }); + repo.markAiFailed(id, 'unreachable'); + repo.trash(id, new Date().toISOString()); + enqueued.length = 0; // reset + + await svc.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('pending'); + expect(enqueued).toContain(id); + }); + + it('restoreNote does not enqueue done note', async () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.trash(id, new Date().toISOString()); + enqueued.length = 0; // reset + + await svc.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('done'); + expect(enqueued).not.toContain(id); + }); +}); + describe('CaptureService.retryAllFailed', () => { let db: Database.Database; let repo: NoteRepository; diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 9cb2854..37e6abb 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -267,6 +267,49 @@ describe('NoteRepository', () => { repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' }); expect(repo.findRecallCandidate()?.id).toBe(d); }); + + it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.markAiFailed(id, 'unreachable'); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('failed'); + + repo.restoreNote(id); + + const after = repo.findById(id)!; + expect(after.deletedAt).toBeNull(); + expect(after.aiStatus).toBe('pending'); + expect(after.aiError).toBeNull(); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); + }); + + it('restoreNote does not re-enqueue done note', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('done'); + + repo.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('done'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeUndefined(); + }); + + it('restoreNote re-enqueues pending note (defensive)', () => { + const id = repo.create({ rawText: 'x' }).id; + // 인공적으로 pending_jobs 비운 후 trash + db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('pending'); + + repo.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('pending'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); + }); }); describe('NoteRepository.trash', () => { @@ -449,6 +492,19 @@ describe('NoteRepository.countTrashed', () => { expect(repo.countTrashed()).toBe(10); expect(repo.listTrashed({ limit: 5 })).toHaveLength(5); }); + + it('countTrashed returns accurate count (>200 not capped)', () => { + const now = new Date().toISOString(); + for (let i = 0; i < 250; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.trash(id, now); + } + expect(repo.countTrashed()).toBe(250); + }); + + it('countTrashed returns 0 for empty trash', () => { + expect(repo.countTrashed()).toBe(0); + }); }); describe('Active queries exclude deleted notes', () => { diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 0e41f86..a7ae35e 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSy import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { TelemetryService } from '@main/services/TelemetryService.js'; +import { hasNoteId } from '@main/services/telemetryEvents.js'; describe('TelemetryService.emit', () => { let dir: string; @@ -147,11 +148,7 @@ describe('TelemetryService.readAllRecent', () => { const events = await svc.readAllRecent(); expect(events).toHaveLength(3); // discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패 - expect(events.map((e) => - (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss') - ? null - : e.payload.noteId - )).toEqual(['a', 'b', 'b']); + expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { @@ -164,7 +161,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(1); const ev = events[0]!; expect(ev.kind).toBe('capture'); - if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a'); + if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a'); }); it('returns [] when dir missing', async () => { diff --git a/tests/unit/kstDate.test.ts b/tests/unit/kstDate.test.ts index 657829c..189a5d3 100644 --- a/tests/unit/kstDate.test.ts +++ b/tests/unit/kstDate.test.ts @@ -1,18 +1,29 @@ import { describe, it, expect } from 'vitest'; -import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js'; +import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate } from '@shared/util/kstDate.js'; -describe('todayInKstString', () => { +describe('kstTodayIso', () => { it('returns KST calendar date as YYYY-MM-DD', () => { // 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST - expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01'); + expect(kstTodayIso(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01'); }); it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => { - expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02'); + expect(kstTodayIso(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02'); }); it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => { - expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02'); + expect(kstTodayIso(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02'); + }); + + it('boundary — UTC 14:59:59 still KST 23:59:59 same day', () => { + // KST 5/4 23:59:59 = UTC 5/4 14:59:59 + const utcDate = new Date('2026-05-04T14:59:59Z'); + expect(kstTodayIso(utcDate)).toBe('2026-05-04'); + }); + + it('KST 5/5 00:30 (UTC 5/4 15:30) returns 2026-05-05', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); + expect(kstTodayIso(utcDate)).toBe('2026-05-05'); }); }); @@ -34,4 +45,19 @@ describe('nextKstMidnightMs', () => { expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000); expect(next - now).toBeLessThan(24 * 60 * 60 * 1000); }); + + it('KST 5/5 00:30 → next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC', () => { + const utcMs = new Date('2026-05-04T15:30:00Z').getTime(); + const next = nextKstMidnightMs(utcMs); + expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z'); + }); +}); + +describe('kstTodayAsDate', () => { + it('returns UTC Date at KST 00:00', () => { + // KST 5/5 00:30 → KST 5/5 00:00 = UTC 5/4 15:00 + const utcDate = new Date('2026-05-04T15:30:00Z'); + const result = kstTodayAsDate(utcDate); + expect(result.toISOString()).toBe('2026-05-05T00:00:00.000Z'); + }); }); diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index b6e4dd4..4199645 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { validateEvent } from '@main/services/telemetryEvents.js'; +import { validateEvent, hasNoteId } from '@main/services/telemetryEvents.js'; describe('validateEvent — happy path', () => { it('accepts capture event', () => { @@ -333,3 +333,19 @@ describe('validateEvent — recall', () => { }); }); +describe('hasNoteId', () => { + it('returns true for noteId-bearing events', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); + const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'recall_shown', payload: { noteId: 'n1', ageDays: 14 } }); + expect(hasNoteId(e1)).toBe(true); + expect(hasNoteId(e2)).toBe(true); + }); + + it('returns false for noteId-less events', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } }); + const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } }); + expect(hasNoteId(e1)).toBe(false); + expect(hasNoteId(e2)).toBe(false); + }); +}); +