F4-C·F Cue 강화 (v0.2.1 dogfood-feedback Track #8 — FINAL) #9

Merged
altair823 merged 3 commits from feat/f4-cf-cue into main 2026-04-26 02:52:01 +00:00
12 changed files with 190 additions and 30 deletions

View File

@@ -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, 측정 후 결정
### 관찰

View File

@@ -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, 아이콘 자산 필요

View File

@@ -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<string, unknown>));
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();
});

View File

@@ -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 {

View File

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

View File

@@ -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());
}

View File

@@ -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);

View File

@@ -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 {
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<ContinuityBadge />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<ContinuityBadge />
<IdentityCounter />
</div>
</div>
<main className="main">
<OllamaBanner />

View File

@@ -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 <div style={{ fontSize: 12, color: '#999' }}> ?</div>;
}
return (
<div style={{ fontSize: 12, color: '#236b1a' }}>
<b>{todayCount}</b>
</div>
);
}

View File

@@ -9,6 +9,7 @@ interface InboxState {
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
todayCount: number;
loading: boolean;
tagFilter: string | null;
loadInitial: () => Promise<void>;
@@ -28,25 +29,28 @@ export const useInbox = create<InboxState>((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);

View File

@@ -66,6 +66,7 @@ export interface InboxApi {
getContinuity(): Promise<WeeklyContinuity>;
getPendingCount(): Promise<number>;
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
getTodayCount(): Promise<number>;
onNoteUpdated(cb: (note: Note) => void): () => void;
}

View File

@@ -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);
});
});