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
@@ -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}