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