feat(cue): IdentityCounter + tray refresh — 오늘 N번 잡아뒀다

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.
This commit is contained in:
altair823
2026-04-26 11:49:09 +09:00
parent cca3029b7e
commit bcd1151a24
8 changed files with 90 additions and 28 deletions

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

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