⚗ writing telemetry
This commit is contained in:
@@ -47,6 +47,18 @@ export interface RevisionMeta {
|
|||||||
wordCount: number
|
wordCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetrySession {
|
||||||
|
id: string
|
||||||
|
storyId: string
|
||||||
|
date: string // YYYY-MM-DD local date
|
||||||
|
startedAt: number // ms timestamp
|
||||||
|
endedAt: number // ms timestamp
|
||||||
|
wordsStart: number
|
||||||
|
wordsEnd: number
|
||||||
|
activeMs: number // typing time, excluding idle gaps
|
||||||
|
wpm: number
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const borgesDir = (): string => join(getCollectionRoot(), '.borges')
|
const borgesDir = (): string => join(getCollectionRoot(), '.borges')
|
||||||
@@ -56,6 +68,7 @@ const sessionFile = (): string => join(borgesDir(), 'session.json')
|
|||||||
const marketsFile = (): string => join(borgesDir(), 'markets.json')
|
const marketsFile = (): string => join(borgesDir(), 'markets.json')
|
||||||
const submissionsFile = (): string => join(borgesDir(), 'submissions.json')
|
const submissionsFile = (): string => join(borgesDir(), 'submissions.json')
|
||||||
const revisionsDir = (): string => join(borgesDir(), 'revisions')
|
const revisionsDir = (): string => join(borgesDir(), 'revisions')
|
||||||
|
const telemetryFile = (): string => join(borgesDir(), 'telemetry.json')
|
||||||
|
|
||||||
const MAX_REVISIONS = 50
|
const MAX_REVISIONS = 50
|
||||||
|
|
||||||
@@ -320,3 +333,23 @@ export async function loadRevision(filePath: string, revisionId: string): Promis
|
|||||||
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
|
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
|
||||||
return raw.content
|
return raw.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function appendTelemetrySession(session: TelemetrySession): Promise<void> {
|
||||||
|
await mkdir(borgesDir(), { recursive: true })
|
||||||
|
let sessions: TelemetrySession[] = []
|
||||||
|
try {
|
||||||
|
sessions = JSON.parse(await readFile(telemetryFile(), 'utf-8'))
|
||||||
|
} catch { /* first write */ }
|
||||||
|
sessions.push(session)
|
||||||
|
await writeFile(telemetryFile(), JSON.stringify(sessions, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTelemetry(): Promise<TelemetrySession[]> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(telemetryFile(), 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
saveOrderList, readSession, writeSession,
|
saveOrderList, readSession, writeSession,
|
||||||
listMarkets, upsertMarket, deleteMarket,
|
listMarkets, upsertMarket, deleteMarket,
|
||||||
listSubmissions, addSubmission, updateSubmission,
|
listSubmissions, addSubmission, updateSubmission,
|
||||||
saveRevision, listRevisions, loadRevision
|
saveRevision, listRevisions, loadRevision,
|
||||||
|
appendTelemetrySession, readTelemetry
|
||||||
} from './fileSystem'
|
} from './fileSystem'
|
||||||
import type { Market, Submission, StoryMeta } from './fileSystem'
|
import type { Market, Submission, StoryMeta, TelemetrySession } from './fileSystem'
|
||||||
import { streamMessage, streamPrompt, resetClient } from './aiService'
|
import { streamMessage, streamPrompt, resetClient } from './aiService'
|
||||||
import type { AIPayload } from './aiService'
|
import type { AIPayload } from './aiService'
|
||||||
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
||||||
@@ -48,6 +49,10 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('revisions:list', async (_e, path: string) => listRevisions(path))
|
ipcMain.handle('revisions:list', async (_e, path: string) => listRevisions(path))
|
||||||
ipcMain.handle('revisions:load', async (_e, path: string, id: string) => loadRevision(path, id))
|
ipcMain.handle('revisions:load', async (_e, path: string, id: string) => loadRevision(path, id))
|
||||||
|
|
||||||
|
// ── Telemetry ─────────────────────────────────────────────────────────────────
|
||||||
|
ipcMain.handle('telemetry:append', async (_e, session: TelemetrySession) => appendTelemetrySession(session))
|
||||||
|
ipcMain.handle('telemetry:read', async () => readTelemetry())
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
ipcMain.handle('config:read', async () => readGlobalConfig())
|
ipcMain.handle('config:read', async () => readGlobalConfig())
|
||||||
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
|
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { Market, Submission, StoryFile, StoryMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
|
import type { Market, Submission, StoryFile, StoryMeta, RevisionMeta, CollectionConfig, TelemetrySession } from '../main/fileSystem'
|
||||||
import type { AIPayload } from '../main/aiService'
|
import type { AIPayload } from '../main/aiService'
|
||||||
import type { GlobalConfig } from '../main/globalConfig'
|
import type { GlobalConfig } from '../main/globalConfig'
|
||||||
|
|
||||||
@@ -87,6 +87,10 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
appendTelemetrySession: (session: TelemetrySession): Promise<void> => ipcRenderer.invoke('telemetry:append', session),
|
||||||
|
readTelemetry: (): Promise<TelemetrySession[]> => ipcRenderer.invoke('telemetry:read'),
|
||||||
|
|
||||||
// Native context menus
|
// Native context menus
|
||||||
showEditorContextMenu: (): Promise<void> => ipcRenderer.invoke('menu:editorContext'),
|
showEditorContextMenu: (): Promise<void> => ipcRenderer.invoke('menu:editorContext'),
|
||||||
showStoryContextMenu: (storyId: string): Promise<string | null> => ipcRenderer.invoke('menu:storyContext', storyId),
|
showStoryContextMenu: (storyId: string): Promise<string | null> => ipcRenderer.invoke('menu:storyContext', storyId),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useBorgesStore } from '../../store/borgesStore'
|
import { useBorgesStore } from '../../store/borgesStore'
|
||||||
import { PromptHero } from './PromptHero'
|
import { PromptHero } from './PromptHero'
|
||||||
|
import { WritingStats } from './WritingStats'
|
||||||
|
|
||||||
function daysSince(iso: string): number {
|
function daysSince(iso: string): number {
|
||||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
|
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
|
||||||
@@ -99,6 +100,9 @@ export function Dashboard(): JSX.Element {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Writing stats */}
|
||||||
|
<WritingStats stories={stories} />
|
||||||
|
|
||||||
{/* Word count overview */}
|
{/* Word count overview */}
|
||||||
<div className="dashboard-card">
|
<div className="dashboard-card">
|
||||||
<div className="dashboard-card-title">Word counts</div>
|
<div className="dashboard-card-title">Word counts</div>
|
||||||
|
|||||||
173
src/renderer/components/Dashboard/WritingStats.tsx
Normal file
173
src/renderer/components/Dashboard/WritingStats.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { TelemetrySession } from '../../types/borges'
|
||||||
|
import type { StoryFile } from '../../types/borges'
|
||||||
|
|
||||||
|
interface StoryStats {
|
||||||
|
storyId: string
|
||||||
|
title: string
|
||||||
|
sessions: number
|
||||||
|
wordsWritten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalSessions: number
|
||||||
|
totalWordsWritten: number
|
||||||
|
totalWritingDays: number
|
||||||
|
currentStreak: number
|
||||||
|
longestStreak: number
|
||||||
|
avgWpm: number
|
||||||
|
bestWpm: number
|
||||||
|
byStory: StoryStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStats(sessions: TelemetrySession[], stories: StoryFile[]): Stats {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return { totalSessions: 0, totalWordsWritten: 0, totalWritingDays: 0, currentStreak: 0, longestStreak: 0, avgWpm: 0, bestWpm: 0, byStory: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMap = new Map(stories.map((s) => [s.id, s.meta.title || s.id]))
|
||||||
|
|
||||||
|
const writingDaySet = new Set(sessions.map((s) => s.date))
|
||||||
|
const writingDays = [...writingDaySet].sort()
|
||||||
|
|
||||||
|
// Streak calculation (calendar days)
|
||||||
|
let currentStreak = 0
|
||||||
|
let longestStreak = 0
|
||||||
|
let streak = 1
|
||||||
|
const today = localDate(Date.now())
|
||||||
|
const yesterday = localDate(Date.now() - 86_400_000)
|
||||||
|
|
||||||
|
for (let i = 1; i < writingDays.length; i++) {
|
||||||
|
const prev = new Date(writingDays[i - 1])
|
||||||
|
const curr = new Date(writingDays[i])
|
||||||
|
const diff = (curr.getTime() - prev.getTime()) / 86_400_000
|
||||||
|
if (diff === 1) {
|
||||||
|
streak++
|
||||||
|
} else {
|
||||||
|
longestStreak = Math.max(longestStreak, streak)
|
||||||
|
streak = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longestStreak = Math.max(longestStreak, streak)
|
||||||
|
|
||||||
|
const lastDay = writingDays[writingDays.length - 1]
|
||||||
|
if (lastDay === today || lastDay === yesterday) {
|
||||||
|
// Walk back to find current streak length
|
||||||
|
currentStreak = 1
|
||||||
|
for (let i = writingDays.length - 2; i >= 0; i--) {
|
||||||
|
const next = new Date(writingDays[i + 1])
|
||||||
|
const curr = new Date(writingDays[i])
|
||||||
|
if ((next.getTime() - curr.getTime()) / 86_400_000 === 1) {
|
||||||
|
currentStreak++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWordsWritten = sessions.reduce((sum, s) => sum + Math.max(0, s.wordsEnd - s.wordsStart), 0)
|
||||||
|
const wpmSessions = sessions.filter((s) => s.wpm > 0)
|
||||||
|
const avgWpm = wpmSessions.length > 0 ? Math.round(wpmSessions.reduce((sum, s) => sum + s.wpm, 0) / wpmSessions.length) : 0
|
||||||
|
const bestWpm = wpmSessions.length > 0 ? Math.max(...wpmSessions.map((s) => s.wpm)) : 0
|
||||||
|
|
||||||
|
// Per-story aggregation
|
||||||
|
const storyMap = new Map<string, StoryStats>()
|
||||||
|
for (const s of sessions) {
|
||||||
|
if (!storyMap.has(s.storyId)) {
|
||||||
|
storyMap.set(s.storyId, { storyId: s.storyId, title: titleMap.get(s.storyId) || s.storyId, sessions: 0, wordsWritten: 0 })
|
||||||
|
}
|
||||||
|
const entry = storyMap.get(s.storyId)!
|
||||||
|
entry.sessions++
|
||||||
|
entry.wordsWritten += Math.max(0, s.wordsEnd - s.wordsStart)
|
||||||
|
}
|
||||||
|
const byStory = [...storyMap.values()].sort((a, b) => b.wordsWritten - a.wordsWritten)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions: sessions.length,
|
||||||
|
totalWordsWritten,
|
||||||
|
totalWritingDays: writingDaySet.size,
|
||||||
|
currentStreak,
|
||||||
|
longestStreak,
|
||||||
|
avgWpm,
|
||||||
|
bestWpm,
|
||||||
|
byStory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WritingStatsProps {
|
||||||
|
stories: StoryFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WritingStats({ stories }: WritingStatsProps): JSX.Element {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.readTelemetry().then((sessions) => {
|
||||||
|
setStats(computeStats(sessions, stories))
|
||||||
|
}).catch(() => setStats(computeStats([], stories)))
|
||||||
|
}, [stories])
|
||||||
|
|
||||||
|
if (!stats) return <div className="dashboard-card"><div className="dashboard-card-title">Writing stats</div><div className="dashboard-empty">Loading…</div></div>
|
||||||
|
|
||||||
|
if (stats.totalSessions === 0) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-card">
|
||||||
|
<div className="dashboard-card-title">Writing stats</div>
|
||||||
|
<div className="dashboard-empty">No writing sessions yet. Start typing to begin tracking.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-card writing-stats-card">
|
||||||
|
<div className="dashboard-card-title">Writing stats</div>
|
||||||
|
|
||||||
|
<div className="writing-stats-grid">
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.totalWordsWritten.toLocaleString()}</div>
|
||||||
|
<div className="writing-stat-label">words written</div>
|
||||||
|
</div>
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.totalSessions}</div>
|
||||||
|
<div className="writing-stat-label">sessions</div>
|
||||||
|
</div>
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.currentStreak}</div>
|
||||||
|
<div className="writing-stat-label">day streak</div>
|
||||||
|
</div>
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.longestStreak}</div>
|
||||||
|
<div className="writing-stat-label">longest streak</div>
|
||||||
|
</div>
|
||||||
|
{stats.avgWpm > 0 && (
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.avgWpm}</div>
|
||||||
|
<div className="writing-stat-label">avg wpm</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.bestWpm > 0 && (
|
||||||
|
<div className="writing-stat">
|
||||||
|
<div className="writing-stat-value">{stats.bestWpm}</div>
|
||||||
|
<div className="writing-stat-label">best wpm</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.byStory.length > 0 && (
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
{stats.byStory.slice(0, 6).map((s) => (
|
||||||
|
<div key={s.storyId} className="dashboard-row" style={{ cursor: 'default' }}>
|
||||||
|
<span className="dashboard-row-title">{s.title}</span>
|
||||||
|
<span className="dashboard-row-meta">{s.wordsWritten.toLocaleString()}w · {s.sessions} {s.sessions === 1 ? 'session' : 'sessions'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -78,6 +78,22 @@ const markdownHighlight = HighlightStyle.define([
|
|||||||
{ tag: tags.comment, color: 'var(--text3)' },
|
{ tag: tags.comment, color: 'var(--text3)' },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const IDLE_MS = 2 * 60 * 1000 // 2 min idle ends active-typing interval
|
||||||
|
const SESSION_FLUSH_MS = 10 * 60 * 1000 // 10 min total idle flushes session
|
||||||
|
|
||||||
|
function wordCount(text: string): number {
|
||||||
|
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 9)
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownEditor(): JSX.Element {
|
export function MarkdownEditor(): JSX.Element {
|
||||||
const { activeStoryPath, activeStoryContent, activeStoryId,
|
const { activeStoryPath, activeStoryContent, activeStoryId,
|
||||||
annotations, theme, fontSize, revisionPanelOpen, toggleRevisionPanel, revisions, setRevisions } = useBorgesStore()
|
annotations, theme, fontSize, revisionPanelOpen, toggleRevisionPanel, revisions, setRevisions } = useBorgesStore()
|
||||||
@@ -87,6 +103,70 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
const storeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const storeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [storyPrompt, setStoryPrompt] = useState<string | null>(null)
|
const [storyPrompt, setStoryPrompt] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// ── Telemetry session state ──────────────────────────────────────────────────
|
||||||
|
const sessionRef = useRef<{
|
||||||
|
storyId: string
|
||||||
|
startedAt: number
|
||||||
|
wordsStart: number
|
||||||
|
activeMs: number // accumulated active typing ms
|
||||||
|
intervalStart: number // start of current active interval
|
||||||
|
lastKeystroke: number // last doc change timestamp
|
||||||
|
} | null>(null)
|
||||||
|
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
function flushSession(): void {
|
||||||
|
const s = sessionRef.current
|
||||||
|
if (!s) return
|
||||||
|
const now = Date.now()
|
||||||
|
// Close active interval if still ongoing
|
||||||
|
const activeMs = s.activeMs + (now - s.lastKeystroke < IDLE_MS ? now - s.intervalStart : 0)
|
||||||
|
const wordsEnd = wordCount(useBorgesStore.getState().activeStoryContent)
|
||||||
|
const durationMs = now - s.startedAt
|
||||||
|
const wpm = activeMs > 0 ? Math.round((wordsEnd - s.wordsStart) / (activeMs / 60_000)) : 0
|
||||||
|
sessionRef.current = null
|
||||||
|
if (flushTimerRef.current) { clearTimeout(flushTimerRef.current); flushTimerRef.current = null }
|
||||||
|
// Only persist if something was actually written
|
||||||
|
if (wordsEnd - s.wordsStart <= 0 && durationMs < 5000) return
|
||||||
|
window.api.appendTelemetrySession({
|
||||||
|
id: shortId(),
|
||||||
|
storyId: s.storyId,
|
||||||
|
date: localDate(s.startedAt),
|
||||||
|
startedAt: s.startedAt,
|
||||||
|
endedAt: now,
|
||||||
|
wordsStart: s.wordsStart,
|
||||||
|
wordsEnd,
|
||||||
|
activeMs,
|
||||||
|
wpm: Math.max(0, wpm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocChanged(storyId: string, content: string): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!sessionRef.current || sessionRef.current.storyId !== storyId) {
|
||||||
|
// Start a fresh session
|
||||||
|
flushSession()
|
||||||
|
sessionRef.current = {
|
||||||
|
storyId,
|
||||||
|
startedAt: now,
|
||||||
|
wordsStart: wordCount(content),
|
||||||
|
activeMs: 0,
|
||||||
|
intervalStart: now,
|
||||||
|
lastKeystroke: now,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const s = sessionRef.current
|
||||||
|
if (now - s.lastKeystroke > IDLE_MS) {
|
||||||
|
// Resume after idle: close previous interval, start new one
|
||||||
|
s.activeMs += s.lastKeystroke - s.intervalStart
|
||||||
|
s.intervalStart = now
|
||||||
|
}
|
||||||
|
s.lastKeystroke = now
|
||||||
|
}
|
||||||
|
// Reset flush-on-idle timer
|
||||||
|
if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
|
||||||
|
flushTimerRef.current = setTimeout(flushSession, SESSION_FLUSH_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize editor
|
// Initialize editor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,6 +196,8 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
storeTimerRef.current = setTimeout(() => {
|
storeTimerRef.current = setTimeout(() => {
|
||||||
useBorgesStore.getState().setContent(content)
|
useBorgesStore.getState().setContent(content)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
const { activeStoryId } = useBorgesStore.getState()
|
||||||
|
if (activeStoryId) onDocChanged(activeStoryId, content)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
@@ -144,7 +226,14 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
parent: editorRef.current
|
parent: editorRef.current
|
||||||
})
|
})
|
||||||
viewRef.current = view
|
viewRef.current = view
|
||||||
return () => { view.destroy(); viewRef.current = null }
|
const handleBlur = (): void => flushSession()
|
||||||
|
window.addEventListener('blur', handleBlur)
|
||||||
|
return () => {
|
||||||
|
flushSession()
|
||||||
|
window.removeEventListener('blur', handleBlur)
|
||||||
|
view.destroy()
|
||||||
|
viewRef.current = null
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Sync content when active story changes
|
// Sync content when active story changes
|
||||||
@@ -152,6 +241,7 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
if (!view) return
|
if (!view) return
|
||||||
if (activeStoryPath !== lastPathRef.current) {
|
if (activeStoryPath !== lastPathRef.current) {
|
||||||
|
flushSession()
|
||||||
lastPathRef.current = activeStoryPath
|
lastPathRef.current = activeStoryPath
|
||||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: activeStoryContent } })
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: activeStoryContent } })
|
||||||
view.dispatch({ effects: setAnnotationsEffect.of([]) })
|
view.dispatch({ effects: setAnnotationsEffect.of([]) })
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ textarea { resize: vertical; }
|
|||||||
.dashboard-row-meta { font-size: 11px; color: var(--text3); flex-shrink: 0; }
|
.dashboard-row-meta { font-size: 11px; color: var(--text3); flex-shrink: 0; }
|
||||||
.dashboard-row-flag { font-size: 11px; color: var(--warn); flex-shrink: 0; }
|
.dashboard-row-flag { font-size: 11px; color: var(--warn); flex-shrink: 0; }
|
||||||
.dashboard-empty { font-size: 13px; color: var(--text3); padding: 8px 0; }
|
.dashboard-empty { font-size: 13px; color: var(--text3); padding: 8px 0; }
|
||||||
|
.writing-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-bottom: 4px; }
|
||||||
|
.writing-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.writing-stat-value { font-size: 22px; font-weight: 300; color: var(--text); line-height: 1; }
|
||||||
|
.writing-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text3); }
|
||||||
|
|
||||||
/* ── Prompt hero ──────────────────────────────────────────────────────────── */
|
/* ── Prompt hero ──────────────────────────────────────────────────────────── */
|
||||||
.prompt-hero {
|
.prompt-hero {
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ export interface RevisionMeta {
|
|||||||
wordCount: number
|
wordCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetrySession {
|
||||||
|
id: string
|
||||||
|
storyId: string
|
||||||
|
date: string
|
||||||
|
startedAt: number
|
||||||
|
endedAt: number
|
||||||
|
wordsStart: number
|
||||||
|
wordsEnd: number
|
||||||
|
activeMs: number
|
||||||
|
wpm: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
|
|||||||
4
src/renderer/types/global.d.ts
vendored
4
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { StoryFile, StoryMeta, Market, Submission, RevisionMeta } from './borges'
|
import type { StoryFile, StoryMeta, Market, Submission, RevisionMeta, TelemetrySession } from './borges'
|
||||||
|
|
||||||
type AnalysisModeAI = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
|
type AnalysisModeAI = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
|
||||||
|
|
||||||
@@ -53,6 +53,8 @@ declare global {
|
|||||||
saveRevision(path: string, content: string): Promise<void>
|
saveRevision(path: string, content: string): Promise<void>
|
||||||
listRevisions(path: string): Promise<RevisionMeta[]>
|
listRevisions(path: string): Promise<RevisionMeta[]>
|
||||||
loadRevision(path: string, id: string): Promise<string>
|
loadRevision(path: string, id: string): Promise<string>
|
||||||
|
appendTelemetrySession(session: TelemetrySession): Promise<void>
|
||||||
|
readTelemetry(): Promise<TelemetrySession[]>
|
||||||
readConfig(): Promise<GlobalConfig>
|
readConfig(): Promise<GlobalConfig>
|
||||||
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||||
pickFolder(): Promise<string | null>
|
pickFolder(): Promise<string | null>
|
||||||
|
|||||||
Reference in New Issue
Block a user