feat: ship v1.0.0 with character graph and writing analytics

Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 10:48:31 +08:00
parent 2c9b034a29
commit 4adafa1936
44 changed files with 2062 additions and 602 deletions
+7 -2
View File
@@ -13,7 +13,8 @@ function mapRow(row: Record<string, unknown>): Chapter {
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
povCharacterId: (row.pov_character_id as string) || null
}
}
@@ -66,6 +67,7 @@ export class ChapterRepository {
status: ChapterStatus
cursorOffset: number
publishStatus: PublishStatus
povCharacterId: string | null
}>
): Chapter {
const existing = this.get(id)
@@ -73,12 +75,14 @@ export class ChapterRepository {
const content = patch.content ?? existing.content
const wordCount = countWords(content)
const povCharacterId =
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
this.db
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
publish_status = ?, word_count = ?, updated_at = datetime('now')
publish_status = ?, pov_character_id = ?, word_count = ?, updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -87,6 +91,7 @@ export class ChapterRepository {
patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount,
id
)
@@ -22,4 +22,19 @@ export class PomodoroDailyRepository {
.get(date, bookId) as { completed_count: number } | undefined
return row?.completed_count ?? 0
}
getTodayStats(
bookId: string,
date = localDateString()
): { completedCount: number; totalWords: number } {
const row = this.db
.prepare(
'SELECT completed_count, total_words FROM pomodoro_daily WHERE date = ? AND book_id = ?'
)
.get(date, bookId) as { completed_count: number; total_words: number } | undefined
return {
completedCount: row?.completed_count ?? 0,
totalWords: row?.total_words ?? 0
}
}
}
+12 -1
View File
@@ -33,7 +33,18 @@ export class WritingLogRepository {
completed_count INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
)
);
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at)
`)
}
@@ -0,0 +1,83 @@
import type { DatabaseSync } from 'node:sqlite'
export interface WritingSessionRow {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}
function mapRow(row: Record<string, unknown>): WritingSessionRow {
return {
id: row.id as string,
bookId: row.book_id as string,
chapterId: (row.chapter_id as string) || undefined,
startedAt: row.started_at as string,
endedAt: row.ended_at as string,
wordDelta: row.word_delta as number,
date: row.date as string
}
}
export class WritingSessionRepository {
constructor(private db: DatabaseSync) {}
insert(row: {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}): void {
this.db
.prepare(
`INSERT INTO writing_sessions (id, book_id, chapter_id, started_at, ended_at, word_delta, date)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
row.id,
row.bookId,
row.chapterId ?? null,
row.startedAt,
row.endedAt,
row.wordDelta,
row.date
)
}
updateActivity(id: string, endedAt: string, wordDelta: number): void {
this.db
.prepare(
`UPDATE writing_sessions SET ended_at = ?, word_delta = word_delta + ? WHERE id = ?`
)
.run(endedAt, wordDelta, id)
}
getById(id: string): WritingSessionRow | null {
const row = this.db.prepare('SELECT * FROM writing_sessions WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
listForBookDate(bookId: string, date: string): WritingSessionRow[] {
const rows = this.db
.prepare('SELECT * FROM writing_sessions WHERE book_id = ? AND date = ? ORDER BY started_at')
.all(bookId, date) as Record<string, unknown>[]
return rows.map(mapRow)
}
listSince(bookId: string, sinceIso: string): WritingSessionRow[] {
const rows = this.db
.prepare(
`SELECT * FROM writing_sessions WHERE book_id = ? AND started_at >= ? ORDER BY started_at`
)
.all(bookId, sinceIso) as Record<string, unknown>[]
return rows.map(mapRow)
}
}