feat: ship v0.9.0 with pomodoro, achievements, and injection history

Add pomodoro timer with goal notifications, writing streak milestones, and knowledge injection logging with UI previews across AI writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 19:15:52 +08:00
parent 255f1a99a0
commit d4122c8f95
53 changed files with 1679 additions and 80 deletions
@@ -0,0 +1,53 @@
import { randomUUID } from 'crypto'
import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): 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<string, unknown>[]
return rows.map(mapRow)
}
}
@@ -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
}
}
@@ -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 = ?')
@@ -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 }
}
}