From cca3029b7e33f2a6bddb630b77cd36183ae17bd5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:47:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(repo):=20countToday(now=3F)=20?= =?UTF-8?q?=E2=80=94=20KST=20midnight=20bucket=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the F4-C·F cue strengthening surfaces (tray tooltip + Inbox identity counter), main + renderer need a single source of truth for "오늘 N번 잡아뒀다". Implements `NoteRepository.countToday(now?)` that computes the half-open UTC interval covering the KST calendar date of `now` and counts rows whose `created_at` falls inside. `now` is injectable for deterministic tests across the KST/UTC boundary (02:00 KST and 23:00 KST land on different UTC dates yet the same / a different KST day). Four new cases cover empty DB, KST-day filtering, KST-midnight crossover, and the default-arg branch. --- src/main/repository/NoteRepository.ts | 22 +++++++++++++++ tests/unit/NoteRepository.test.ts | 39 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index ee4f4f4..13d26cb 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -302,6 +302,28 @@ export class NoteRepository { return row.c; } + /** + * Count notes whose `created_at` falls on the KST calendar date of `now`. + * KST = UTC+9. We compute the UTC half-open interval + * [KST-midnight today, KST-midnight tomorrow) + * and count rows whose UTC ISO `created_at` lies inside. + */ + countToday(now: Date = new Date()): number { + const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const kstYear = kstNow.getUTCFullYear(); + const kstMonth = kstNow.getUTCMonth(); + const kstDate = kstNow.getUTCDate(); + const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS; + const nextKstMidnightUtc = kstMidnightUtc + 24 * 60 * 60 * 1000; + const startIso = new Date(kstMidnightUtc).toISOString(); + const endIso = new Date(nextKstMidnightUtc).toISOString(); + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`) + .get(startIso, endIso) as { c: number }; + return row.c; + } + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { const rows = this.db .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 0ae09a6..5fde31d 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -175,4 +175,43 @@ describe('NoteRepository', () => { const note = repo.findById(id)!; expect(note.dueDate).toBeNull(); }); + + it('countToday returns 0 for empty DB', () => { + expect(repo.countToday(new Date('2026-04-26T12:00:00Z'))).toBe(0); + }); + + it('countToday counts notes created on the KST date of "now"', () => { + // now = 2026-04-26 14:00 KST (= 2026-04-26T05:00:00Z UTC). + // a, b: 2026-04-26 KST → counted. + // c: 2026-04-25 KST → excluded. + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, 'x', 'pending', ?, ?)` + ).run('a', '2026-04-25T17:00:00Z', '2026-04-25T17:00:00Z'); // 04-26 02:00 KST + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, 'x', 'pending', ?, ?)` + ).run('b', '2026-04-25T18:00:00Z', '2026-04-25T18:00:00Z'); // 04-26 03:00 KST + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, 'x', 'pending', ?, ?)` + ).run('c', '2026-04-25T14:00:00Z', '2026-04-25T14:00:00Z'); // 04-25 23:00 KST + expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(2); + }); + + it('countToday handles KST midnight crossover', () => { + // now = 2026-04-26 14:00 KST. A note at 2026-04-26T23:30Z = 2026-04-27 08:30 KST + // belongs to "tomorrow" (KST), so MUST NOT be counted as "today". + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, 'x', 'pending', ?, ?)` + ).run('a', '2026-04-26T23:30:00Z', '2026-04-26T23:30:00Z'); + expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(0); + }); + + it('countToday default arg uses Date.now()', () => { + const n = repo.countToday(); + expect(typeof n).toBe('number'); + expect(n).toBeGreaterThanOrEqual(0); + }); }); -- 2.49.1 From bcd1151a243025fa7e1770cb539fa785e90afb69 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:49:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(cue):=20IdentityCounter=20+=20tray=20r?= =?UTF-8?q?efresh=20=E2=80=94=20=EC=98=A4=EB=8A=98=20N=EB=B2=88=20?= =?UTF-8?q?=EC=9E=A1=EC=95=84=EB=92=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4-C·F (cue 강화) — Inkling 의 두 표면에 정체성 신호를 추가. F4-C 환경 앵커 (tray): - nativeImage 색 변경은 미지원이므로 색 뱃지 대신 tooltip + 메뉴 첫 비활성 라벨로 대체. tooltip 은 항상 `Inkling — 오늘 N`, 메뉴 첫 항목은 N>0 일 때 `오늘 N번 잡아둠` (비활성). - `tray.ts` 가 `refreshTray(todayCount)` 를 export 하여 main 이 60s interval + AiWorker.onUpdate hook 에서 갱신을 트리거. - N=0 일 때는 라벨을 띄우지 않아 메뉴가 자연스럽게 시작. F4-F 정체성 고리 (Inbox 헤더): - ContinuityBadge 옆에 새 IdentityCounter 컴포넌트. - N>0 → `오늘 N번 잡아뒀다` (정체성 강화 카피). - N=0 → `오늘은 처음 한 줄?` (priming 카피로 첫 캡처 유도). - 갱신은 `loadInitial` / `refreshMeta` (focus + note:updated) 경로 공유 — 별도 IPC subscription 없음. Wiring: - `NoteRepository.countToday()` 를 `inbox:todayCount` IPC 로 노출. - preload bridge `getTodayCount`, `InboxApi.getTodayCount()` 타입. - 스토어에 `todayCount: number` 필드 추가, 두 메타 fetch 경로 모두에서 갱신. 스키마 변경 없음. 197/197 unit pass, 1/1 e2e pass. --- src/main/index.ts | 16 ++++- src/main/ipc/inboxApi.ts | 1 + src/main/tray.ts | 59 +++++++++++++------ src/preload/index.ts | 1 + src/renderer/inbox/App.tsx | 6 +- .../inbox/components/IdentityCounter.tsx | 18 ++++++ src/renderer/inbox/store.ts | 16 +++-- src/shared/types.ts | 1 + 8 files changed, 90 insertions(+), 28 deletions(-) create mode 100644 src/renderer/inbox/components/IdentityCounter.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 49f84e9..ab2de69 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,7 +22,7 @@ import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow } from './windows/quickCaptureWindow.js'; -import { createTray } 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'; @@ -67,7 +67,11 @@ app.whenReady().then(async () => { void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record)); const worker = new AiWorker(repo, provider, { - onUpdate: (note) => pushNoteUpdated(getInboxWindow, note), + onUpdate: (note) => { + pushNoteUpdated(getInboxWindow, note); + // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. + refreshTray(repo.countToday()); + }, logger }); @@ -282,6 +286,14 @@ app.whenReady().then(async () => { } ); + // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. + // 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거. + refreshTray(repo.countToday()); + const trayInterval = setInterval(() => { + refreshTray(repo.countToday()); + }, 60_000); + app.on('before-quit', () => { clearInterval(trayInterval); }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); }); diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 628ae43..af5aa92 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -51,6 +51,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:continuity', () => deps.continuity.get()); ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount()); ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus()); + ipcMain.handle('inbox:todayCount', () => deps.repo.countToday()); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/tray.ts b/src/main/tray.ts index fa3b333..c45b99f 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -3,24 +3,28 @@ import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron'; const { app, Tray, Menu, nativeImage } = electron; let tray: TrayType | null = null; +let _showInbox: () => void = () => {}; +let _showCapture: () => void = () => {}; +let _runBackup: () => void = () => {}; +let _runExport: () => void = () => {}; +let _runImport: () => void = () => {}; +let _runSync: () => void = () => {}; +let _todayCount = 0; -function buildMenu( - showInbox: () => void, - showCapture: () => void, - runBackup: () => void, - runExport: () => void, - runImport: () => void, - runSync: () => void -) { - const items: MenuItemConstructorOptions[] = [ - { label: '보관한 메모 보기', click: showInbox }, - { label: '한 줄 적기', click: showCapture }, - { type: 'separator' }, - { label: '지금 백업', click: runBackup }, - { label: '내보내기...', click: runExport }, - { label: '백업에서 복원...', click: runImport }, - { label: '지금 동기화', click: runSync } - ]; +function buildMenu() { + const items: MenuItemConstructorOptions[] = []; + // F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작. + if (_todayCount > 0) { + items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false }); + items.push({ type: 'separator' }); + } + items.push({ label: '보관한 메모 보기', click: _showInbox }); + items.push({ label: '한 줄 적기', click: _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 }); if (app.isPackaged) { const { openAtLogin } = app.getLoginItemSettings(); items.push({ @@ -50,10 +54,27 @@ export function createTray( runImport: () => void, runSync: () => void ): TrayType { + _showInbox = showInbox; + _showCapture = showCapture; + _runBackup = runBackup; + _runExport = runExport; + _runImport = runImport; + _runSync = runSync; const icon = nativeImage.createEmpty(); tray = new Tray(icon); - tray.setToolTip('Inkling'); - tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport, runImport, runSync)); + tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); + tray.setContextMenu(buildMenu()); tray.on('click', showInbox); return tray; } + +/** + * F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신. + * `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출. + */ +export function refreshTray(todayCount: number): void { + _todayCount = todayCount; + if (tray === null) return; + tray.setToolTip(`Inkling — 오늘 ${todayCount}`); + tray.setContextMenu(buildMenu()); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 00073eb..7d80085 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,7 @@ const api: InklingApi = { getContinuity: () => ipcRenderer.invoke('inbox:continuity'), getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'), getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'), + getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'), onNoteUpdated: (cb) => { const listener = (_e: unknown, note: Note) => cb(note); ipcRenderer.on('note:updated', listener); diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index ae96484..796c3b9 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -4,6 +4,7 @@ import { inboxApi } from './api.js'; import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js'; import { NoteCard } from './components/NoteCard.js'; import { ContinuityBadge } from './components/ContinuityBadge.js'; +import { IdentityCounter } from './components/IdentityCounter.js'; import { PendingBanner } from './components/PendingBanner.js'; import { OllamaBanner } from './components/OllamaBanner.js'; import { RecoveryToast } from './components/RecoveryToast.js'; @@ -41,7 +42,10 @@ export function App(): React.ReactElement { <>

