feat(promotion): store promotionCandidates + accept/snooze/dismiss + settings 영속화

- SettingsService: promotion_dismissed_tags / promotion_snoozed_until_ms / sidebar_visible / sidebar_width 스키마 + getter/setter 추가
- NotebookRepository: getDefault() (created_at ASC LIMIT 1) 헬퍼 추가
- inboxApi: notebookRepo 옵션 dep + 5개 IPC 핸들러 (list-promotion-candidates / get-dismissed-tags / add-dismissed-tag / get-snoozed-until / set-snoozed-until)
- shared/types: PromotionCandidate 인터페이스 + InboxApi 5개 메서드 추가
- preload: 5개 ipcRenderer.invoke 패스스루
- store: promotionCandidates 상태 + loadPromotionCandidates / acceptPromotion / snoozePromotion / dismissPromotion 액션 + toTitleCase helper
- tests: store.promotion.test.ts 신설 (6개 케이스)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:44:52 +09:00
parent 9dfca6edf2
commit 53a1579266
8 changed files with 320 additions and 3 deletions

View File

@@ -187,7 +187,9 @@ app.whenReady().then(async () => {
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir },
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
enqueue: (id) => worker.enqueue(id)
enqueue: (id) => worker.enqueue(id),
// v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
notebookRepo
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.

View File

@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
const { ipcMain, dialog, shell } = electron;
import { join, normalize, sep } from 'node:path';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { NotebookRepository } from '../repository/NotebookRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
@@ -29,6 +30,9 @@ export interface InboxIpcDeps {
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
enqueue?: (noteId: string) => Promise<void>;
// v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
// 미주입 시 list-promotion-candidates 는 빈 배열 반환 (graceful fallback).
notebookRepo?: NotebookRepository;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -280,6 +284,30 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
ipcMain.handle('inbox:list-promotion-candidates', () => {
if (!deps.notebookRepo) return [];
const defaultNb = deps.notebookRepo.getDefault();
if (!defaultNb) return [];
return deps.repo.findPromotionCandidates(defaultNb.id);
});
ipcMain.handle('inbox:get-promotion-dismissed-tags', () =>
deps.settings.getPromotionDismissedTags()
);
ipcMain.handle('inbox:add-promotion-dismissed-tag', (_e, tag: string) =>
deps.settings.addPromotionDismissedTag(tag)
);
ipcMain.handle('inbox:get-promotion-snoozed-until', () =>
deps.settings.getPromotionSnoozeUntil()
);
ipcMain.handle('inbox:set-promotion-snoozed-until', (_e, ms: number) =>
deps.settings.setPromotionSnoozeUntil(ms)
);
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });

View File

