diff --git a/README.md b/README.md index 63663bf..4eb56f7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.8.0(P5.2 写作日志 + AI 知识抽取) +长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0(P5.3 番茄钟/成就 + P6.1 知识注入历史) -## 功能概览(v0.8.0) +## 功能概览(v0.9.0) - 写作日志与热力图、连更统计、状态栏日更进度(P5.2) - AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2) diff --git a/docs/superpowers/plans/2026-07-07-bilin-p53-p61-goals-injection.md b/docs/superpowers/plans/2026-07-07-bilin-p53-p61-goals-injection.md index 1e37758..452c030 100644 --- a/docs/superpowers/plans/2026-07-07-bilin-p53-p61-goals-injection.md +++ b/docs/superpowers/plans/2026-07-07-bilin-p53-p61-goals-injection.md @@ -1,6 +1,6 @@ # 笔临 P5.3 + P6.1 写作目标 + 知识注入历史 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. **Goal:** 交付 v0.9.0:§8 番茄钟(15/25/45)、连更里程碑徽章(7/30/100)、日更/番茄/里程碑通知;P6.1 写作流三模式知识注入历史记录与知识库反向链接 UI。 @@ -64,7 +64,7 @@ - Modify: `src/main/services/global-settings.ts` - Modify: `src/renderer/stores/useSettingsStore.ts` -- [ ] **Step 1: 扩展 types** +- [x] **Step 1: 扩展 types** ```typescript export type PomodoroDuration = 15 | 25 | 45 @@ -115,7 +115,7 @@ enableGoalNotifications?: boolean dailyGoalNotifiedDate?: string // YYYY-MM-DD,防重复达标 Toast ``` -- [ ] **Step 2: IPC 通道** +- [x] **Step 2: IPC 通道** ```typescript POMODORO_START: 'pomodoro:start', @@ -129,7 +129,7 @@ POMODORO_TICK: 'pomodoro:tick', // main → renderer push GOAL_NOTIFICATION: 'goal:notification', // main → renderer push ``` -- [ ] **Step 3: global-settings 默认值** +- [x] **Step 3: global-settings 默认值** ```typescript pomodoroDurationMinutes: 25, @@ -137,9 +137,9 @@ enableSystemNotifications: false, enableGoalNotifications: true, ``` -- [ ] **Step 4: useSettingsStore 同步** +- [x] **Step 4: useSettingsStore 同步** -- [ ] **Step 5: build** +- [x] **Step 5: build** ```bash npm run build @@ -155,7 +155,7 @@ npm run build - Create: `src/main/db/repositories/pomodoro-daily.repo.ts` - Create: `tests/main/achievement.test.ts`(先写 milestone repo 部分) -- [ ] **Step 1: writing-log.repo migrate 扩展** +- [x] **Step 1: writing-log.repo migrate 扩展** 在 `migrate()` 追加: @@ -171,7 +171,7 @@ CREATE TABLE IF NOT EXISTS pomodoro_daily ( ); ``` -- [ ] **Step 2: WritingMilestoneRepository** +- [x] **Step 2: WritingMilestoneRepository** ```typescript export class WritingMilestoneRepository { @@ -182,7 +182,7 @@ export class WritingMilestoneRepository { } ``` -- [ ] **Step 3: PomodoroDailyRepository** +- [x] **Step 3: PomodoroDailyRepository** ```typescript recordComplete(bookId: string, wordDelta: number, date?: string): void { @@ -191,7 +191,7 @@ recordComplete(bookId: string, wordDelta: number, date?: string): void { getTodayCount(bookId: string): number ``` -- [ ] **Step 4: UT-ACH repo smoke** +- [x] **Step 4: UT-ACH repo smoke** ```typescript it('UT-ACH-01: unlock streak_7 once', () => { @@ -201,7 +201,7 @@ it('UT-ACH-01: unlock streak_7 once', () => { }) ``` -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01" @@ -218,9 +218,9 @@ npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01" - Create: `tests/main/migrate-v8.test.ts` - Create: `tests/main/knowledge-injection.test.ts` -- [ ] **Step 1: schema-v8.sql**(见 spec §3.2) +- [x] **Step 1: schema-v8.sql**(见 spec §3.2) -- [ ] **Step 2: migrate.ts** +- [x] **Step 2: migrate.ts** ```typescript import schemaV8 from './schema-v8.sql?raw' @@ -232,21 +232,21 @@ if (current < 8) { } ``` -- [ ] **Step 3: KnowledgeInjectionRepository** +- [x] **Step 3: KnowledgeInjectionRepository** ```typescript insertBatch(rows: Array>): void listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] ``` -- [ ] **Step 4: UT-MIG-08 + UT-INJ-01** +- [x] **Step 4: UT-MIG-08 + UT-INJ-01** ```typescript it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { /* ... */ }) it('UT-INJ-01: insertBatch creates 3 rows', () => { /* ... */ }) ``` -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.test.ts @@ -260,7 +260,7 @@ npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.tes - Create: `src/main/services/pomodoro.service.ts` - Create: `tests/main/pomodoro.test.ts` -- [ ] **Step 1: PomodoroService 实现** +- [x] **Step 1: PomodoroService 实现** ```typescript export class PomodoroService { @@ -304,14 +304,14 @@ export class PomodoroService { } ``` -- [ ] **Step 2: UT-POM-01 / UT-POM-02** +- [x] **Step 2: UT-POM-01 / UT-POM-02** ```typescript it('UT-POM-01: complete records word delta', () => { /* mock writingLogs */ }) it('UT-POM-02: start different book cancels previous', () => { /* ... */ }) ``` -- [ ] **Step 3: 运行** +- [x] **Step 3: 运行** ```bash npm run test -- tests/main/pomodoro.test.ts @@ -327,7 +327,7 @@ npm run test -- tests/main/pomodoro.test.ts - Modify: `src/main/services/writing-log.service.ts` - Modify: `tests/main/achievement.test.ts` -- [ ] **Step 1: GoalNotificationService** +- [x] **Step 1: GoalNotificationService** ```typescript export class GoalNotificationService { @@ -353,7 +353,7 @@ export class GoalNotificationService { } ``` -- [ ] **Step 2: AchievementService** +- [x] **Step 2: AchievementService** ```typescript checkMilestones(bookId: string): WritingAchievement[] { @@ -372,7 +372,7 @@ checkMilestones(bookId: string): WritingAchievement[] { } ``` -- [ ] **Step 3: WritingLogService.addToday 扩展** +- [x] **Step 3: WritingLogService.addToday 扩展** `addToday` 返回后: @@ -384,9 +384,9 @@ if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) { this.achievementService.checkMilestones(bookId) ``` -- [ ] **Step 4: UT-ACH-02 + IT-GOAL-01** +- [x] **Step 4: UT-ACH-02 + IT-GOAL-01** -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/achievement.test.ts @@ -403,7 +403,7 @@ npm run test -- tests/main/achievement.test.ts - Modify: `src/main/ipc/handlers/wizard.handler.ts` - Modify: `tests/main/knowledge-injection.test.ts` -- [ ] **Step 1: KnowledgeInjectionService** +- [x] **Step 1: KnowledgeInjectionService** ```typescript export class KnowledgeInjectionService { @@ -424,7 +424,7 @@ export class KnowledgeInjectionService { } ``` -- [ ] **Step 2: interactive.handler 挂钩** +- [x] **Step 2: interactive.handler 挂钩** 在 `INTERACTIVE_CONFIRM_CONTEXT` 的 `buildFlowContextPayload` 成功后: @@ -441,13 +441,13 @@ try { } ``` -- [ ] **Step 3: auto / wizard 同理** +- [x] **Step 3: auto / wizard 同理** -- [ ] **Step 4: IT-INJ-01** +- [x] **Step 4: IT-INJ-01** 直接调用 handler 或 service + 内存 DB,断言 `knowledge_injection_logs` 行数。 -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/knowledge-injection.test.ts @@ -466,9 +466,9 @@ npm run test -- tests/main/knowledge-injection.test.ts - Modify: `src/preload/index.ts` - Modify: `src/shared/electron-api.d.ts` -- [ ] **Step 1: pomodoro.handler + achievement.handler** +- [x] **Step 1: pomodoro.handler + achievement.handler** -- [ ] **Step 2: knowledge.handler** +- [x] **Step 2: knowledge.handler** ```typescript ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) => @@ -476,7 +476,7 @@ ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) = ) ``` -- [ ] **Step 3: register.ts 注入** +- [x] **Step 3: register.ts 注入** ```typescript const goalNotify = new GoalNotificationService(settings, () => getMainWindow()) @@ -487,7 +487,7 @@ registerPomodoroHandlers(pomodoro) registerAchievementHandlers(achievementService) ``` -- [ ] **Step 4: preload** +- [x] **Step 4: preload** ```typescript pomodoro: { @@ -501,14 +501,14 @@ onPomodoroTick: (cb) => { ipcRenderer.on(IPC.POMODORO_TICK, cb); return () => .. onGoalNotification: (cb) => { ipcRenderer.on(IPC.GOAL_NOTIFICATION, cb); return () => ... }, ``` -- [ ] **Step 5: CockpitService 扩展** +- [x] **Step 5: CockpitService 扩展** ```typescript achievements: this.achievementService.listAchievements(bookId), pomodoroTodayCount: this.pomodoroDaily.getTodayCount(bookId), ``` -- [ ] **Step 6: build** +- [x] **Step 6: build** ```bash npm run build @@ -524,11 +524,11 @@ npm run build - Modify: `src/renderer/App.tsx`(注册 onGoalNotification 全局 Toast) - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: usePomodoroStore** +- [x] **Step 1: usePomodoroStore** 订阅 `onPomodoroTick`;actions:`start/pause/resume/cancel`。 -- [ ] **Step 2: EditorLayout 状态栏控件** +- [x] **Step 2: EditorLayout 状态栏控件** ```tsx
@@ -544,7 +544,7 @@ npm run build
``` -- [ ] **Step 3: App.tsx goal notification** +- [x] **Step 3: App.tsx goal notification** ```typescript useEffect(() => { @@ -554,7 +554,7 @@ useEffect(() => { }, []) ``` -- [ ] **Step 4: build 验证** +- [x] **Step 4: build 验证** ```bash npm run build @@ -570,7 +570,7 @@ npm run build - Modify: `public/locales/zh-CN/translation.json` - Modify: `public/locales/en/translation.json` -- [ ] **Step 1: CockpitModal 成就区** +- [x] **Step 1: CockpitModal 成就区** ```tsx {summary.achievements && summary.achievements.length > 0 && ( @@ -581,13 +581,13 @@ npm run build ``` -- [ ] **Step 2: SettingsPage 三项设置** +- [x] **Step 2: SettingsPage 三项设置** `settings-pomodoro-duration` / `settings-system-notifications` / `settings-goal-notifications` -- [ ] **Step 3: i18n**(spec §7 全部键,中英) +- [x] **Step 3: i18n**(spec §7 全部键,中英) -- [ ] **Step 4: build** +- [x] **Step 4: build** ```bash npm run build @@ -602,7 +602,7 @@ npm run build - Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` - Modify: `src/renderer/stores/useKnowledgeStore.ts` -- [ ] **Step 1: KnowledgePanel 加载注入预览** +- [x] **Step 1: KnowledgePanel 加载注入预览** 条目 render 时调用 `knowledge.listInjections(bookId, entry.id, 3)`(或 batch 加载 map)。 @@ -614,11 +614,11 @@ npm run build ``` -- [ ] **Step 2: KnowledgeEditorDialog 完整历史** +- [x] **Step 2: KnowledgeEditorDialog 完整历史** `data-testid="knowledge-injection-history"`;章节名点击 `switchTarget({ kind:'chapter', id })`。 -- [ ] **Step 3: build** +- [x] **Step 3: build** ```bash npm run build @@ -635,17 +635,17 @@ npm run build - Modify: `README.md` - Modify: `tests/main/migrate-v7.test.ts` 等(若 CURRENT_VERSION→8,期望版本改为 8) -- [ ] **Step 1: E2E-GOAL-01~03** +- [x] **Step 1: E2E-GOAL-01~03** - E2E-GOAL-01:设 dailyWordGoal=100,打字保存,断言 toast / status-daily-goal - E2E-GOAL-02:通过测试 hook 或 seed API 写入 streak_7 milestone,打开驾驶舱断言 `cockpit-achievements` - E2E-GOAL-03:`POMODORO_TEST_SEC=3`,启动番茄,等待完成 Toast -- [ ] **Step 2: E2E-INJ-01** +- [x] **Step 2: E2E-INJ-01** 向导 confirm 知识上下文(可 stub 空 AI),断言 `knowledge-injection-preview-*` 可见。 -- [ ] **Step 3: 全量测试** +- [x] **Step 3: 全量测试** ```bash npm run build @@ -653,11 +653,11 @@ npm run test npm run test:e2e -- e2e/writing-goals.spec.ts e2e/knowledge-injection-history.spec.ts ``` -- [ ] **Step 4: 版本 0.9.0** +- [x] **Step 4: 版本 0.9.0** `package.json`、`README.md`、`SettingsPage` about 版本号。 -- [ ] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理) +- [x] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理) --- diff --git a/e2e/knowledge-injection-history.spec.ts b/e2e/knowledge-injection-history.spec.ts new file mode 100644 index 0000000..4117a2d --- /dev/null +++ b/e2e/knowledge-injection-history.spec.ts @@ -0,0 +1,108 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function openBookWithContent(page: Page): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill('注入历史书') + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + } + await page.getByRole('button', { name: '+ 新章' }).click() + const editor = page.locator('.ProseMirror') + await expect(editor).toBeVisible({ timeout: 10_000 }) + await editor.click() + await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。') + await page.keyboard.press('Control+s') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 }) +} + +async function addApprovedKnowledge(page: Page, title: string): Promise { + await page.locator('[data-testid="panel-tab-knowledge"]').click() + await page.locator('[data-testid="knowledge-new"]').click() + await page.locator('[data-testid="knowledge-title-input"]').fill(title) + await page.locator('[data-testid="knowledge-save"]').click() + await page.locator('[data-testid="knowledge-tab-pending"]').click() + const approveBtn = page.locator('[data-testid^="knowledge-approve-"]').first() + const testId = await approveBtn.getAttribute('data-testid') + await approveBtn.click() + const entryId = testId?.replace('knowledge-approve-', '') ?? '' + return entryId +} + +async function openWizardToContext(page: Page): Promise { + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 }) + await page.getByTestId('ai-mode-tab-wizard').click() + await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 }) + await page.getByTestId('wizard-goal-input').fill('林远通过入门考核') + await page.getByRole('button', { name: '下一步' }).click() + await page.getByTestId('wizard-rhythm-mixed').click() + await page.getByRole('button', { name: '下一步' }).click() + await page.getByRole('button', { name: '下一步' }).click() + await expect(page.getByTestId('wizard-context-edit')).toBeVisible({ timeout: 10_000 }) +} + +test.describe('Knowledge injection history P6.1', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-inj-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-INJ-01: wizard confirmContext shows injection preview', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await openBookWithContent(page) + const entryId = await addApprovedKnowledge(page, '测灵石异常') + expect(entryId).toBeTruthy() + + await openWizardToContext(page) + await page.getByTestId('wizard-context-edit').click() + await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 }) + await page.getByTestId('context-tab-knowledge').click() + await page.locator(`[data-testid="context-knowledge-item-${entryId}"] input`).check() + await page.getByTestId('context-save').click() + await page.getByRole('button', { name: '下一步' }).click() + + await page.locator('[data-testid="panel-tab-knowledge"]').click() + await page.locator('[data-testid="knowledge-tab-all"]').click() + await expect(page.locator(`[data-testid="knowledge-injection-preview-${entryId}"]`)).toBeVisible({ + timeout: 10_000 + }) + await app.close() + }) +}) diff --git a/e2e/writing-goals.spec.ts b/e2e/writing-goals.spec.ts new file mode 100644 index 0000000..8827f05 --- /dev/null +++ b/e2e/writing-goals.spec.ts @@ -0,0 +1,152 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { DatabaseSync } from 'node:sqlite' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string, extraEnv: Record = {}) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir, + ...extraEnv + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) + } +} + +async function dismissCockpitIfOpen(page: Page): Promise { + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + await expect(cockpit).toBeHidden({ timeout: 5000 }) + } +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) +} + +async function ensureChapterEditor(page: Page): Promise { + const items = page.locator('.chapter-item') + if ((await items.count()) === 0) { + await page.getByRole('button', { name: '+ 新章' }).click() + } else { + await items.first().click() + } + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) +} + +function seedMilestone(userDataDir: string, bookId: string, milestone: string): void { + const dbPath = join(userDataDir, 'writing_logs.sqlite') + const db = new DatabaseSync(dbPath) + db.prepare('INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)').run( + bookId, + milestone + ) + db.close() +} + +test.describe('Writing goals P5.3', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-goal-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-GOAL-01: daily goal met shows progress and toast', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '目标测试书') + await ensureChapterEditor(page) + + await page.getByRole('button', { name: '设置' }).click() + await page.locator('[data-testid="settings-daily-word-goal"]').fill('100') + await page.getByRole('button', { name: '书籍' }).click() + await ensureChapterEditor(page) + + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially( + '这是一段用于测试每日写作目标达标的示例文字,需要足够长度才能触发字数统计与达标提醒通知。' + ) + await page.waitForTimeout(1500) + + await expect(page.locator('[data-testid="status-daily-goal"].goal-met')).toBeVisible({ + timeout: 15_000 + }) + await expect(page.locator('.toast')).toContainText(/目标|goal/i, { timeout: 10_000 }) + await app.close() + }) + + test('E2E-GOAL-02: cockpit shows unlocked achievement badge', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '成就测试书') + const bookId = await page.evaluate(async () => { + const res = await window.electronAPI.book.list() + return res.ok ? res.data[0]?.id : null + }) + expect(bookId).toBeTruthy() + await app.close() + + seedMilestone(userDataDir, bookId!, 'streak_7') + + const app2 = await launchApp(userDataDir) + const page2 = await app2.firstWindow() + await skipOnboarding(page2) + await page2.locator('.book-card').filter({ hasText: '成就测试书' }).click() + await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page2) + await page2.getByTestId('topbar-cockpit').click() + await expect(page2.locator('[data-testid="cockpit-modal"]')).toBeVisible({ timeout: 10_000 }) + const badge = page2.locator('[data-testid="cockpit-achievements"] .cockpit-achievement-badge.unlocked') + await expect(badge).toHaveCount(1, { timeout: 10_000 }) + await app2.close() + }) + + test('E2E-GOAL-03: pomodoro completes with toast', async () => { + const app = await launchApp(userDataDir, { POMODORO_TEST_SEC: '3' }) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '番茄测试书') + await ensureChapterEditor(page) + + await page.locator('[data-testid="pomodoro-control"] button').first().click() + await expect(page.locator('.pomodoro-time')).toBeVisible() + await page.waitForTimeout(4500) + await expect(page.locator('.toast')).toContainText(/番茄|Pomodoro|完成|complete/i, { + timeout: 10_000 + }) + await app.close() + }) +}) diff --git a/package.json b/package.json index 73e964e..6a27a7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.8.0", + "version": "0.9.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 7a5ed1e..5a8c187 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -224,6 +224,28 @@ "cockpit.todayProgress": "Today {{current}} / {{goal}} words", "cockpit.streak": "{{days}}-day streak", "cockpit.heatmap": "Writing heatmap", + "cockpit.achievements": "Writing achievements", + "cockpit.pomodoroToday": "{{count}} pomodoros today", + "pomodoro.start": "Start pomodoro", + "pomodoro.pause": "Pause", + "pomodoro.resume": "Resume", + "pomodoro.cancel": "Cancel", + "pomodoro.complete": "Pomodoro complete! {{words}} words written", + "pomodoro.switchedBook": "Book switched — pomodoro cancelled", + "goal.dailyMet": "Daily goal met: {{current}} / {{goal}} words", + "achievement.streak7": "7-day streak", + "achievement.streak30": "30-day streak", + "achievement.streak100": "100-day streak", + "achievement.unlocked": "Achievement unlocked: {{name}}", + "knowledge.injectionHistory": "Injection history", + "knowledge.injectionPreview": "Recent injections", + "knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}", + "knowledge.injectionMode.interactive": "Interactive", + "knowledge.injectionMode.auto": "Auto writing", + "knowledge.injectionMode.wizard": "Wizard", + "settings.pomodoroDuration": "Pomodoro duration (minutes)", + "settings.systemNotifications": "Enable system notifications", + "settings.goalNotifications": "Enable goal notifications", "bridge.title": "Chapter bridge", "bridge.loading": "Analyzing…", "bridge.previousTail": "Previous chapter ending", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 64f56cb..0ef8570 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -224,6 +224,28 @@ "cockpit.todayProgress": "今日 {{current}} / {{goal}} 字", "cockpit.streak": "连更 {{days}} 天", "cockpit.heatmap": "写作热力图", + "cockpit.achievements": "写作成就", + "cockpit.pomodoroToday": "今日番茄 {{count}} 次", + "pomodoro.start": "开始番茄钟", + "pomodoro.pause": "暂停", + "pomodoro.resume": "继续", + "pomodoro.cancel": "取消", + "pomodoro.complete": "番茄钟完成!本次写作 {{words}} 字", + "pomodoro.switchedBook": "已切换书籍,番茄钟已取消", + "goal.dailyMet": "今日目标已达成 {{current}} / {{goal}} 字", + "achievement.streak7": "连更 7 天", + "achievement.streak30": "连更 30 天", + "achievement.streak100": "连更 100 天", + "achievement.unlocked": "解锁成就:{{name}}", + "knowledge.injectionHistory": "注入历史", + "knowledge.injectionPreview": "最近注入", + "knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}", + "knowledge.injectionMode.interactive": "交互写作", + "knowledge.injectionMode.auto": "自动续写", + "knowledge.injectionMode.wizard": "向导模式", + "settings.pomodoroDuration": "番茄钟时长(分钟)", + "settings.systemNotifications": "启用系统通知", + "settings.goalNotifications": "启用目标达成提醒", "bridge.title": "章间衔接", "bridge.loading": "分析中…", "bridge.previousTail": "上一章末尾", diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index da25bf1..9858642 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -6,8 +6,9 @@ import schemaV4 from './schema-v4.sql?raw' import schemaV5 from './schema-v5.sql?raw' import schemaV6 from './schema-v6.sql?raw' import schemaV7 from './schema-v7.sql?raw' +import schemaV8 from './schema-v8.sql?raw' -const CURRENT_VERSION = 7 +const CURRENT_VERSION = 8 function tryAlter(db: SqliteDb, sql: string): void { try { @@ -81,5 +82,11 @@ export function migrate(db: SqliteDb): void { tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT') tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_id TEXT') db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(7, 'P5.2 writing logs extraction') + current = 7 + } + + if (current < 8) { + db.exec(schemaV8) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection') } } diff --git a/src/main/db/repositories/knowledge-injection.repo.ts b/src/main/db/repositories/knowledge-injection.repo.ts new file mode 100644 index 0000000..1aa9b76 --- /dev/null +++ b/src/main/db/repositories/knowledge-injection.repo.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'crypto' +import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): KnowledgeInjectionLog { + return { + id: row.id as string, + knowledgeEntryId: row.knowledge_entry_id as string, + flowMode: row.flow_mode as InjectionFlowMode, + flowId: row.flow_id as string, + chapterId: (row.chapter_id as string) || undefined, + injectedAt: row.injected_at as string + } +} + +export class KnowledgeInjectionRepository { + constructor(private db: SqliteDb) {} + + insertBatch( + rows: Array<{ + knowledgeEntryId: string + flowMode: InjectionFlowMode + flowId: string + chapterId?: string + }> + ): void { + const stmt = this.db.prepare( + `INSERT INTO knowledge_injection_logs + (id, knowledge_entry_id, flow_mode, flow_id, chapter_id) + VALUES (?, ?, ?, ?, ?)` + ) + for (const row of rows) { + stmt.run( + randomUUID(), + row.knowledgeEntryId, + row.flowMode, + row.flowId, + row.chapterId ?? null + ) + } + } + + listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] { + const rows = this.db + .prepare( + `SELECT * FROM knowledge_injection_logs + WHERE knowledge_entry_id = ? + ORDER BY injected_at DESC LIMIT ?` + ) + .all(entryId, limit) as Record[] + return rows.map(mapRow) + } +} diff --git a/src/main/db/repositories/pomodoro-daily.repo.ts b/src/main/db/repositories/pomodoro-daily.repo.ts new file mode 100644 index 0000000..097dbd8 --- /dev/null +++ b/src/main/db/repositories/pomodoro-daily.repo.ts @@ -0,0 +1,25 @@ +import type { DatabaseSync } from 'node:sqlite' +import { localDateString } from '../../services/writing-date.util' + +export class PomodoroDailyRepository { + constructor(private db: DatabaseSync) {} + + recordComplete(bookId: string, wordDelta: number, date = localDateString()): void { + this.db + .prepare( + `INSERT INTO pomodoro_daily (date, book_id, completed_count, total_words) + VALUES (?, ?, 1, ?) + ON CONFLICT(date, book_id) DO UPDATE SET + completed_count = completed_count + 1, + total_words = total_words + excluded.total_words` + ) + .run(date, bookId, wordDelta) + } + + getTodayCount(bookId: string, date = localDateString()): number { + const row = this.db + .prepare('SELECT completed_count FROM pomodoro_daily WHERE date = ? AND book_id = ?') + .get(date, bookId) as { completed_count: number } | undefined + return row?.completed_count ?? 0 + } +} diff --git a/src/main/db/repositories/writing-log.repo.ts b/src/main/db/repositories/writing-log.repo.ts index 8c7069a..3fd9339 100644 --- a/src/main/db/repositories/writing-log.repo.ts +++ b/src/main/db/repositories/writing-log.repo.ts @@ -20,10 +20,27 @@ export class WritingLogRepository { book_id TEXT NOT NULL, word_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (date, book_id) + ); + CREATE TABLE IF NOT EXISTS writing_milestones ( + book_id TEXT NOT NULL, + milestone TEXT NOT NULL, + unlocked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (book_id, milestone) + ); + CREATE TABLE IF NOT EXISTS pomodoro_daily ( + date TEXT NOT NULL, + book_id TEXT NOT NULL, + completed_count INTEGER NOT NULL DEFAULT 0, + total_words INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, book_id) ) `) } + getDb(): DatabaseSync { + return this.db + } + addToday(bookId: string, delta: number, date = localDateString()): number { const row = this.db .prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?') diff --git a/src/main/db/repositories/writing-milestone.repo.ts b/src/main/db/repositories/writing-milestone.repo.ts new file mode 100644 index 0000000..7a11da9 --- /dev/null +++ b/src/main/db/repositories/writing-milestone.repo.ts @@ -0,0 +1,37 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { WritingAchievement, WritingMilestone } from '../../../shared/types' + +const ORDER: WritingMilestone[] = ['streak_7', 'streak_30', 'streak_100'] + +export class WritingMilestoneRepository { + constructor(private db: DatabaseSync) {} + + list(bookId: string): WritingAchievement[] { + const rows = this.db + .prepare('SELECT milestone, unlocked_at FROM writing_milestones WHERE book_id = ?') + .all(bookId) as Array<{ milestone: string; unlocked_at: string }> + return rows + .map((r) => ({ milestone: r.milestone as WritingMilestone, unlockedAt: r.unlocked_at })) + .sort((a, b) => ORDER.indexOf(a.milestone) - ORDER.indexOf(b.milestone)) + } + + has(bookId: string, milestone: WritingMilestone): boolean { + const row = this.db + .prepare('SELECT 1 FROM writing_milestones WHERE book_id = ? AND milestone = ?') + .get(bookId, milestone) + return row != null + } + + unlock(bookId: string, milestone: WritingMilestone): WritingAchievement | null { + if (this.has(bookId, milestone)) return null + this.db + .prepare( + `INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)` + ) + .run(bookId, milestone) + const row = this.db + .prepare('SELECT unlocked_at FROM writing_milestones WHERE book_id = ? AND milestone = ?') + .get(bookId, milestone) as { unlocked_at: string } + return { milestone, unlockedAt: row.unlocked_at } + } +} diff --git a/src/main/db/schema-v8.sql b/src/main/db/schema-v8.sql new file mode 100644 index 0000000..c763f95 --- /dev/null +++ b/src/main/db/schema-v8.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS knowledge_injection_logs ( + id TEXT PRIMARY KEY, + knowledge_entry_id TEXT NOT NULL, + flow_mode TEXT NOT NULL, + flow_id TEXT NOT NULL, + chapter_id TEXT, + injected_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (knowledge_entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_injection_entry_time + ON knowledge_injection_logs (knowledge_entry_id, injected_at DESC); diff --git a/src/main/ipc/handlers/achievement.handler.ts b/src/main/ipc/handlers/achievement.handler.ts new file mode 100644 index 0000000..9d4dee0 --- /dev/null +++ b/src/main/ipc/handlers/achievement.handler.ts @@ -0,0 +1,10 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { AchievementService } from '../../services/achievement.service' +import { wrap } from '../result' + +export function registerAchievementHandlers(achievement: AchievementService): void { + ipcMain.handle(IPC.ACHIEVEMENT_LIST, (_e, { bookId }: { bookId: string }) => + wrap(() => achievement.listAchievements(bookId)) + ) +} diff --git a/src/main/ipc/handlers/auto.handler.ts b/src/main/ipc/handlers/auto.handler.ts index fb683e7..49baac6 100644 --- a/src/main/ipc/handlers/auto.handler.ts +++ b/src/main/ipc/handlers/auto.handler.ts @@ -9,6 +9,7 @@ import { checkInteractiveGate } from '../../services/interactive-gate.service' import { AutoWritingService } from '../../services/auto-writing.service' import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner' import { trackerFor } from '../../services/chapter-writing-tracker.service' +import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner' import type { WritingLogService } from '../../services/writing-log.service' import { InteractiveRepository } from '../../db/repositories/interactive.repo' import { wrap } from '../result' @@ -89,6 +90,12 @@ export function registerAutoHandlers( bookId, settings.get().penName ) + recordKnowledgeInjection(registry, bookId, { + knowledgeIds: binding.knowledgeIds, + flowMode: 'auto', + flowId, + chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined + }) return svc.enrichFlow(svc.confirmContext(flowId, payload)) }) ) diff --git a/src/main/ipc/handlers/cockpit.handler.ts b/src/main/ipc/handlers/cockpit.handler.ts index 4ccac24..ec59c68 100644 --- a/src/main/ipc/handlers/cockpit.handler.ts +++ b/src/main/ipc/handlers/cockpit.handler.ts @@ -3,19 +3,29 @@ import { IPC } from '../../../shared/ipc-channels' import { BookRegistryService } from '../../services/book-registry' import { CockpitService } from '../../services/cockpit.service' import type { GlobalSettingsService } from '../../services/global-settings' +import type { AchievementService } from '../../services/achievement.service' +import type { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo' import type { WritingLogService } from '../../services/writing-log.service' import { wrap } from '../result' export function registerCockpitHandlers( registry: BookRegistryService, settings: GlobalSettingsService, - writingLogs: WritingLogService + writingLogs: WritingLogService, + achievementService: AchievementService, + pomodoroDaily: PomodoroDailyRepository ): void { ipcMain.handle( IPC.COCKPIT_SUMMARY, (_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) => wrap(() => - new CockpitService(registry.getDb(bookId), settings, writingLogs).getSummary(volumeId, bookId) + new CockpitService( + registry.getDb(bookId), + settings, + writingLogs, + achievementService, + pomodoroDaily + ).getSummary(volumeId, bookId) ) ) diff --git a/src/main/ipc/handlers/interactive.handler.ts b/src/main/ipc/handlers/interactive.handler.ts index 1ee28cc..4f33eb6 100644 --- a/src/main/ipc/handlers/interactive.handler.ts +++ b/src/main/ipc/handlers/interactive.handler.ts @@ -9,6 +9,7 @@ import { checkInteractiveGate } from '../../services/interactive-gate.service' import { InteractiveWritingService } from '../../services/interactive-writing.service' import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner' import { trackerFor } from '../../services/chapter-writing-tracker.service' +import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner' import type { WritingLogService } from '../../services/writing-log.service' import { wrap } from '../result' @@ -66,6 +67,12 @@ export function registerInteractiveHandlers( bookId, settings.get().penName ) + recordKnowledgeInjection(registry, bookId, { + knowledgeIds: binding.knowledgeIds, + flowMode: 'interactive', + flowId, + chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined + }) const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload) return serviceFor(registry, bookId, aiClient).enrichFlow(flow) }) diff --git a/src/main/ipc/handlers/knowledge.handler.ts b/src/main/ipc/handlers/knowledge.handler.ts index d7510d6..c16b946 100644 --- a/src/main/ipc/handlers/knowledge.handler.ts +++ b/src/main/ipc/handlers/knowledge.handler.ts @@ -11,6 +11,7 @@ import type { AiClientService } from '../../services/ai-client.service' import type { GlobalSettingsService } from '../../services/global-settings' import { KnowledgeRepository } from '../../db/repositories/knowledge.repo' import { KnowledgeExtractionService } from '../../services/knowledge-extraction.service' +import { KnowledgeInjectionService } from '../../services/knowledge-injection.service' import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service' import { KnowledgeService } from '../../services/knowledge.service' import { wrap } from '../result' @@ -120,4 +121,15 @@ export function registerKnowledgeHandlers( return new KnowledgeService(registry.getDb(bookId)).batchApproveHighConfidence(t) }) ) + + ipcMain.handle( + IPC.KNOWLEDGE_LIST_INJECTIONS, + ( + _e, + { bookId, entryId, limit }: { bookId: string; entryId: string; limit?: number } + ) => + wrap(() => + new KnowledgeInjectionService(registry.getDb(bookId)).listForEntry(entryId, limit ?? 50) + ) + ) } diff --git a/src/main/ipc/handlers/pomodoro.handler.ts b/src/main/ipc/handlers/pomodoro.handler.ts new file mode 100644 index 0000000..7e20170 --- /dev/null +++ b/src/main/ipc/handlers/pomodoro.handler.ts @@ -0,0 +1,14 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { PomodoroService } from '../../services/pomodoro.service' +import { wrap } from '../result' + +export function registerPomodoroHandlers(pomodoro: PomodoroService): void { + ipcMain.handle(IPC.POMODORO_START, (_e, { bookId }: { bookId: string }) => + wrap(() => pomodoro.start(bookId)) + ) + ipcMain.handle(IPC.POMODORO_PAUSE, () => wrap(() => pomodoro.pause())) + ipcMain.handle(IPC.POMODORO_RESUME, () => wrap(() => pomodoro.resume())) + ipcMain.handle(IPC.POMODORO_CANCEL, () => wrap(() => pomodoro.cancel())) + ipcMain.handle(IPC.POMODORO_GET_STATE, () => wrap(() => pomodoro.getState())) +} diff --git a/src/main/ipc/handlers/wizard.handler.ts b/src/main/ipc/handlers/wizard.handler.ts index ae10535..748f9d5 100644 --- a/src/main/ipc/handlers/wizard.handler.ts +++ b/src/main/ipc/handlers/wizard.handler.ts @@ -6,6 +6,7 @@ import { GlobalSettingsService } from '../../services/global-settings' import { AiClientService } from '../../services/ai-client.service' import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { WizardWritingService } from '../../services/wizard-writing.service' +import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner' import { InteractiveRepository } from '../../db/repositories/interactive.repo' import { wrap } from '../result' @@ -99,6 +100,12 @@ export function registerWizardHandlers( bookId, settings.get().penName ) + recordKnowledgeInjection(registry, bookId, { + knowledgeIds: binding.knowledgeIds, + flowMode: 'wizard', + flowId, + chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined + }) const svc = serviceFor(registry, bookId, aiClient) return svc.enrichFlow(svc.confirmContext(flowId, payload)) }) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 572db61..6154a01 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -1,11 +1,15 @@ -import { join } from 'path' import { app } from 'electron' import { GlobalSettingsService } from '../services/global-settings' import { BookRegistryService } from '../services/book-registry' import { SnapshotService } from '../services/snapshot.service' import { ShortcutManager } from '../shortcuts/manager' import { WritingLogRepository } from '../db/repositories/writing-log.repo' +import { WritingMilestoneRepository } from '../db/repositories/writing-milestone.repo' +import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo' import { WritingLogService } from '../services/writing-log.service' +import { GoalNotificationService } from '../services/goal-notification.service' +import { AchievementService } from '../services/achievement.service' +import { PomodoroService } from '../services/pomodoro.service' import { registerSettingsHandlers } from './handlers/settings.handler' import { registerBookHandlers } from './handlers/book.handler' import { registerChapterHandlers } from './handlers/chapter.handler' @@ -23,6 +27,8 @@ import { registerWizardHandlers } from './handlers/wizard.handler' import { registerKnowledgeHandlers } from './handlers/knowledge.handler' import { registerCockpitHandlers } from './handlers/cockpit.handler' import { registerWritingHandlers } from './handlers/writing.handler' +import { registerPomodoroHandlers } from './handlers/pomodoro.handler' +import { registerAchievementHandlers } from './handlers/achievement.handler' import { registerBridgeHandlers } from './handlers/bridge.handler' import { AiClientService } from '../services/ai-client.service' import { NetworkMonitorService } from '../services/network-monitor.service' @@ -36,7 +42,20 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg const settings = new GlobalSettingsService(userData) const books = new BookRegistryService(userData) - const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings) + const writingLogRepo = new WritingLogRepository(userData) + const writingLogs = new WritingLogService(writingLogRepo, settings) + const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb()) + const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb()) + const goalNotify = new GoalNotificationService(settings) + const achievementService = new AchievementService( + milestoneRepo, + writingLogs, + settings, + goalNotify + ) + writingLogs.setGoalHooks(achievementService, goalNotify) + const pomodoro = new PomodoroService(writingLogs, pomodoroDailyRepo, settings, goalNotify) + shortcutManager = new ShortcutManager(settings) snapshotService = new SnapshotService(() => settings.get()) networkMonitor = new NetworkMonitorService() @@ -57,8 +76,10 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerAutoHandlers(books, settings, aiClient, writingLogs) registerWizardHandlers(books, settings, aiClient) registerKnowledgeHandlers(books, settings, aiClient) - registerCockpitHandlers(books, settings, writingLogs) + registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo) registerWritingHandlers(writingLogs) + registerPomodoroHandlers(pomodoro) + registerAchievementHandlers(achievementService) registerBridgeHandlers(books, aiClient) return { settings, books, shortcuts: shortcutManager } diff --git a/src/main/services/achievement.service.ts b/src/main/services/achievement.service.ts new file mode 100644 index 0000000..50c14ba --- /dev/null +++ b/src/main/services/achievement.service.ts @@ -0,0 +1,41 @@ +import type { WritingAchievement, WritingMilestone } from '../../shared/types' +import { WritingMilestoneRepository } from '../db/repositories/writing-milestone.repo' +import type { GoalNotificationService } from './goal-notification.service' +import type { GlobalSettingsService } from './global-settings' +import type { WritingLogService } from './writing-log.service' + +const THRESHOLDS: Array<{ days: number; milestone: WritingMilestone }> = [ + { days: 7, milestone: 'streak_7' }, + { days: 30, milestone: 'streak_30' }, + { days: 100, milestone: 'streak_100' } +] + +export class AchievementService { + constructor( + private milestoneRepo: WritingMilestoneRepository, + private writingLogs: WritingLogService, + private settings: GlobalSettingsService, + private notify: GoalNotificationService + ) {} + + listAchievements(bookId: string): WritingAchievement[] { + return this.milestoneRepo.list(bookId) + } + + checkMilestones(bookId: string): WritingAchievement[] { + const { dailyWordGoal } = this.settings.get() + if (dailyWordGoal <= 0) return [] + const streak = this.writingLogs.getStats(bookId).streakDays + const unlocked: WritingAchievement[] = [] + for (const { days, milestone } of THRESHOLDS) { + if (streak >= days) { + const a = this.milestoneRepo.unlock(bookId, milestone) + if (a) { + this.notify.notifyMilestone(milestone) + unlocked.push(a) + } + } + } + return unlocked + } +} diff --git a/src/main/services/cockpit.service.ts b/src/main/services/cockpit.service.ts index 084c17c..fcd936e 100644 --- a/src/main/services/cockpit.service.ts +++ b/src/main/services/cockpit.service.ts @@ -4,6 +4,8 @@ import { BookPrefsRepository } from '../db/repositories/book-prefs.repo' import { ChapterRepository } from '../db/repositories/chapter.repo' import type { GlobalSettingsService } from './global-settings' import { KnowledgeService } from './knowledge.service' +import type { AchievementService } from './achievement.service' +import type { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo' import type { WritingLogService } from './writing-log.service' import { stripHtml } from './word-count' @@ -11,7 +13,9 @@ export class CockpitService { constructor( private db: SqliteDb, private settings: GlobalSettingsService, - private writingLogs?: WritingLogService + private writingLogs?: WritingLogService, + private achievementService?: AchievementService, + private pomodoroDaily?: PomodoroDailyRepository ) {} getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary { @@ -27,6 +31,12 @@ export class CockpitService { const { stockBufferThreshold } = this.settings.get() const writingStats = bookId && this.writingLogs ? this.writingLogs.getStats(bookId) : undefined + const achievements = + bookId && this.achievementService + ? this.achievementService.listAchievements(bookId) + : undefined + const pomodoroTodayCount = + bookId && this.pomodoroDaily ? this.pomodoroDaily.getTodayCount(bookId) : undefined return { totalWords, volumeWords, @@ -35,7 +45,9 @@ export class CockpitService { foreshadowBuried: stats.buried, foreshadowResolved: stats.resolved, foreshadowForgotten: stats.forgotten, - writingStats + writingStats, + achievements, + pomodoroTodayCount } } diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index ce59005..4d0c8a1 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -32,7 +32,10 @@ function defaults(): GlobalSettings { knowledgeAutoSuggest: true, knowledgeSuggestTopN: 8, knowledgeAutoExtract: true, - knowledgeExtractConfidenceThreshold: 0.8 + knowledgeExtractConfidenceThreshold: 0.8, + pomodoroDurationMinutes: 25, + enableSystemNotifications: false, + enableGoalNotifications: true } } @@ -57,6 +60,10 @@ export class GlobalSettingsService { knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8, knowledgeAutoExtract: raw.knowledgeAutoExtract ?? true, knowledgeExtractConfidenceThreshold: raw.knowledgeExtractConfidenceThreshold ?? 0.8, + pomodoroDurationMinutes: raw.pomodoroDurationMinutes ?? 25, + enableSystemNotifications: raw.enableSystemNotifications ?? false, + enableGoalNotifications: raw.enableGoalNotifications ?? true, + dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } } diff --git a/src/main/services/goal-notification.service.ts b/src/main/services/goal-notification.service.ts new file mode 100644 index 0000000..9575ad6 --- /dev/null +++ b/src/main/services/goal-notification.service.ts @@ -0,0 +1,72 @@ +import { BrowserWindow, Notification } from 'electron' +import { IPC } from '../../shared/ipc-channels' +import type { GoalNotificationPayload, WritingMilestone } from '../../shared/types' +import type { GlobalSettingsService } from './global-settings' + +const MILESTONE_LABEL: Record = { + streak_7: 'achievement.streak7', + streak_30: 'achievement.streak30', + streak_100: 'achievement.streak100' +} + +export class GoalNotificationService { + constructor(private settings: GlobalSettingsService) {} + + private getWindow(): BrowserWindow | null { + const wins = BrowserWindow.getAllWindows() + return wins.length > 0 ? wins[0] : null + } + + pushToast(payload: GoalNotificationPayload): void { + this.getWindow()?.webContents.send(IPC.GOAL_NOTIFICATION, payload) + } + + maybeSystemNotify(title: string, body: string): void { + if (!this.settings.get().enableSystemNotifications) return + if (!Notification.isSupported()) return + try { + new Notification({ title, body }).show() + } catch { + /* permission denied */ + } + } + + notifyPomodoroComplete(words: number): void { + const payload: GoalNotificationPayload = { + kind: 'pomodoro_complete', + messageKey: 'pomodoro.complete', + messageParams: { words } + } + this.pushToast(payload) + this.maybeSystemNotify('笔临', `番茄钟完成,本次写作 ${words} 字`) + } + + notifyPomodoroBookSwitch(): void { + this.pushToast({ + kind: 'pomodoro_cancelled_book_switch', + messageKey: 'pomodoro.switchedBook' + }) + } + + maybeNotifyDailyMet(current: number, goal: number): void { + if (!this.settings.get().enableGoalNotifications) return + const today = new Date().toLocaleDateString('sv-SE') + if (this.settings.get().dailyGoalNotifiedDate === today) return + if (current < goal) return + this.settings.update({ dailyGoalNotifiedDate: today }) + this.pushToast({ + kind: 'daily_met', + messageKey: 'goal.dailyMet', + messageParams: { current, goal } + }) + this.maybeSystemNotify('笔临', `今日目标已达成 ${current} / ${goal} 字`) + } + + notifyMilestone(milestone: WritingMilestone): void { + this.pushToast({ + kind: 'milestone', + messageKey: MILESTONE_LABEL[milestone] + }) + this.maybeSystemNotify('笔临', '解锁写作成就') + } +} diff --git a/src/main/services/knowledge-injection-runner.ts b/src/main/services/knowledge-injection-runner.ts new file mode 100644 index 0000000..832e45d --- /dev/null +++ b/src/main/services/knowledge-injection-runner.ts @@ -0,0 +1,20 @@ +import type { BookRegistryService } from './book-registry' +import type { InjectionFlowMode } from '../../shared/types' +import { KnowledgeInjectionService } from './knowledge-injection.service' + +export function recordKnowledgeInjection( + registry: BookRegistryService, + bookId: string, + input: { + knowledgeIds: string[] + flowMode: InjectionFlowMode + flowId: string + chapterId?: string + } +): void { + try { + new KnowledgeInjectionService(registry.getDb(bookId)).record(input) + } catch (err) { + console.error('[knowledge-injection]', err) + } +} diff --git a/src/main/services/knowledge-injection.service.ts b/src/main/services/knowledge-injection.service.ts new file mode 100644 index 0000000..abe07cc --- /dev/null +++ b/src/main/services/knowledge-injection.service.ts @@ -0,0 +1,39 @@ +import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../shared/types' +import type { SqliteDb } from '../db/types' +import { KnowledgeRepository } from '../db/repositories/knowledge.repo' +import { KnowledgeInjectionRepository } from '../db/repositories/knowledge-injection.repo' + +export class KnowledgeInjectionService { + private injectionRepo: KnowledgeInjectionRepository + private knowledgeRepo: KnowledgeRepository + + constructor(db: SqliteDb) { + this.injectionRepo = new KnowledgeInjectionRepository(db) + this.knowledgeRepo = new KnowledgeRepository(db) + } + + record(input: { + knowledgeIds: string[] + flowMode: InjectionFlowMode + flowId: string + chapterId?: string + }): void { + if (input.knowledgeIds.length === 0) return + const rows = input.knowledgeIds + .filter((id) => { + const entry = this.knowledgeRepo.get(id) + return entry?.status === 'approved' + }) + .map((id) => ({ + knowledgeEntryId: id, + flowMode: input.flowMode, + flowId: input.flowId, + chapterId: input.chapterId + })) + if (rows.length > 0) this.injectionRepo.insertBatch(rows) + } + + listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] { + return this.injectionRepo.listForEntry(entryId, limit) + } +} diff --git a/src/main/services/pomodoro.service.ts b/src/main/services/pomodoro.service.ts new file mode 100644 index 0000000..7938d4d --- /dev/null +++ b/src/main/services/pomodoro.service.ts @@ -0,0 +1,111 @@ +import type { PomodoroState } from '../../shared/types' +import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo' +import type { GoalNotificationService } from './goal-notification.service' +import type { GlobalSettingsService } from './global-settings' +import type { WritingLogService } from './writing-log.service' +import { IPC } from '../../shared/ipc-channels' +import { BrowserWindow } from 'electron' + +export class PomodoroService { + private state: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null } + private wordSnapshot = 0 + private timer: ReturnType | null = null + + constructor( + private writingLogs: WritingLogService, + private pomodoroDaily: PomodoroDailyRepository, + private settings: GlobalSettingsService, + private notify: GoalNotificationService + ) {} + + getState(): PomodoroState { + return { ...this.state } + } + + start(bookId: string): PomodoroState { + if (this.state.running && this.state.bookId !== bookId) { + this.notify.notifyPomodoroBookSwitch() + this.cancelInternal() + } + const duration = this.resolveDurationSec() + this.wordSnapshot = this.writingLogs.getStats(bookId).todayWords + this.state = { running: true, paused: false, remainingSec: duration, bookId } + this.startTimer() + this.pushTick() + return this.getState() + } + + pause(): PomodoroState { + if (!this.state.running || this.state.paused) return this.getState() + this.state = { ...this.state, paused: true } + this.stopTimer() + return this.getState() + } + + resume(): PomodoroState { + if (!this.state.running || !this.state.paused) return this.getState() + this.state = { ...this.state, paused: false } + this.startTimer() + return this.getState() + } + + cancel(): PomodoroState { + this.cancelInternal() + return this.getState() + } + + private cancelInternal(): void { + this.stopTimer() + this.state = { running: false, paused: false, remainingSec: 0, bookId: null } + this.wordSnapshot = 0 + this.pushTick() + } + + private complete(): void { + const bookId = this.state.bookId! + const delta = Math.max( + 0, + this.writingLogs.getStats(bookId).todayWords - this.wordSnapshot + ) + this.pomodoroDaily.recordComplete(bookId, delta) + this.notify.notifyPomodoroComplete(delta) + this.cancelInternal() + } + + private tick(): void { + if (!this.state.running || this.state.paused) return + if (this.state.remainingSec <= 1) { + this.complete() + return + } + this.state = { ...this.state, remainingSec: this.state.remainingSec - 1 } + this.pushTick() + } + + private startTimer(): void { + this.stopTimer() + this.timer = setInterval(() => this.tick(), 1000) + } + + private stopTimer(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + private pushTick(): void { + const wins = BrowserWindow.getAllWindows() + for (const win of wins) { + win.webContents.send(IPC.POMODORO_TICK, this.getState()) + } + } + + private resolveDurationSec(): number { + if (process.env.BILIN_E2E === '1' && process.env.POMODORO_TEST_SEC) { + return Number(process.env.POMODORO_TEST_SEC) || 3 + } + const mins = this.settings.get().pomodoroDurationMinutes ?? 25 + return mins * 60 + } +} diff --git a/src/main/services/writing-log.service.ts b/src/main/services/writing-log.service.ts index c800644..a0df24d 100644 --- a/src/main/services/writing-log.service.ts +++ b/src/main/services/writing-log.service.ts @@ -1,16 +1,36 @@ import type { WritingStats } from '../../shared/types' import { WritingLogRepository } from '../db/repositories/writing-log.repo' +import type { AchievementService } from './achievement.service' import type { GlobalSettingsService } from './global-settings' +import type { GoalNotificationService } from './goal-notification.service' import { addDays, localDateString } from './writing-date.util' export class WritingLogService { + private achievementService: AchievementService | null = null + private goalNotify: GoalNotificationService | null = null + constructor( private repo: WritingLogRepository, private settings: GlobalSettingsService ) {} + setGoalHooks(achievement: AchievementService, notify: GoalNotificationService): void { + this.achievementService = achievement + this.goalNotify = notify + } + addToday(bookId: string, delta: number): number { - return this.repo.addToday(bookId, delta) + const result = this.repo.addToday(bookId, delta) + if (delta !== 0) this.afterWordsChanged(bookId) + return result + } + + private afterWordsChanged(bookId: string): void { + const stats = this.getStats(bookId) + if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) { + this.goalNotify?.maybeNotifyDailyMet(stats.todayWords, stats.dailyGoal) + } + this.achievementService?.checkMilestones(bookId) } getStats(bookId: string): WritingStats { diff --git a/src/preload/index.ts b/src/preload/index.ts index 0e466a6..0f1c120 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -46,7 +46,11 @@ import type { ChapterBridgeResult, PublishStatus, WritingStats, - KnowledgeExtractionResult + KnowledgeExtractionResult, + PomodoroState, + GoalNotificationPayload, + KnowledgeInjectionLog, + WritingAchievement } from '../shared/types' const electronAPI = { @@ -477,12 +481,30 @@ const electronAPI = { bookId: string, threshold?: number ): Promise> => - ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, { bookId, threshold }) + ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, { bookId, threshold }), + listInjections: ( + bookId: string, + entryId: string, + limit?: number + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_LIST_INJECTIONS, { bookId, entryId, limit }) }, writing: { getStats: (bookId: string): Promise> => ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId }) }, + pomodoro: { + start: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.POMODORO_START, { bookId }), + pause: (): Promise> => ipcRenderer.invoke(IPC.POMODORO_PAUSE), + resume: (): Promise> => ipcRenderer.invoke(IPC.POMODORO_RESUME), + cancel: (): Promise> => ipcRenderer.invoke(IPC.POMODORO_CANCEL), + getState: (): Promise> => ipcRenderer.invoke(IPC.POMODORO_GET_STATE) + }, + achievement: { + list: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.ACHIEVEMENT_LIST, { bookId }) + }, cockpit: { getSummary: (bookId: string, volumeId?: string): Promise> => ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }), @@ -543,6 +565,20 @@ const electronAPI = { } ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler) return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler) + }, + onPomodoroTick: (callback: (state: PomodoroState) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, state: PomodoroState) => { + callback(state) + } + ipcRenderer.on(IPC.POMODORO_TICK, handler) + return () => ipcRenderer.removeListener(IPC.POMODORO_TICK, handler) + }, + onGoalNotification: (callback: (payload: GoalNotificationPayload) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, payload: GoalNotificationPayload) => { + callback(payload) + } + ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler) + return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler) } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 506ec5a..dabfd49 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -47,6 +47,12 @@ function AppInner(): React.JSX.Element { if (loaded && !onboardingCompleted) setShowOnboarding(true) }, [loaded, onboardingCompleted]) + useEffect(() => { + return window.electronAPI.onGoalNotification((payload) => { + showToast(t(payload.messageKey, payload.messageParams)) + }) + }, [showToast, t]) + useEffect(() => { const openSearch = (): void => useSearchStore.getState().setOpen(true) const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true) diff --git a/src/renderer/components/cockpit/CockpitModal.tsx b/src/renderer/components/cockpit/CockpitModal.tsx index 191dcb1..b2047b1 100644 --- a/src/renderer/components/cockpit/CockpitModal.tsx +++ b/src/renderer/components/cockpit/CockpitModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import type { WritingMilestone } from '@shared/types' import { useTranslation } from 'react-i18next' import { useBookStore } from '@renderer/stores/useBookStore' import { useEditStore } from '@renderer/stores/useEditStore' @@ -10,6 +11,14 @@ interface CockpitModalProps { onOpenBridge: (chapterId: string) => void } +const ALL_MILESTONES: WritingMilestone[] = ['streak_7', 'streak_30', 'streak_100'] + +const MILESTONE_I18N: Record = { + streak_7: 'achievement.streak7', + streak_30: 'achievement.streak30', + streak_100: 'achievement.streak100' +} + export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null { const { t } = useTranslation() const bookId = useBookStore((s) => s.currentBookId) @@ -105,6 +114,26 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele )} +
+ {t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })} +
+
+
{t('cockpit.achievements')}
+
+ {ALL_MILESTONES.map((milestone) => { + const unlocked = summary.achievements?.some((a) => a.milestone === milestone) + return ( + + {milestone === 'streak_7' ? '7🔥' : milestone === 'streak_30' ? '30🔥' : '100🔥'} + + ) + })} +
+
)} diff --git a/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx index c6243a7..42bc7cc 100644 --- a/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx +++ b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx @@ -4,9 +4,11 @@ import type { CreateKnowledgeInput, ForeshadowStatus, KnowledgeEntry, + KnowledgeInjectionLog, KnowledgeType } from '@shared/types' import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore' import { ipcCall } from '@renderer/lib/ipc-client' @@ -26,6 +28,7 @@ export function KnowledgeEditorDialog({ const { t } = useTranslation() const bookId = useBookStore((s) => s.currentBookId) const chapters = useBookStore((s) => s.chapters) + const switchTarget = useEditStore((s) => s.switchTarget) const entries = useKnowledgeStore((s) => s.entries) const create = useKnowledgeStore((s) => s.create) const update = useKnowledgeStore((s) => s.update) @@ -45,6 +48,17 @@ export function KnowledgeEditorDialog({ const [foreshadowStatus, setForeshadowStatus] = useState('buried') const [sourceChapterId, setSourceChapterId] = useState('') const [approveNow, setApproveNow] = useState(false) + const [injections, setInjections] = useState([]) + + useEffect(() => { + if (!open || !bookId || !entryId) { + setInjections([]) + return + } + void ipcCall(() => window.electronAPI.knowledge.listInjections(bookId, entryId)).then( + setInjections + ) + }, [open, bookId, entryId]) useEffect(() => { if (!open) return @@ -183,6 +197,39 @@ export function KnowledgeEditorDialog({ {t('knowledge.approveNow')} )} + {existing && injections.length > 0 && ( +
+
{t('knowledge.injectionHistory')}
+ {injections.map((log) => { + const chapterTitle = + chapters.find((c) => c.id === log.chapterId)?.title ?? t('knowledge.noChapter') + return ( +
+ + {new Date(log.injectedAt).toLocaleString()} + + + {t(`knowledge.injectionMode.${log.flowMode}`)} + + {log.chapterId ? ( + + ) : ( + {chapterTitle} + )} +
+ ) + })} +
+ )}
{isMergeMode && bookId && existing && ( diff --git a/src/renderer/components/knowledge/KnowledgePanel.tsx b/src/renderer/components/knowledge/KnowledgePanel.tsx index 520e25e..af1906f 100644 --- a/src/renderer/components/knowledge/KnowledgePanel.tsx +++ b/src/renderer/components/knowledge/KnowledgePanel.tsx @@ -13,6 +13,7 @@ import { export function KnowledgePanel(): React.JSX.Element { const { t } = useTranslation() const bookId = useBookStore((s) => s.currentBookId) + const chapters = useBookStore((s) => s.chapters) const selectedChapterId = useBookStore((s) => s.selectedChapterId) const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold) const { @@ -32,6 +33,8 @@ export function KnowledgePanel(): React.JSX.Element { extractChapter, batchApproveHighConfidence } = useKnowledgeStore() + const injectionPreviews = useKnowledgeStore((s) => s.injectionPreviews) + const loadInjectionPreviews = useKnowledgeStore((s) => s.loadInjectionPreviews) const [extracting, setExtracting] = useState(false) const [namingOpen, setNamingOpen] = useState(false) const [selected, setSelected] = useState>(new Set()) @@ -51,6 +54,14 @@ export function KnowledgePanel(): React.JSX.Element { ) }, [bookId, entries]) + useEffect(() => { + if (!bookId || entries.length === 0) return + void loadInjectionPreviews( + bookId, + entries.map((e) => e.id) + ) + }, [bookId, entries, loadInjectionPreviews]) + const handleBatchApprove = async (): Promise => { if (!bookId || selected.size === 0) return await batchApprove(bookId, [...selected]) @@ -188,6 +199,25 @@ export function KnowledgePanel(): React.JSX.Element {
)} {entry.content &&
{entry.content}
} + {(injectionPreviews[entry.id]?.length ?? 0) > 0 && ( +
+
{t('knowledge.injectionPreview')}
+ {injectionPreviews[entry.id]!.map((log) => ( +
+ {t('knowledge.injectionEntry', { + date: new Date(log.injectedAt).toLocaleDateString(), + mode: t(`knowledge.injectionMode.${log.flowMode}`), + chapter: + chapters.find((c) => c.id === log.chapterId)?.title ?? + t('knowledge.noChapter') + })} +
+ ))} +
+ )}
{entry.status === 'pending' && bookId && ( diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index 1d42caf..f069522 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -10,6 +10,7 @@ import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore' import { useCockpitStore } from '@renderer/stores/useCockpitStore' import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore' +import { usePomodoroStore, formatPomodoroTime } from '@renderer/stores/usePomodoroStore' import { ipcCall } from '@renderer/lib/ipc-client' import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor' import { insertLandmarkInEditor } from '@renderer/lib/editor-commands' @@ -94,6 +95,15 @@ export function EditorLayout(): React.JSX.Element { const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal) const writingStats = useWritingStatsStore((s) => s.stats) const refreshWritingStats = useWritingStatsStore((s) => s.refresh) + const pomodoroRunning = usePomodoroStore((s) => s.running) + const pomodoroPaused = usePomodoroStore((s) => s.paused) + const pomodoroRemaining = usePomodoroStore((s) => s.remainingSec) + const pomodoroBookId = usePomodoroStore((s) => s.bookId) + const pomodoroInit = usePomodoroStore((s) => s.init) + const pomodoroStart = usePomodoroStore((s) => s.start) + const pomodoroPause = usePomodoroStore((s) => s.pause) + const pomodoroResume = usePomodoroStore((s) => s.resume) + const pomodoroCancel = usePomodoroStore((s) => s.cancel) const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId) const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter) const updateChapterLocal = useBookStore((s) => s.updateChapterLocal) @@ -111,11 +121,21 @@ export function EditorLayout(): React.JSX.Element { const isChapterTarget = target?.kind === 'chapter' const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId + useEffect(() => { + void pomodoroInit() + }, [pomodoroInit]) + + useEffect(() => { + if (!pomodoroRunning || !pomodoroBookId || !currentBookId) return + if (pomodoroBookId === currentBookId) return + void pomodoroCancel() + }, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel]) + useEffect(() => { if (selectedChapterId && (!target || target.kind === 'chapter')) { setTarget({ kind: 'chapter', id: selectedChapterId }) } - }, [selectedChapterId, setTarget]) + }, [selectedChapterId, setTarget, target]) useEffect(() => { if (!currentBookId || target?.kind !== 'chapter') return @@ -426,6 +446,39 @@ export function EditorLayout(): React.JSX.Element { bookId={currentBookId} />
+
+ {!pomodoroRunning ? ( + + ) : ( + <> + + {formatPomodoroTime(pomodoroRemaining)} + + + )} +
{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''} {dailyWordGoal > 0 && writingStats && (
+
+ {t('settings.pomodoroDuration')} + +
+
+ +
+
+ +
{t('settings.extractThreshold')} } {section === 'about' && (

- {t('app.name')} v0.8.0 + {t('app.name')} v0.9.0
{t('app.tagline')}

diff --git a/src/renderer/stores/useKnowledgeStore.ts b/src/renderer/stores/useKnowledgeStore.ts index 3317ecf..adf73bd 100644 --- a/src/renderer/stores/useKnowledgeStore.ts +++ b/src/renderer/stores/useKnowledgeStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import type { CreateKnowledgeInput, KnowledgeEntry, + KnowledgeInjectionLog, KnowledgeStatus, KnowledgeType } from '@shared/types' @@ -17,7 +18,9 @@ interface KnowledgeState { forgottenOnly: boolean editorOpen: boolean editingId: string | null + injectionPreviews: Record load: (bookId: string) => Promise + loadInjectionPreviews: (bookId: string, entryIds: string[]) => Promise setTab: (tab: KnowledgeTab) => void openForgottenFilter: () => void openEditor: (id?: string | null) => void @@ -46,6 +49,7 @@ export const useKnowledgeStore = create((set, get) => ({ forgottenOnly: false, editorOpen: false, editingId: null, + injectionPreviews: {}, load: async (bookId) => { const [entries, stats] = await Promise.all([ ipcCall(() => window.electronAPI.knowledge.list(bookId)), @@ -53,6 +57,21 @@ export const useKnowledgeStore = create((set, get) => ({ ]) set({ entries, stats }) }, + loadInjectionPreviews: async (bookId, entryIds) => { + if (entryIds.length === 0) { + set({ injectionPreviews: {} }) + return + } + const pairs = await Promise.all( + entryIds.map(async (id) => { + const logs = await ipcCall(() => + window.electronAPI.knowledge.listInjections(bookId, id, 3) + ) + return [id, logs] as const + }) + ) + set({ injectionPreviews: Object.fromEntries(pairs) }) + }, setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }), openForgottenFilter: () => { useAppStore.getState().setRightPanel('knowledge') diff --git a/src/renderer/stores/usePomodoroStore.ts b/src/renderer/stores/usePomodoroStore.ts new file mode 100644 index 0000000..bea98a4 --- /dev/null +++ b/src/renderer/stores/usePomodoroStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import type { PomodoroState } from '@shared/types' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface PomodoroStore extends PomodoroState { + tickUnsub: (() => void) | null + init: () => Promise + start: (bookId: string) => Promise + pause: () => Promise + resume: () => Promise + cancel: () => Promise + applyState: (state: PomodoroState) => void +} + +const idle: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null } + +export const usePomodoroStore = create((set, get) => ({ + ...idle, + tickUnsub: null, + init: async () => { + const state = await ipcCall(() => window.electronAPI.pomodoro.getState()) + set(state) + if (!get().tickUnsub) { + const unsub = window.electronAPI.onPomodoroTick((next) => { + set(next) + }) + set({ tickUnsub: unsub }) + } + }, + applyState: (state) => set(state), + start: async (bookId) => { + const state = await ipcCall(() => window.electronAPI.pomodoro.start(bookId)) + set(state) + }, + pause: async () => { + const state = await ipcCall(() => window.electronAPI.pomodoro.pause()) + set(state) + }, + resume: async () => { + const state = await ipcCall(() => window.electronAPI.pomodoro.resume()) + set(state) + }, + cancel: async () => { + const state = await ipcCall(() => window.electronAPI.pomodoro.cancel()) + set(state) + } +})) + +export function formatPomodoroTime(sec: number): string { + const m = Math.floor(sec / 60) + const s = sec % 60 + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +} diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index f2e198a..77819fe 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -26,6 +26,9 @@ export const useSettingsStore = create((set, get) => ({ knowledgeSuggestTopN: 8, knowledgeAutoExtract: true, knowledgeExtractConfidenceThreshold: 0.8, + pomodoroDurationMinutes: 25, + enableSystemNotifications: false, + enableGoalNotifications: true, loaded: false, load: async () => { const data = await ipcCall(() => window.electronAPI.settings.get()) diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 3462b17..66f20b2 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -909,6 +909,108 @@ font-weight: 600; } +.pomodoro-control { + display: inline-flex; + align-items: center; + gap: 4px; + margin-right: 4px; +} + +.pomodoro-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 2px 6px; +} + +.pomodoro-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent-light); +} + +.pomodoro-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pomodoro-time { + font-variant-numeric: tabular-nums; + min-width: 42px; + text-align: center; +} + +.cockpit-achievements-section { + margin-top: 12px; +} + +.cockpit-achievements { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.cockpit-achievement-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 8px; + font-size: 13px; + border: 1px solid var(--border); +} + +.cockpit-achievement-badge.unlocked { + background: var(--bg-tertiary); + opacity: 1; +} + +.cockpit-achievement-badge.locked { + opacity: 0.35; + filter: grayscale(1); +} + +.cockpit-pomodoro-today { + margin-top: 12px; + font-size: 13px; + color: var(--text-secondary); +} + +.kb-injection-preview, +.knowledge-injection-history { + margin-top: 8px; + font-size: 11px; + color: var(--text-muted); +} + +.kb-injection-label { + font-weight: 600; + margin-bottom: 4px; + color: var(--text-secondary); +} + +.kb-injection-preview-row, +.kb-injection-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-top: 2px; +} + +.kb-injection-chapter-link { + background: none; + border: none; + color: var(--accent-light); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; +} + .kb-confidence-badge { margin-left: 8px; font-size: 11px; diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index a7fa78e..5fbe9f1 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -44,7 +44,12 @@ import type { ChapterBridgeResult, PublishStatus, WritingStats, - KnowledgeExtractionResult + KnowledgeExtractionResult, + PomodoroState, + GoalNotificationPayload, + KnowledgeInjectionLog, + WritingAchievement, + PomodoroDuration } from './types' export interface ElectronAPI { @@ -358,10 +363,25 @@ export interface ElectronAPI { approveMerge: (bookId: string, pendingId: string) => Promise> saveMergeAsNew: (bookId: string, pendingId: string) => Promise> batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise> + listInjections: ( + bookId: string, + entryId: string, + limit?: number + ) => Promise> } writing: { getStats: (bookId: string) => Promise> } + pomodoro: { + start: (bookId: string) => Promise> + pause: () => Promise> + resume: () => Promise> + cancel: () => Promise> + getState: () => Promise> + } + achievement: { + list: (bookId: string) => Promise> + } cockpit: { getSummary: (bookId: string, volumeId?: string) => Promise> markSeen: (bookId: string) => Promise> @@ -390,6 +410,8 @@ export interface ElectronAPI { onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void + onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void + onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 68c4594..7a6bd85 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -117,6 +117,15 @@ export const IPC = { KNOWLEDGE_SAVE_MERGE_AS_NEW: 'knowledge:saveMergeAsNew', KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence', WRITING_GET_STATS: 'writing:getStats', + POMODORO_START: 'pomodoro:start', + POMODORO_PAUSE: 'pomodoro:pause', + POMODORO_RESUME: 'pomodoro:resume', + POMODORO_CANCEL: 'pomodoro:cancel', + POMODORO_GET_STATE: 'pomodoro:getState', + POMODORO_TICK: 'pomodoro:tick', + ACHIEVEMENT_LIST: 'achievement:list', + KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections', + GOAL_NOTIFICATION: 'goal:notification', COCKPIT_SUMMARY: 'cockpit:summary', COCKPIT_MARK_SEEN: 'cockpit:markSeen', COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen', diff --git a/src/shared/types.ts b/src/shared/types.ts index a645ca2..c7b4956 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -94,6 +94,8 @@ export interface CockpitSummary { foreshadowResolved: number foreshadowForgotten: number writingStats?: WritingStats + achievements?: WritingAchievement[] + pomodoroTodayCount?: number } export interface WritingDayLog { @@ -127,6 +129,37 @@ export interface KnowledgeExtractionResult { items: ExtractedKnowledgeItem[] } +export type PomodoroDuration = 15 | 25 | 45 +export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100' +export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard' + +export interface PomodoroState { + running: boolean + paused: boolean + remainingSec: number + bookId: string | null +} + +export interface WritingAchievement { + milestone: WritingMilestone + unlockedAt: string +} + +export interface KnowledgeInjectionLog { + id: string + knowledgeEntryId: string + flowMode: InjectionFlowMode + flowId: string + chapterId?: string + injectedAt: string +} + +export interface GoalNotificationPayload { + kind: 'daily_met' | 'milestone' | 'pomodoro_complete' | 'pomodoro_cancelled_book_switch' + messageKey: string + messageParams?: Record +} + export interface ChapterBridgeResult { previousChapterId: string | null previousTail: string @@ -171,6 +204,10 @@ export interface GlobalSettings { knowledgeSuggestTopN?: number knowledgeAutoExtract?: boolean knowledgeExtractConfidenceThreshold?: number + pomodoroDurationMinutes?: PomodoroDuration + enableSystemNotifications?: boolean + enableGoalNotifications?: boolean + dailyGoalNotifiedDate?: string } export interface BookMeta { diff --git a/tests/main/achievement.test.ts b/tests/main/achievement.test.ts new file mode 100644 index 0000000..e9bfa42 --- /dev/null +++ b/tests/main/achievement.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [] + }, + Notification: { + isSupported: () => false + } +})) + +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' +import { WritingMilestoneRepository } from '../../src/main/db/repositories/writing-milestone.repo' +import { GlobalSettingsService } from '../../src/main/services/global-settings' +import { WritingLogService } from '../../src/main/services/writing-log.service' +import { GoalNotificationService } from '../../src/main/services/goal-notification.service' +import { AchievementService } from '../../src/main/services/achievement.service' +import { addDays, localDateString } from '../../src/main/services/writing-date.util' + +describe('WritingMilestoneRepository', () => { + let userDir: string + let repo: WritingLogRepository + let milestoneRepo: WritingMilestoneRepository + + afterEach(() => { + repo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + }) + + it('UT-ACH-01: unlock streak_7 once', () => { + userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-')) + repo = new WritingLogRepository(userDir) + milestoneRepo = new WritingMilestoneRepository(repo.getDb()) + milestoneRepo.unlock('b1', 'streak_7') + expect(milestoneRepo.has('b1', 'streak_7')).toBe(true) + expect(milestoneRepo.unlock('b1', 'streak_7')).toBeNull() + }) +}) + +describe('AchievementService', () => { + let userDir: string + let settingsDir: string + let settings: GlobalSettingsService + let writingRepo: WritingLogRepository + let writingLogs: WritingLogService + let milestoneRepo: WritingMilestoneRepository + let notify: GoalNotificationService + let achievement: AchievementService + + afterEach(() => { + writingRepo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + if (settingsDir) rmSync(settingsDir, { recursive: true, force: true }) + }) + + function setup(goal = 100): void { + userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-svc-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-ach-settings-')) + settings = new GlobalSettingsService(settingsDir) + settings.update({ + ...settings.get(), + dailyWordGoal: goal, + enableGoalNotifications: false + }) + writingRepo = new WritingLogRepository(userDir) + writingLogs = new WritingLogService(writingRepo, settings) + milestoneRepo = new WritingMilestoneRepository(writingRepo.getDb()) + notify = new GoalNotificationService(settings) + achievement = new AchievementService(milestoneRepo, writingLogs, settings, notify) + writingLogs.setGoalHooks(achievement, notify) + } + + it('UT-ACH-02: streakDays=7 unlocks milestone and notifies once', () => { + setup(100) + const bookId = 'book-1' + const today = localDateString() + for (let i = 1; i <= 6; i++) { + writingRepo.addToday(bookId, 150, addDays(today, -i)) + } + writingRepo.addToday(bookId, 120, today) + vi.spyOn(notify, 'notifyMilestone') + writingLogs.addToday(bookId, 1) + expect(milestoneRepo.has(bookId, 'streak_7')).toBe(true) + expect(notify.notifyMilestone).toHaveBeenCalledTimes(1) + writingLogs.addToday(bookId, 1) + expect(notify.notifyMilestone).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/main/knowledge-injection.test.ts b/tests/main/knowledge-injection.test.ts new file mode 100644 index 0000000..3167ba6 --- /dev/null +++ b/tests/main/knowledge-injection.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { migrate } from '../../src/main/db/migrate' +import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo' +import { KnowledgeInjectionRepository } from '../../src/main/db/repositories/knowledge-injection.repo' +import { KnowledgeInjectionService } from '../../src/main/services/knowledge-injection.service' +import type { SqliteDb } from '../../src/main/db/types' + +describe('KnowledgeInjectionRepository', () => { + let db: SqliteDb + afterEach(() => db?.close()) + + it('UT-INJ-01: insertBatch creates rows listable by entry', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const knowledge = new KnowledgeRepository(db) + const entry = knowledge.create({ type: 'fact', title: '测试', status: 'approved' }) + const repo = new KnowledgeInjectionRepository(db) + repo.insertBatch([ + { knowledgeEntryId: entry.id, flowMode: 'wizard', flowId: 'flow-1', chapterId: 'ch-1' }, + { knowledgeEntryId: entry.id, flowMode: 'interactive', flowId: 'flow-2' }, + { knowledgeEntryId: entry.id, flowMode: 'auto', flowId: 'flow-3', chapterId: 'ch-2' } + ]) + const logs = repo.listForEntry(entry.id) + expect(logs).toHaveLength(3) + expect(logs.map((l) => l.flowMode).sort()).toEqual(['auto', 'interactive', 'wizard']) + }) +}) + +describe('KnowledgeInjectionService', () => { + let db: SqliteDb + afterEach(() => db?.close()) + + it('IT-INJ-01: record only approved knowledge entries', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const knowledge = new KnowledgeRepository(db) + const approved = knowledge.create({ type: 'fact', title: '已审', status: 'approved' }) + const pending = knowledge.create({ type: 'fact', title: '待审', status: 'pending' }) + const service = new KnowledgeInjectionService(db) + service.record({ + knowledgeIds: [approved.id, pending.id], + flowMode: 'interactive', + flowId: 'flow-x', + chapterId: 'ch-1' + }) + const logs = service.listForEntry(approved.id) + expect(logs).toHaveLength(1) + expect(logs[0].flowMode).toBe('interactive') + expect(service.listForEntry(pending.id)).toHaveLength(0) + }) +}) diff --git a/tests/main/migrate-v2.test.ts b/tests/main/migrate-v2.test.ts index 75de6d0..d62068e 100644 --- a/tests/main/migrate-v2.test.ts +++ b/tests/main/migrate-v2.test.ts @@ -30,7 +30,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'search_fts')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) }) it('migrates existing v1 database to v2', () => { @@ -47,7 +47,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'outline_items')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[] const colNames = cols.map((c) => c.name) diff --git a/tests/main/migrate-v3.test.ts b/tests/main/migrate-v3.test.ts index a2c44db..3b3b8f9 100644 --- a/tests/main/migrate-v3.test.ts +++ b/tests/main/migrate-v3.test.ts @@ -25,7 +25,7 @@ describe('migrate v3', () => { expect(tableExists(db, 'ai_messages')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) }) it('migrates existing v2 database to v3', () => { @@ -49,6 +49,6 @@ describe('migrate v3', () => { expect(tables.map((t) => t.name)).toContain('ai_messages') const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) }) }) diff --git a/tests/main/migrate-v4.test.ts b/tests/main/migrate-v4.test.ts index 95ac13b..a96400d 100644 --- a/tests/main/migrate-v4.test.ts +++ b/tests/main/migrate-v4.test.ts @@ -23,7 +23,7 @@ describe('migrate v4', () => { migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) expect(tableExists(db, 'interactive_flows')).toBe(true) expect(tableExists(db, 'interactive_scenes')).toBe(true) @@ -48,7 +48,7 @@ describe('migrate v4', () => { migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) expect(tableExists(db, 'interactive_flows')).toBe(true) }) }) diff --git a/tests/main/migrate-v5.test.ts b/tests/main/migrate-v5.test.ts index 72c281b..1f2b92c 100644 --- a/tests/main/migrate-v5.test.ts +++ b/tests/main/migrate-v5.test.ts @@ -15,7 +15,7 @@ describe('migrate v5', () => { db = new DatabaseSync(':memory:') migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[] expect(cols.some((c) => c.name === 'flow_mode')).toBe(true) expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true) @@ -39,6 +39,6 @@ describe('migrate v5', () => { db.prepare('INSERT INTO schema_version (version) VALUES (4)').run() migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) }) }) diff --git a/tests/main/migrate-v6.test.ts b/tests/main/migrate-v6.test.ts index 8b019a4..3498c5c 100644 --- a/tests/main/migrate-v6.test.ts +++ b/tests/main/migrate-v6.test.ts @@ -11,7 +11,7 @@ describe('migrate v6', () => { db = new DatabaseSync(':memory:') migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[] diff --git a/tests/main/migrate-v7.test.ts b/tests/main/migrate-v7.test.ts index 244fd8e..cefabfc 100644 --- a/tests/main/migrate-v7.test.ts +++ b/tests/main/migrate-v7.test.ts @@ -11,7 +11,7 @@ describe('migrate v7', () => { db = new DatabaseSync(':memory:') migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(7) + expect(version.v).toBe(8) const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'") .get() diff --git a/tests/main/migrate-v8.test.ts b/tests/main/migrate-v8.test.ts new file mode 100644 index 0000000..91c3826 --- /dev/null +++ b/tests/main/migrate-v8.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { migrate } from '../../src/main/db/migrate' +import type { SqliteDb } from '../../src/main/db/types' + +describe('migrate v8', () => { + let db: SqliteDb + afterEach(() => db?.close()) + + it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } + expect(version.v).toBe(8) + const table = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'" + ) + .get() + expect(table).toBeTruthy() + const cols = db.prepare('PRAGMA table_info(knowledge_injection_logs)').all() as Array<{ + name: string + }> + expect(cols.some((c) => c.name === 'knowledge_entry_id')).toBe(true) + expect(cols.some((c) => c.name === 'flow_mode')).toBe(true) + }) +}) diff --git a/tests/main/pomodoro.test.ts b/tests/main/pomodoro.test.ts new file mode 100644 index 0000000..54e304a --- /dev/null +++ b/tests/main/pomodoro.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [] + }, + Notification: { + isSupported: () => false + } +})) + +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' +import { PomodoroDailyRepository } from '../../src/main/db/repositories/pomodoro-daily.repo' +import { GlobalSettingsService } from '../../src/main/services/global-settings' +import { WritingLogService } from '../../src/main/services/writing-log.service' +import { GoalNotificationService } from '../../src/main/services/goal-notification.service' +import { PomodoroService } from '../../src/main/services/pomodoro.service' + +describe('PomodoroService', () => { + let userDir: string + let settingsDir: string + let settings: GlobalSettingsService + let writingRepo: WritingLogRepository + let writingLogs: WritingLogService + let pomodoroDaily: PomodoroDailyRepository + let notify: GoalNotificationService + let pomodoro: PomodoroService + + beforeEach(() => { + vi.useFakeTimers() + process.env.BILIN_E2E = '1' + process.env.POMODORO_TEST_SEC = '3' + userDir = mkdtempSync(join(tmpdir(), 'bilin-pom-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-pom-settings-')) + settings = new GlobalSettingsService(settingsDir) + writingRepo = new WritingLogRepository(userDir) + writingLogs = new WritingLogService(writingRepo, settings) + pomodoroDaily = new PomodoroDailyRepository(writingRepo.getDb()) + notify = new GoalNotificationService(settings) + vi.spyOn(notify, 'notifyPomodoroComplete') + vi.spyOn(notify, 'notifyPomodoroBookSwitch') + pomodoro = new PomodoroService(writingLogs, pomodoroDaily, settings, notify) + }) + + afterEach(() => { + vi.useRealTimers() + delete process.env.POMODORO_TEST_SEC + writingRepo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + if (settingsDir) rmSync(settingsDir, { recursive: true, force: true }) + }) + + it('UT-POM-01: complete records word delta in pomodoro_daily', () => { + const bookId = 'book-1' + writingLogs.addToday(bookId, 100) + pomodoro.start(bookId) + writingLogs.addToday(bookId, 50) + vi.advanceTimersByTime(3000) + expect(pomodoroDaily.getTodayCount(bookId)).toBe(1) + expect(notify.notifyPomodoroComplete).toHaveBeenCalledWith(50) + expect(pomodoro.getState().running).toBe(false) + }) + + it('UT-POM-02: start different book cancels previous', () => { + pomodoro.start('book-a') + expect(pomodoro.getState().bookId).toBe('book-a') + pomodoro.start('book-b') + expect(notify.notifyPomodoroBookSwitch).toHaveBeenCalled() + expect(pomodoro.getState().bookId).toBe('book-b') + expect(pomodoro.getState().running).toBe(true) + }) +})