Inkling

- +
+ + +
diff --git a/src/renderer/inbox/components/IdentityCounter.tsx b/src/renderer/inbox/components/IdentityCounter.tsx new file mode 100644 index 0000000..ca29427 --- /dev/null +++ b/src/renderer/inbox/components/IdentityCounter.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useInbox } from '../store.js'; + +/** + * F4-F 정체성 고리 — 헤더에 "오늘 N번 잡아뒀다" identity-reinforcement copy. + * count = 0 일 때는 priming 카피 ("오늘은 처음 한 줄?") 로 첫 캡처를 유도. + */ +export function IdentityCounter(): React.ReactElement { + const todayCount = useInbox((s) => s.todayCount); + if (todayCount === 0) { + return
오늘은 처음 한 줄?
; + } + return ( +
+ 오늘 {todayCount}번 잡아뒀다 +
+ ); +} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 65641c4..f4efcb5 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -9,6 +9,7 @@ interface InboxState { continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; + todayCount: number; loading: boolean; tagFilter: string | null; loadInitial: () => Promise; @@ -28,25 +29,28 @@ export const useInbox = create((set, get) => ({ continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, + todayCount: 0, loading: false, tagFilter: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), - inboxApi.getOllamaStatus() + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount() ]); - set({ notes, continuity, pendingCount, ollamaStatus, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), - inboxApi.getOllamaStatus() + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount() ]); - set({ continuity, pendingCount, ollamaStatus }); + set({ continuity, pendingCount, ollamaStatus, todayCount }); }, upsertNote(note) { const i = get().notes.findIndex((n) => n.id === note.id); diff --git a/src/shared/types.ts b/src/shared/types.ts index 8af0560..af566e9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -66,6 +66,7 @@ export interface InboxApi { getContinuity(): Promise; getPendingCount(): Promise; getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>; + getTodayCount(): Promise; onNoteUpdated(cb: (note: Note) => void): () => void; } -- 2.49.1 From 72e69fb53ac09ba96d23e2c476c245220e6e99a4 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:49:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(spec):=20promote=20F4-C=C2=B7F=20cue?= =?UTF-8?q?=20strengthening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 의 6개 cue 메커니즘 중 외부 신호 없이 즉시 구현 가능한 두 가지 (C 환경 앵커, F 정체성 고리) 를 묶어 promoted spec 으로 추출. A (잠금 hook), D (variable interval prompt), B (ambient if-then) 는 dogfood soak 측정 결과를 본 뒤 결정. F4 헤더를 🌱 raw → 🔬 drafting (C·E·F promoted) 로 갱신하고, F4 진행 상태에 두 promoted 경로를 명시. --- .../specs/2026-04-25-dogfood-feedback.md | 6 ++-- .../superpowers/specs/2026-04-26-f4-cf-cue.md | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-26-f4-cf-cue.md diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index 14b3162..9d15022 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -270,13 +270,15 @@ H1 이 미달이면 본 항목 ❌ rejected. --- -## F4. 떠오른 순간 → "Inkling!" 자동 연상 만들기 (🌱 raw) +## F4. 떠오른 순간 → "Inkling!" 자동 연상 만들기 (🔬 drafting — C·E·F promoted) **발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 와 strategy.md §3 가 **이미 알고 있는 contextual cue** (회의 후, 퇴근 전, 디버깅 후) 의 if-then 만 다루고, **ambient/spontaneous 떠오름** (샤워, 산책, 대화 중, 자기 전) 은 사각지대. **진행 상태:** - E (Zeigarnik priming) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f3-f4e-copy.md` -- A·B·C·D·F — 🌱 raw, 측정 후 결정 +- C (환경 앵커) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f4-cf-cue.md` +- F (정체성 고리) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f4-cf-cue.md` +- A (잠금 hook), D (variable interval prompt), B (ambient if-then) — 🌱 raw, 측정 후 결정 ### 관찰 diff --git a/docs/superpowers/specs/2026-04-26-f4-cf-cue.md b/docs/superpowers/specs/2026-04-26-f4-cf-cue.md new file mode 100644 index 0000000..b7a0e6c --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-f4-cf-cue.md @@ -0,0 +1,35 @@ +# F4-C·F Cue 강화 Spec (Promoted) + +**Extracted from:** `2026-04-25-dogfood-feedback.md` F4 §"C. 환경 앵커" + §"F. 정체성 고리" +**Status:** 🚀 promoted — implemented 2026-04-26 + +## 결정 (mini-brainstorm 결과) + +| 결정 | 값 | 근거 | +|------|-----|------| +| 트레이 색·뱃지 | nativeImage 색 변경 미지원 → tooltip + 메뉴 비활성 라벨로 대체 | 빈 아이콘 위에 색칠 비용 vs 텍스트 신호 비용 | +| 트레이 메뉴 첫 항목 | count > 0: `오늘 N번 잡아둠` (비활성) | 시야에 들어오는 매 트레이 클릭에 정체성 노출 | +| 트레이 tooltip | `Inkling — 오늘 N` (always) | 호버 시 즉시 신호 | +| Inbox 헤더 | count > 0: `오늘 N번 잡아뒀다` / 0: `오늘은 처음 한 줄?` | F4-F 정체성 고리 + F4-E Zeigarnik priming 결합 | +| 갱신 주기 | 60초 interval + AI worker onUpdate + focus event | renderer/store + main/tray 분리 | +| count 정의 | KST 자정 기준 created_at | ContinuityService 와 같은 KST 정의 | + +## 범위 (PR 안에 포함됨) + +- `src/main/repository/NoteRepository.ts` — `countToday(now: Date = new Date()): number` +- `src/main/ipc/inboxApi.ts` — `inbox:todayCount` 핸들러 +- `src/preload/index.ts` — `getTodayCount` 브리지 +- `src/shared/types.ts` — `InboxApi.getTodayCount()` +- `src/renderer/inbox/store.ts` — `todayCount` 필드 + load/refresh +- `src/renderer/inbox/components/IdentityCounter.tsx` — 신규 컴포넌트 +- `src/renderer/inbox/App.tsx` — 헤더에 IdentityCounter 추가 +- `src/main/tray.ts` — `refreshTray(todayCount)` export, 메뉴 첫 항목 동적 + tooltip 동적 +- `src/main/index.ts` — 60초 interval + AiWorker onUpdate hook +- 테스트 — `NoteRepository.countToday` 4 케이스 (empty, KST date filtering, midnight crossover, default arg) + +## 후속 + +- F4-A (잠금/잠금해제 hook) — measurement-dependent, dogfood soak 후 +- F4-D (variable interval random prompt) — measurement-dependent +- F4-B (ambient if-then) — strategy 재검토와 묶임, 별 spec +- 트레이 아이콘 PNG 색 변형 (count > 0 = 활성 색, 0 = 회색) — 별 spec, 아이콘 자산 필요 -- 2.49.1