@@ -71,6 +71,20 @@ export class NotebookRepository {
return r ? this.hydrate(r) : null;
}
/**
* v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default.
* m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당.
*/
getDefault(): Notebook | null {
const r = this.db.prepare(
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb ORDER BY nb.created_at ASC LIMIT 1`
).get() as Record<string, unknown> | undefined;
return r ? this.hydrate(r) : null;
}
/** notes.notebook_id 갱신만 (status 등은 보존). */
moveNote(noteId: string, notebookId: string): void {
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)

View File

@@ -21,7 +21,12 @@ const SettingsSchema = z.object({
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
vision_cache_at: z.string().optional(),
// v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃.
promotion_dismissed_tags: z.array(z.string()).optional(),
promotion_snoozed_until_ms: z.number().int().optional(),
sidebar_visible: z.boolean().optional(),
sidebar_width: z.number().int().min(180).max(400).optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -155,6 +160,50 @@ export class SettingsService {
await this.persist(next);
}
// v0.4 Task 11 — promotion candidate 영속화.
async getPromotionDismissedTags(): Promise<string[]> {
const s = await this.load();
return s.promotion_dismissed_tags ?? [];
}
async addPromotionDismissedTag(tag: string): Promise<void> {
const s = await this.load();
const list = new Set(s.promotion_dismissed_tags ?? []);
list.add(tag);
await this.persist({ ...s, promotion_dismissed_tags: [...list] });
}
async getPromotionSnoozeUntil(): Promise<number> {
const s = await this.load();
return s.promotion_snoozed_until_ms ?? 0;
}
async setPromotionSnoozeUntil(ms: number): Promise<void> {
const s = await this.load();
await this.persist({ ...s, promotion_snoozed_until_ms: ms });
}
// v0.4 Task 15 — sidebar 레이아웃 영속화.
async getSidebarVisible(): Promise<boolean> {
const s = await this.load();
return s.sidebar_visible ?? false;
}
async setSidebarVisible(v: boolean): Promise<void> {
const s = await this.load();
await this.persist({ ...s, sidebar_visible: v });
}
async getSidebarWidth(): Promise<number> {
const s = await this.load();
return s.sidebar_width ?? 240;
}
async setSidebarWidth(v: number): Promise<void> {
const s = await this.load();
await this.persist({ ...s, sidebar_width: v });
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';

View File

@@ -105,6 +105,12 @@ const api: InklingApi = {
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
listPromotionCandidates: () => ipcRenderer.invoke('inbox:list-promotion-candidates'),
getPromotionDismissedTags: () => ipcRenderer.invoke('inbox:get-promotion-dismissed-tags'),
addPromotionDismissedTag: (tag: string) => ipcRenderer.invoke('inbox:add-promotion-dismissed-tag', tag),
getPromotionSnoozeUntil: () => ipcRenderer.invoke('inbox:get-promotion-snoozed-until'),
setPromotionSnoozeUntil: (ms: number) => ipcRenderer.invoke('inbox:set-promotion-snoozed-until', ms),
},
// v0.4 — notebook CRUD IPC
notebook: {

View File

@@ -1,8 +1,12 @@
import { create } from 'zustand';
import type { Note, Notebook, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import type { Note, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import { inboxApi, notebookApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
function toTitleCase(s: string): string {
return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
export { selectFilteredNotes } from './selectFilteredNotes.js';
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
@@ -50,6 +54,8 @@ interface InboxState {
selectedNotebookId: string | null;
sidebarVisible: boolean;
sidebarWidth: number;
// v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
promotionCandidates: PromotionCandidate[];
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
@@ -85,6 +91,11 @@ interface InboxState {
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
toggleSidebar: () => void;
// v0.4 Task 11 — promotion candidate actions.
loadPromotionCandidates: () => Promise<void>;
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
snoozePromotion: () => Promise<void>;
dismissPromotion: (tag: string) => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -119,6 +130,7 @@ export const useInbox = create<InboxState>((set, get) => ({
selectedNotebookId: null,
sidebarVisible: false,
sidebarWidth: 240,
promotionCandidates: [],
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
@@ -459,5 +471,53 @@ export const useInbox = create<InboxState>((set, get) => ({
},
toggleSidebar() {
set({ sidebarVisible: !get().sidebarVisible });
},
// v0.4 Task 11 — promotion candidate actions.
async loadPromotionCandidates() {
try {
const [dismissed, snoozeUntil, raw] = await Promise.all([
inboxApi.getPromotionDismissedTags(),
inboxApi.getPromotionSnoozeUntil(),
inboxApi.listPromotionCandidates()
]);
// snoozed_until > now → 빈 배열 (전체 스누즈).
if (snoozeUntil > Date.now()) {
set({ promotionCandidates: [] });
return;
}
const dismissedSet = new Set(dismissed);
const candidates: PromotionCandidate[] = raw
.filter((c) => !dismissedSet.has(c.tag))
.map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) }));
set({ promotionCandidates: candidates });
} catch (e) {
console.error('[inbox] loadPromotionCandidates failed', e);
set({ promotionCandidates: [] });
}
},
async acceptPromotion(tag, customName, color) {
const candidate = get().promotionCandidates.find((c) => c.tag === tag);
if (!candidate) return;
const r = await notebookApi.create({ name: customName, color });
if (!r.ok) return;
const notebookId = r.notebook.id;
await Promise.all(candidate.noteIds.map((noteId) => notebookApi.moveNote(noteId, notebookId)));
// state: candidate 제거 + notebooks 갱신 + sidebar 열기 + 새 notebook 선택.
set({
promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag),
notebooks: [...get().notebooks, r.notebook],
sidebarVisible: true,
selectedNotebookId: notebookId
});
await get().refreshMeta();
},
async snoozePromotion() {
const snoozeUntil = Date.now() + 24 * 60 * 60 * 1000;
await inboxApi.setPromotionSnoozeUntil(snoozeUntil);
set({ promotionCandidates: [] });
},
async dismissPromotion(tag) {
await inboxApi.addPromotionDismissedTag(tag);
set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
}
}));

View File

@@ -96,6 +96,14 @@ export interface Note {
notebookId: string;
}
// v0.4 Task 11 — tag 기반 notebook 승격 제안 후보.
// suggestedName 은 renderer 가 toTitleCase(tag) 로 채움 — IPC 응답에는 없음.
export interface PromotionCandidate {
tag: string;
noteIds: string[];
suggestedName: string;
}
// v0.4 — Notebook: 노트 묶음 단위. noteCount = status='active' 노트 수.
export interface Notebook {
id: string;
@@ -245,6 +253,12 @@ export interface InboxApi {
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
listPromotionCandidates(): Promise<PromotionCandidate[]>;
getPromotionDismissedTags(): Promise<string[]>;
addPromotionDismissedTag(tag: string): Promise<void>;
getPromotionSnoozeUntil(): Promise<number>;
setPromotionSnoozeUntil(ms: number): Promise<void>;
}
export interface NotebookApi {

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// promotion IPC
listPromotionCandidates: vi.fn(async () => []),
getPromotionDismissedTags: vi.fn(async () => []),
addPromotionDismissedTag: vi.fn(async () => undefined),
getPromotionSnoozeUntil: vi.fn(async () => 0),
setPromotionSnoozeUntil: vi.fn(async () => undefined),
// refreshMeta deps
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
getTrashCount: vi.fn(async () => 0),
listExpired: vi.fn(async () => []),
getFailedCount: vi.fn(async () => 0),
listRecallCandidate: vi.fn(async () => null),
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
getSettings: vi.fn(async () => ({ ai_enabled: true }))
},
notebookApi: {
list: vi.fn(async () => []),
create: vi.fn(async (i: { name: string; color?: string }) => ({
ok: true as const,
notebook: { id: 'nb-promo', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 }
})),
moveNote: vi.fn(async () => ({ ok: true as const })),
rename: vi.fn(async () => ({ ok: true as const })),
setColor: vi.fn(async () => ({ ok: true as const })),
delete: vi.fn(async () => ({ ok: true as const }))
}
}));
import { useInbox } from '../../src/renderer/inbox/store.js';
import { inboxApi } from '../../src/renderer/inbox/api.js';
import { notebookApi } from '../../src/renderer/inbox/api.js';
type MockInboxApi = {
listPromotionCandidates: ReturnType<typeof vi.fn>;
getPromotionDismissedTags: ReturnType<typeof vi.fn>;
addPromotionDismissedTag: ReturnType<typeof vi.fn>;
getPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
setPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
};
type MockNotebookApi = {
create: ReturnType<typeof vi.fn>;
moveNote: ReturnType<typeof vi.fn>;
};
const mockInbox = inboxApi as unknown as MockInboxApi;
const mockNotebook = notebookApi as unknown as MockNotebookApi;
describe('store promotion actions', () => {
beforeEach(() => {
vi.clearAllMocks();
useInbox.setState({ promotionCandidates: [], notebooks: [], sidebarVisible: false, selectedNotebookId: null } as never);
});
it('loadPromotionCandidates: 후보 목록 반환 + suggestedName toTitleCase 변환', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'machine-learning', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.suggestedName).toBe('Machine Learning');
expect(candidates[0]!.noteIds).toEqual(['n1', 'n2', 'n3']);
});
it('loadPromotionCandidates: snooze 유효 시 빈 배열', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
// 24h 후 만료되는 snooze
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(Date.now() + 24 * 60 * 60 * 1000);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
});
it('loadPromotionCandidates: dismissed tag 는 제외', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce(['work']);
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' },
{ tag: 'study', noteIds: ['n4', 'n5', 'n6'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.tag).toBe('study');
});
it('dismissPromotion: addPromotionDismissedTag 호출 + state 에서 그 tag 제거', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' },
{ tag: 'study', noteIds: ['n4', 'n5'], suggestedName: 'Study' }
]
} as never);
await useInbox.getState().dismissPromotion('work');
expect(mockInbox.addPromotionDismissedTag).toHaveBeenCalledWith('work');
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.tag).toBe('study');
});
it('snoozePromotion: setPromotionSnoozeUntil 24h 후로 호출 + state 비우기', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' }
]
} as never);
const before = Date.now();
await useInbox.getState().snoozePromotion();
const [[ms]] = mockInbox.setPromotionSnoozeUntil.mock.calls as [[number]];
expect(ms).toBeGreaterThan(before + 23 * 60 * 60 * 1000);
expect(ms).toBeLessThan(before + 25 * 60 * 60 * 1000);
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
});
it('acceptPromotion: notebook 생성 + moveNote 호출 + sidebar 열림 + selectedNotebookId 설정', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2'], suggestedName: 'Work' }
],
notebooks: []
} as never);
await useInbox.getState().acceptPromotion('work', 'Work', '#0a4b80');
expect(mockNotebook.create).toHaveBeenCalledWith({ name: 'Work', color: '#0a4b80' });
expect(mockNotebook.moveNote).toHaveBeenCalledTimes(2);
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n1', 'nb-promo');
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n2', 'nb-promo');
const state = useInbox.getState();
expect(state.sidebarVisible).toBe(true);
expect(state.selectedNotebookId).toBe('nb-promo');
expect(state.promotionCandidates.find((c) => c.tag === 'work')).toBeUndefined();
expect(state.notebooks.some((n) => n.id === 'nb-promo')).toBe(true);
});
});