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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user