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)
}
}
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
import { WritingSessionRepository } from '../../db/repositories/writing-session.repo'
import { WritingAnalyticsService } from '../../services/writing-analytics.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerAnalyticsHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
sessionRepo: WritingSessionRepository,
pomodoroDaily: PomodoroDailyRepository
): void {
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }: { bookId: string }) =>
wrap(() => svc.getSummary(bookId))
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
@@ -38,7 +39,8 @@ export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
@@ -217,7 +219,10 @@ export function registerAutoHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
+14 -2
View File
@@ -2,9 +2,15 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
let previousBookId: string | null = null
export function registerBookHandlers(
registry: BookRegistryService,
writingSessions?: WritingSessionService
): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
@@ -18,7 +24,13 @@ export function registerBookHandlers(registry: BookRegistryService): void {
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
wrap(() => {
if (writingSessions && previousBookId && previousBookId !== bookId) {
writingSessions.endActive(previousBookId)
}
previousBookId = bookId
return registry.open(bookId)
})
)
ipcMain.handle(
+12 -4
View File
@@ -4,12 +4,14 @@ import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
@@ -72,7 +74,8 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId
}: {
bookId: string
chapterId: string
@@ -80,6 +83,7 @@ export function registerChapterHandlers(
content?: string
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
}
) =>
wrap(() => {
@@ -87,7 +91,8 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId
})
ftsSync.upsert(
registry.getDb(bookId),
@@ -96,7 +101,10 @@ export function registerChapterHandlers(
chapter.title,
chapter.content
)
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
+51
View File
@@ -0,0 +1,51 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { CharacterRelationship } from '../../../shared/types'
import { CharacterGraphService } from '../../services/character-graph.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerGraphHandlers(registry: BookRegistryService): void {
const svc = () => new CharacterGraphService(registry)
ipcMain.handle(
IPC.GRAPH_GET_DATA,
(_e, { bookId, filter }: { bookId: string; filter?: 'all' | 'connected' }) =>
wrap(() => svc().getData(bookId, filter ?? 'all'))
)
ipcMain.handle(
IPC.GRAPH_UPSERT_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationship
}: {
bookId: string
sourceId: string
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
}
) => wrap(() => svc().upsertRelationship(bookId, sourceId, relationship))
)
ipcMain.handle(
IPC.GRAPH_DELETE_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationshipId
}: { bookId: string; sourceId: string; relationshipId: string }
) =>
wrap(() => {
svc().deleteRelationship(bookId, sourceId, relationshipId)
})
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>()
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
+12 -4
View File
@@ -10,6 +10,8 @@ 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 { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import { WritingSessionService } from '../services/writing-session.service'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
@@ -29,6 +31,8 @@ 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 { registerGraphHandlers } from './handlers/graph.handler'
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -46,6 +50,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
const writingLogs = new WritingLogService(writingLogRepo, settings)
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb())
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
const goalNotify = new GoalNotificationService(settings)
const achievementService = new AchievementService(
milestoneRepo,
@@ -61,8 +67,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
networkMonitor = new NetworkMonitorService()
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books, writingLogs)
registerBookHandlers(books, writingSessions)
registerChapterHandlers(books, writingLogs, writingSessions)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
@@ -72,14 +78,16 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSearchHandlers(books, settings)
const aiClient = new AiClientService(() => settings.get().aiConfig)
registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
registerAutoHandlers(books, settings, aiClient, writingLogs)
registerInteractiveHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerAutoHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
registerWritingHandlers(writingLogs)
registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager }
@@ -2,13 +2,15 @@ import type { SqliteDb } from '../db/types'
import { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
import type { BookRegistryService } from './book-registry'
import type { WritingLogService } from './writing-log.service'
import type { WritingSessionService } from './writing-session.service'
export function trackerFor(
registry: BookRegistryService,
bookId: string,
writingLogs: WritingLogService
writingLogs: WritingLogService,
sessions: WritingSessionService
): ChapterWritingTracker {
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs, sessions)
}
export class ChapterWritingTracker {
@@ -17,7 +19,8 @@ export class ChapterWritingTracker {
constructor(
bookDb: SqliteDb,
private bookId: string,
private writingLogs: WritingLogService
private writingLogs: WritingLogService,
private sessions: WritingSessionService
) {
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
}
@@ -26,7 +29,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const delta = newWordCount - logged
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
}
@@ -34,7 +40,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const gap = wordCount - logged
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
if (gap > 0) {
this.writingLogs.addToday(this.bookId, gap)
this.sessions.recordActivity(this.bookId, chapterId, gap)
}
this.snapRepo.upsert(chapterId, wordCount, true)
}
}
@@ -0,0 +1,140 @@
import { randomUUID } from 'crypto'
import type {
CharacterGraphData,
CharacterGraphEdge,
CharacterRelationship,
SettingEntry
} from '../../shared/types'
import { SettingRepository } from '../db/repositories/setting.repo'
import type { BookRegistryService } from './book-registry'
const MAX_NODES = 100
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
const raw = entry.properties?.relationships
if (!Array.isArray(raw)) return []
return raw.filter(
(r): r is CharacterRelationship =>
r != null &&
typeof r === 'object' &&
typeof (r as CharacterRelationship).id === 'string' &&
typeof (r as CharacterRelationship).targetId === 'string'
)
}
function dedupeEdges(
raw: Array<{ source: string; target: string; label: string; intimacy: number; id: string }>
): CharacterGraphEdge[] {
const best = new Map<string, CharacterGraphEdge>()
for (const e of raw) {
const key = [e.source, e.target].sort().join('|')
const existing = best.get(key)
if (!existing || e.intimacy > existing.intimacy) {
best.set(key, {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
intimacy: e.intimacy as 1 | 2 | 3 | 4 | 5
})
}
}
return [...best.values()]
}
export class CharacterGraphService {
constructor(private registry: BookRegistryService) {}
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
const repo = new SettingRepository(this.registry.getDb(bookId))
const characters = repo.list('character')
const nodeCount = characters.length
const truncated = nodeCount > MAX_NODES
let nodes = characters.map((c) => ({ id: c.id, label: c.name }))
const rawEdges: Array<{
source: string
target: string
label: string
intimacy: number
id: string
}> = []
for (const c of characters) {
for (const rel of parseRelationships(c)) {
if (!characters.some((x) => x.id === rel.targetId)) continue
rawEdges.push({
source: c.id,
target: rel.targetId,
label: rel.label,
intimacy: rel.intimacy,
id: rel.id
})
}
}
let edges = dedupeEdges(rawEdges)
if (filter === 'connected') {
const connected = new Set<string>()
for (const e of edges) {
connected.add(e.source)
connected.add(e.target)
}
nodes = nodes.filter((n) => connected.has(n.id))
}
if (truncated) {
nodes = nodes.slice(0, MAX_NODES)
const allowed = new Set(nodes.map((n) => n.id))
edges = edges.filter((e) => allowed.has(e.source) && allowed.has(e.target))
}
return { nodes, edges, truncated, nodeCount }
}
upsertRelationship(
bookId: string,
sourceId: string,
input: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
): CharacterRelationship {
const repo = new SettingRepository(this.registry.getDb(bookId))
const source = repo.get(sourceId)
const target = repo.get(input.targetId)
if (!source || source.type !== 'character') throw new Error('Invalid source character')
if (!target || target.type !== 'character') throw new Error('Invalid target character')
if (sourceId === input.targetId) throw new Error('Cannot relate character to itself')
const intimacy = Math.min(5, Math.max(1, Math.round(input.intimacy))) as CharacterRelationship['intimacy']
const relationships = parseRelationships(source)
const id = input.id ?? randomUUID()
const next: CharacterRelationship = {
id,
targetId: input.targetId,
label: input.label.trim(),
intimacy
}
const idx = relationships.findIndex((r) => r.id === id)
if (idx >= 0) relationships[idx] = next
else relationships.push(next)
repo.update(sourceId, {
properties: { ...source.properties, relationships }
})
return next
}
deleteRelationship(bookId: string, sourceId: string, relationshipId: string): void {
const repo = new SettingRepository(this.registry.getDb(bookId))
const source = repo.get(sourceId)
if (!source) throw new Error('Setting not found')
const relationships = parseRelationships(source).filter((r) => r.id !== relationshipId)
repo.update(sourceId, {
properties: { ...source.properties, relationships }
})
}
}
@@ -0,0 +1,106 @@
import type { WritingAnalyticsSummary } from '../../shared/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import type { BookRegistryService } from './book-registry'
import { addDays, localDateString } from './writing-date.util'
import type { WritingLogService } from './writing-log.service'
export const POV_NONE = '__none__'
function minutesBetween(startIso: string, endIso: string): number {
const ms = new Date(endIso).getTime() - new Date(startIso).getTime()
return Math.max(0, ms / 60000)
}
function computeWpm(
sessions: Array<{ wordDelta: number; startedAt: string; endedAt: string }>
): number | null {
const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0)
const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0)
if (totalMin <= 0 || totalWords <= 0) return null
return Math.round(totalWords / totalMin)
}
export class WritingAnalyticsService {
constructor(
private writingLogs: WritingLogService,
private sessionRepo: WritingSessionRepository,
private pomodoroDaily: PomodoroDailyRepository,
private registry: BookRegistryService
) {}
getSummary(bookId: string): WritingAnalyticsSummary {
const today = localDateString()
const todaySessions = this.sessionRepo.listForBookDate(bookId, today)
const todayWords = this.writingLogs.getStats(bookId).todayWords
const todaySpeedWpm = computeWpm(todaySessions)
const weekTrend: { date: string; words: number }[] = []
for (let i = 6; i >= 0; i--) {
const date = addDays(today, -i)
weekTrend.push({ date, words: this.writingLogs.getDayWords(bookId, date) })
}
const sinceIso = addDays(today, -30) + 'T00:00:00.000Z'
const sessions30 = this.sessionRepo.listSince(bookId, sinceIso)
const hourlyHeatmap = Array.from({ length: 24 }, () => 0)
for (const s of sessions30) {
const hour = new Date(s.startedAt).getHours()
hourlyHeatmap[hour] += s.wordDelta
}
const pomodoroToday = this.pomodoroDaily.getTodayCount(bookId)
const pomodoroStats = this.pomodoroDaily.getTodayStats(bookId)
const pomodoroAvgWords =
pomodoroStats.completedCount > 0
? Math.round(pomodoroStats.totalWords / pomodoroStats.completedCount)
: null
const db = this.registry.getDb(bookId)
const chapters = new ChapterRepository(db).list()
const settings = new SettingRepository(db).list('character')
const nameById = new Map(settings.map((s) => [s.id, s.name]))
const povMap = new Map<string, number>()
for (const ch of chapters) {
const key = ch.povCharacterId ?? POV_NONE
povMap.set(key, (povMap.get(key) ?? 0) + ch.wordCount)
}
const povDistribution = [...povMap.entries()]
.map(([characterId, words]) => ({
characterId,
name: characterId === POV_NONE ? '' : (nameById.get(characterId) ?? characterId),
words
}))
.sort((a, b) => b.words - a.words)
const volumes = new VolumeRepository(db).list()
const volTitle = new Map(volumes.map((v) => [v.id, v.name]))
const volMap = new Map<string, number>()
for (const ch of chapters) {
if (!ch.volumeId) continue
volMap.set(ch.volumeId, (volMap.get(ch.volumeId) ?? 0) + ch.wordCount)
}
const volumeDistribution = [...volMap.entries()]
.map(([volumeId, words]) => ({
volumeId,
title: volTitle.get(volumeId) ?? volumeId,
words
}))
.sort((a, b) => b.words - a.words)
return {
todayWords,
todaySpeedWpm,
weekTrend,
hourlyHeatmap,
pomodoroToday,
pomodoroAvgWords,
povDistribution,
volumeDistribution
}
}
}
+4
View File
@@ -43,6 +43,10 @@ export class WritingLogService {
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
}
getDayWords(bookId: string, date: string): number {
return this.repo.getDay(bookId, date)
}
private calcStreak(bookId: string, goal: number): number {
let streak = 0
let date = addDays(localDateString(), -1)
@@ -0,0 +1,40 @@
import { randomUUID } from 'crypto'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import { localDateString } from './writing-date.util'
const IDLE_MS = 5 * 60 * 1000
export class WritingSessionService {
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
constructor(private repo: WritingSessionRepository) {}
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
if (delta === 0) return
const now = new Date()
const nowIso = now.toISOString()
const cached = this.active.get(bookId)
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
if (idle) {
const id = randomUUID()
this.repo.insert({
id,
bookId,
chapterId,
startedAt: nowIso,
endedAt: nowIso,
wordDelta: delta,
date: localDateString()
})
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
} else {
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
}
}
endActive(bookId: string): void {
this.active.delete(bookId)
}
}
+31 -1
View File
@@ -50,7 +50,10 @@ import type {
PomodoroState,
GoalNotificationPayload,
KnowledgeInjectionLog,
WritingAchievement
WritingAchievement,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary
} from '../shared/types'
const electronAPI = {
@@ -513,6 +516,33 @@ const electronAPI = {
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
},
graph: {
getData: (
bookId: string,
filter?: 'all' | 'connected'
): Promise<IpcResult<CharacterGraphData>> =>
ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
upsertRelationship: (
bookId: string,
sourceId: string,
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
): Promise<IpcResult<CharacterRelationship>> =>
ipcRenderer.invoke(IPC.GRAPH_UPSERT_RELATIONSHIP, { bookId, sourceId, relationship }),
deleteRelationship: (
bookId: string,
sourceId: string,
relationshipId: string
): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.GRAPH_DELETE_RELATIONSHIP, { bookId, sourceId, relationshipId })
},
analytics: {
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
},
bridge: {
get: (
bookId: string,
@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import type { WritingAnalyticsSummary } from '@shared/types'
import { HourlyHeatmap } from '@renderer/components/cockpit/HourlyHeatmap'
import { useGraphStore } from '@renderer/stores/useGraphStore'
interface CockpitAnalyticsProps {
analytics: WritingAnalyticsSummary | null
}
export function CockpitAnalytics({ analytics }: CockpitAnalyticsProps): React.JSX.Element | null {
const { t } = useTranslation()
if (!analytics) return null
const speedLabel =
analytics.todaySpeedWpm != null
? t('analytics.speedToday', { wpm: analytics.todaySpeedWpm })
: t('analytics.speedUnknown')
const povTop = analytics.povDistribution.slice(0, 5)
const povMax = Math.max(...povTop.map((p) => p.words), 1)
const volMax = Math.max(...analytics.volumeDistribution.map((v) => v.words), 1)
const weekMax = Math.max(...analytics.weekTrend.map((d) => d.words), 1)
const povName = (characterId: string, name: string): string =>
characterId === '__none__' ? t('analytics.povUnassigned') : name
return (
<div className="cockpit-analytics" data-testid="cockpit-analytics">
<h4>{t('analytics.title')}</h4>
<div data-testid="cockpit-speed-today">{speedLabel}</div>
<div>
<div className="cockpit-analytics-label">{t('analytics.hourlyHeatmap')}</div>
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
</div>
<div data-testid="cockpit-pov-chart">
<div className="cockpit-analytics-label">{t('analytics.povDistribution')}</div>
{povTop.map((p) => (
<div key={p.characterId} className="analytics-bar-row">
<span>{povName(p.characterId, p.name)}</span>
<div className="analytics-bar-track">
<div className="pov-bar" style={{ width: `${(p.words / povMax) * 100}%` }} />
</div>
<span>{p.words}</span>
</div>
))}
</div>
<div data-testid="cockpit-volume-chart">
<div className="cockpit-analytics-label">{t('analytics.volumeDistribution')}</div>
{analytics.volumeDistribution.map((v) => (
<div key={v.volumeId} className="analytics-bar-row">
<span>{v.title}</span>
<div className="analytics-bar-track">
<div className="volume-bar" style={{ width: `${(v.words / volMax) * 100}%` }} />
</div>
<span>{v.words}</span>
</div>
))}
</div>
<div data-testid="cockpit-week-trend">
<div className="cockpit-analytics-label">{t('analytics.weekTrend')}</div>
<div className="week-trend-bars">
{analytics.weekTrend.map((d) => (
<div
key={d.date}
className="week-trend-bar"
style={{ height: `${Math.max(4, (d.words / weekMax) * 48)}px` }}
title={`${d.date}: ${d.words}`}
/>
))}
</div>
</div>
<button
type="button"
className="btn"
data-testid="cockpit-open-graph"
onClick={() => useGraphStore.getState().openModal()}
>
{t('cockpit.openGraph')}
</button>
</div>
)
}
@@ -1,11 +1,13 @@
import { useEffect } from 'react'
import type { WritingMilestone } from '@shared/types'
import { useEffect, useState } from 'react'
import type { WritingMilestone, WritingAnalyticsSummary } from '@shared/types'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap'
import { CockpitAnalytics } from '@renderer/components/cockpit/CockpitAnalytics'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CockpitModalProps {
onOpenBridge: (chapterId: string) => void
@@ -28,10 +30,14 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
const switchTarget = useEditStore((s) => s.switchTarget)
const { open, summary, loading, close, loadSummary } = useCockpitStore()
const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter)
const [analytics, setAnalytics] = useState<WritingAnalyticsSummary | null>(null)
useEffect(() => {
if (!open || !bookId) return
void loadSummary(bookId, activeVolumeId ?? undefined)
void Promise.all([
loadSummary(bookId, activeVolumeId ?? undefined),
ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics)
])
}, [open, bookId, activeVolumeId, loadSummary])
if (!open) return null
@@ -117,6 +123,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
<div className="cockpit-pomodoro-today" data-testid="cockpit-pomodoro-today">
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
</div>
<CockpitAnalytics analytics={analytics} />
<div className="cockpit-achievements-section">
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
<div className="cockpit-achievements" data-testid="cockpit-achievements">
@@ -0,0 +1,23 @@
interface HourlyHeatmapProps {
data: number[]
testId?: string
}
export function HourlyHeatmap({ data, testId }: HourlyHeatmapProps): React.JSX.Element {
const max = Math.max(...data, 1)
return (
<div className="hourly-heatmap" data-testid={testId}>
{data.map((value, hour) => (
<div
key={hour}
className="hourly-heatmap-cell"
title={`${hour}:00`}
style={{
opacity: value > 0 ? 0.35 + (value / max) * 0.65 : 0.2,
background: value > 0 ? 'var(--accent)' : undefined
}}
/>
))}
</div>
)
}
@@ -10,6 +10,7 @@ import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } fro
import { useBookStore } from '@renderer/stores/useBookStore'
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
import { SettingMention } from '@renderer/extensions/mention'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
@@ -347,6 +348,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
setMentionOpen(false)
}
const settingEntry =
target?.kind === 'setting' ? settings.find((s) => s.id === target.id) : null
if (!target) {
return <div className="placeholder-box">{t('editor.placeholder')}</div>
}
@@ -354,6 +358,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
return (
<div className="editor-content-wrap">
<EditorContent editor={editor} />
{settingEntry?.type === 'character' && bookId && (
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
)}
{mentionOpen && mentionCandidates.length > 0 && (
<div className="mention-dropdown" data-testid="mention-dropdown">
{mentionCandidates.map((s) => (
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react'
import type cytoscape from 'cytoscape'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useGraphStore } from '@renderer/stores/useGraphStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { initCytoscape, runLayout, type GraphSelection, INTIMACY_COLORS } from '@renderer/lib/graph-cytoscape'
export function CharacterGraphModal(): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const { open, data, loading, filter, close, load, setFilter } = useGraphStore()
const switchTarget = useEditStore((s) => s.switchTarget)
const cyRef = useRef<cytoscape.Core | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [selection, setSelection] = useState<GraphSelection>(null)
useEffect(() => {
if (!open || !bookId) return
void load(bookId, filter)
}, [open, bookId, filter, load])
useEffect(() => {
if (!open || !data || !containerRef.current) return
cyRef.current = initCytoscape(containerRef.current, data, setSelection)
return () => {
cyRef.current?.destroy()
cyRef.current = null
setSelection(null)
}
}, [open, data])
if (!open) return null
const handleLayout = (name: 'cose-bilkent' | 'grid' | 'circle'): void => {
if (cyRef.current) runLayout(cyRef.current, name)
}
const handleEditSetting = (): void => {
if (selection?.kind === 'node' && bookId) {
close()
void switchTarget({ kind: 'setting', id: selection.node.id })
}
}
return (
<div className="modal-overlay character-graph-modal" data-testid="character-graph-modal">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('graph.title')}</h3>
<button type="button" className="modal-close" onClick={close}>
</button>
</div>
<div className="graph-toolbar">
<button
type="button"
data-testid="graph-layout-cose"
onClick={() => handleLayout('cose-bilkent')}
>
{t('graph.layout.cose')}
</button>
<button type="button" data-testid="graph-layout-grid" onClick={() => handleLayout('grid')}>
{t('graph.layout.grid')}
</button>
<button
type="button"
data-testid="graph-layout-circle"
onClick={() => handleLayout('circle')}
>
{t('graph.layout.circle')}
</button>
<select
className="form-control"
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'connected')}
>
<option value="all">{t('graph.filter.all')}</option>
<option value="connected">{t('graph.filter.connected')}</option>
</select>
</div>
{data?.truncated && (
<div className="graph-truncated-notice">{t('graph.truncated', { count: 100 })}</div>
)}
<div className="graph-body">
<div
id="character-graph-cy"
ref={containerRef}
data-testid="character-graph-cy"
/>
<aside className="graph-sidebar" data-testid="character-graph-sidebar">
{loading && <div className="sidebar-empty">{t('cockpit.loading')}</div>}
{!loading && !selection && (
<div className="sidebar-empty">{t('graph.sidebar.hint')}</div>
)}
{selection?.kind === 'node' && (
<>
<div>{t('graph.sidebar.node', { name: selection.node.label })}</div>
<button type="button" className="btn" onClick={handleEditSetting}>
{t('graph.editSetting')}
</button>
</>
)}
{selection?.kind === 'edge' && (
<div>
{t('graph.sidebar.edge', { label: selection.edge.label })}
<div className="graph-intimacy-bar">
<span
style={{
background: INTIMACY_COLORS[selection.edge.intimacy],
width: `${selection.edge.intimacy * 20}%`
}}
/>
</div>
<div>{t('relationship.intimacy')}: {selection.edge.intimacy}</div>
</div>
)}
</aside>
</div>
</div>
</div>
)
}
@@ -23,6 +23,7 @@ import { InspirationList } from '@renderer/components/inspiration/InspirationLis
import { VersionModal } from '@renderer/components/version/VersionModal'
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import {
editorDirtyAtom,
@@ -107,6 +108,7 @@ export function EditorLayout(): React.JSX.Element {
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const settings = useBookStore((s) => s.settings)
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
@@ -120,6 +122,20 @@ export function EditorLayout(): React.JSX.Element {
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
const isChapterTarget = target?.kind === 'chapter'
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
const currentChapter = isChapterTarget && chapterId ? chapters.find((c) => c.id === chapterId) : null
const characterSettings = settings.filter((s) => s.type === 'character')
const handlePovChange = async (povCharacterId: string | null): Promise<void> => {
if (!currentBookId || !chapterId) return
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId: currentBookId,
chapterId,
povCharacterId
})
)
updateChapterLocal(updated)
}
useEffect(() => {
void pomodoroInit()
@@ -507,6 +523,21 @@ export function EditorLayout(): React.JSX.Element {
)}
{isChapterTarget ? (
<>
<label className="status-pov-label">
{t('chapter.pov')}
<select
data-testid="chapter-pov-select"
value={currentChapter?.povCharacterId ?? ''}
onChange={(e) => void handlePovChange(e.target.value || null)}
>
<option value="">{t('analytics.povUnassigned')}</option>
{characterSettings.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
@@ -532,6 +563,7 @@ export function EditorLayout(): React.JSX.Element {
setBridgeOpen(true)
}}
/>
<CharacterGraphModal />
<ChapterBridgeModal
open={bridgeOpen}
chapterId={activeBridgeChapterId}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import type { SettingType } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useGraphStore } from '@renderer/stores/useGraphStore'
import { ipcCall } from '@renderer/lib/ipc-client'
const SETTING_TYPES: SettingType[] = [
@@ -84,6 +85,13 @@ export function SettingList(): React.JSX.Element {
})}
</div>
<div className="sidebar-footer">
<button
type="button"
data-testid="setting-open-graph"
onClick={() => useGraphStore.getState().openModal()}
>
{t('graph.title')}
</button>
<button type="button" data-testid="setting-new" onClick={() => setShowDialog(true)}>
+ {t('setting.new')}
</button>
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { CharacterRelationship, SettingEntry } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface SettingRelationshipsEditorProps {
entry: SettingEntry
bookId: string
}
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
const raw = entry.properties?.relationships
if (!Array.isArray(raw)) return []
return raw.filter(
(r): r is CharacterRelationship =>
r != null &&
typeof r === 'object' &&
typeof (r as CharacterRelationship).id === 'string' &&
typeof (r as CharacterRelationship).targetId === 'string'
)
}
export function SettingRelationshipsEditor({
entry,
bookId
}: SettingRelationshipsEditorProps): React.JSX.Element {
const { t } = useTranslation()
const settings = useBookStore((s) => s.settings)
const updateSettingLocal = useBookStore((s) => s.updateSettingLocal)
const characters = settings.filter((s) => s.type === 'character' && s.id !== entry.id)
const relationships = parseRelationships(entry)
const [showForm, setShowForm] = useState(false)
const [targetId, setTargetId] = useState('')
const [label, setLabel] = useState('')
const [intimacy, setIntimacy] = useState(3)
const targetName = (id: string): string => settings.find((s) => s.id === id)?.name ?? id
const syncLocal = (nextRels: CharacterRelationship[]): void => {
updateSettingLocal({
...entry,
properties: { ...entry.properties, relationships: nextRels }
})
}
const handleAdd = async (): Promise<void> => {
if (!targetId || !label.trim()) return
const rel = await ipcCall(() =>
window.electronAPI.graph.upsertRelationship(bookId, entry.id, {
targetId,
label: label.trim(),
intimacy
})
)
syncLocal([...relationships.filter((r) => r.id !== rel.id), rel])
setShowForm(false)
setTargetId('')
setLabel('')
setIntimacy(3)
}
const handleDelete = async (relationshipId: string): Promise<void> => {
await ipcCall(() =>
window.electronAPI.graph.deleteRelationship(bookId, entry.id, relationshipId)
)
syncLocal(relationships.filter((r) => r.id !== relationshipId))
}
return (
<div className="setting-relationships" data-testid="setting-relationships">
<h4>{t('relationship.title')}</h4>
{relationships.map((r) => (
<div key={r.id} className="setting-relationship-row">
<span>
{targetName(r.targetId)} · {r.label} · {r.intimacy}
</span>
<button type="button" aria-label="delete" onClick={() => void handleDelete(r.id)}>
</button>
</div>
))}
{showForm ? (
<div className="setting-relationship-form">
<select
className="form-control"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
>
<option value="">{t('relationship.target')}</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<input
className="form-control"
placeholder={t('relationship.label')}
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
<label>
{t('relationship.intimacy')}
<input
type="range"
min={1}
max={5}
value={intimacy}
onChange={(e) => setIntimacy(Number(e.target.value))}
/>
{intimacy}
</label>
<button type="button" className="btn btn-primary" onClick={() => void handleAdd()}>
{t('dialog.save')}
</button>
<button type="button" className="btn" onClick={() => setShowForm(false)}>
{t('dialog.cancel')}
</button>
</div>
) : (
<button
type="button"
data-testid="setting-relationship-add"
onClick={() => setShowForm(true)}
>
+ {t('relationship.add')}
</button>
)}
</div>
)
}
@@ -228,7 +228,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.9.0
{t('app.name')} v1.0.0
<br />
{t('app.tagline')}
</p>
+114
View File
@@ -0,0 +1,114 @@
import cytoscape from 'cytoscape'
import coseBilkent from 'cytoscape-cose-bilkent'
import type { CharacterGraphData, CharacterGraphEdge, CharacterGraphNode } from '@shared/types'
cytoscape.use(coseBilkent)
const INTIMACY_COLORS: Record<number, string> = {
1: '#4ea8de',
2: '#45b7aa',
3: '#3fb950',
4: '#f0883e',
5: '#e0556a'
}
export type GraphSelection =
| { kind: 'node'; node: CharacterGraphNode }
| { kind: 'edge'; edge: CharacterGraphEdge }
| null
function toElements(data: CharacterGraphData): cytoscape.ElementDefinition[] {
const nodes: cytoscape.ElementDefinition[] = data.nodes.map((n) => ({
data: { id: n.id, label: n.label }
}))
const edges: cytoscape.ElementDefinition[] = data.edges.map((e) => ({
data: {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
intimacy: e.intimacy
}
}))
return [...nodes, ...edges]
}
export function initCytoscape(
container: HTMLElement,
data: CharacterGraphData,
onSelect: (sel: GraphSelection) => void
): cytoscape.Core {
const intimacyStyles: cytoscape.Stylesheet[] = [1, 2, 3, 4, 5].map((level) => ({
selector: `edge[intimacy = ${level}]`,
style: {
'line-color': INTIMACY_COLORS[level],
'target-arrow-color': INTIMACY_COLORS[level]
}
}))
const cy = cytoscape({
container,
elements: toElements(data),
style: [
{
selector: 'node',
style: {
label: 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'background-color': '#6e7681',
color: '#fff',
'font-size': 11,
width: 36,
height: 36
}
},
{
selector: 'edge',
style: {
label: 'data(label)',
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'line-color': '#888',
'target-arrow-color': '#888',
width: 2,
'font-size': 9,
color: '#666'
}
},
...intimacyStyles,
{
selector: ':selected',
style: {
'border-width': 2,
'border-color': '#58a6ff'
}
}
],
layout: { name: 'cose-bilkent', animate: false }
})
cy.on('tap', 'node', (evt) => {
const id = evt.target.id()
const node = data.nodes.find((n) => n.id === id)
if (node) onSelect({ kind: 'node', node })
})
cy.on('tap', 'edge', (evt) => {
const id = evt.target.id()
const edge = data.edges.find((e) => e.id === id)
if (edge) onSelect({ kind: 'edge', edge })
})
cy.on('tap', (evt) => {
if (evt.target === cy) onSelect(null)
})
return cy
}
export function runLayout(cy: cytoscape.Core, name: 'cose-bilkent' | 'grid' | 'circle'): void {
cy.layout({ name, animate: true }).run()
}
export { INTIMACY_COLORS }
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand'
import type { CharacterGraphData } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface GraphState {
open: boolean
data: CharacterGraphData | null
loading: boolean
filter: 'all' | 'connected'
openModal: () => void
close: () => void
load: (bookId: string, filter?: 'all' | 'connected') => Promise<void>
setFilter: (filter: 'all' | 'connected') => void
}
export const useGraphStore = create<GraphState>((set, get) => ({
open: false,
data: null,
loading: false,
filter: 'all',
openModal: () => set({ open: true }),
close: () => set({ open: false, data: null }),
setFilter: (filter) => set({ filter }),
load: async (bookId, filter) => {
const f = filter ?? get().filter
set({ loading: true })
try {
const data = await ipcCall(() => window.electronAPI.graph.getData(bookId, f))
set({ data, filter: f })
} finally {
set({ loading: false })
}
}
}))
+143
View File
@@ -1844,3 +1844,146 @@
opacity: 0.45;
cursor: not-allowed;
}
.character-graph-modal .modal-content {
max-width: 95vw;
width: 1100px;
}
.graph-toolbar {
display: flex;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
}
.graph-body {
display: flex;
min-height: 480px;
}
#character-graph-cy {
min-height: 480px;
flex: 1;
background: var(--bg-secondary);
}
.graph-sidebar {
width: 260px;
padding: 12px;
border-left: 1px solid var(--border);
font-size: 13px;
}
.graph-truncated-notice {
padding: 8px 16px;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-tertiary);
}
.setting-relationships {
margin-top: 12px;
padding: 12px;
border-top: 1px solid var(--border);
}
.setting-relationship-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 13px;
}
.setting-relationship-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.cockpit-analytics {
margin-top: 16px;
display: grid;
gap: 12px;
}
.cockpit-analytics-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.hourly-heatmap {
display: flex;
gap: 2px;
height: 32px;
}
.hourly-heatmap-cell {
flex: 1;
border-radius: 2px;
background: var(--bg-tertiary);
}
.analytics-bar-row {
display: grid;
grid-template-columns: 80px 1fr 40px;
gap: 8px;
align-items: center;
font-size: 12px;
margin-bottom: 4px;
}
.analytics-bar-track {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.pov-bar,
.volume-bar {
height: 8px;
background: var(--accent);
border-radius: 4px;
}
.week-trend-bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 52px;
}
.week-trend-bar {
flex: 1;
min-width: 8px;
background: var(--accent);
border-radius: 2px 2px 0 0;
}
.status-pov-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-pov-label select {
font-size: 11px;
}
.graph-intimacy-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
margin: 8px 0;
overflow: hidden;
}
.graph-intimacy-bar span {
display: block;
height: 100%;
border-radius: 3px;
}
+24 -1
View File
@@ -49,7 +49,10 @@ import type {
GoalNotificationPayload,
KnowledgeInjectionLog,
WritingAchievement,
PomodoroDuration
PomodoroDuration,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary
} from './types'
export interface ElectronAPI {
@@ -387,6 +390,26 @@ export interface ElectronAPI {
markSeen: (bookId: string) => Promise<IpcResult<void>>
shouldShowOnOpen: (bookId: string) => Promise<IpcResult<boolean>>
}
graph: {
getData: (bookId: string, filter?: 'all' | 'connected') => Promise<IpcResult<CharacterGraphData>>
upsertRelationship: (
bookId: string,
sourceId: string,
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
) => Promise<IpcResult<CharacterRelationship>>
deleteRelationship: (
bookId: string,
sourceId: string,
relationshipId: string
) => Promise<IpcResult<void>>
}
analytics: {
getSummary: (bookId: string) => Promise<IpcResult<WritingAnalyticsSummary>>
}
bridge: {
get: (
bookId: string,
+4
View File
@@ -126,6 +126,10 @@ export const IPC = {
ACHIEVEMENT_LIST: 'achievement:list',
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
GOAL_NOTIFICATION: 'goal:notification',
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
+39
View File
@@ -160,6 +160,44 @@ export interface GoalNotificationPayload {
messageParams?: Record<string, string | number>
}
export interface CharacterRelationship {
id: string
targetId: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphNode {
id: string
label: string
}
export interface CharacterGraphEdge {
id: string
source: string
target: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphData {
nodes: CharacterGraphNode[]
edges: CharacterGraphEdge[]
truncated: boolean
nodeCount: number
}
export interface WritingAnalyticsSummary {
todayWords: number
todaySpeedWpm: number | null
weekTrend: { date: string; words: number }[]
hourlyHeatmap: number[]
pomodoroToday: number
pomodoroAvgWords: number | null
povDistribution: { characterId: string; name: string; words: number }[]
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
@@ -340,6 +378,7 @@ export interface UpdateChapterParams {
content?: string
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
}
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'