diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index 8394e62..c84c59e 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -47,6 +47,18 @@ export interface RevisionMeta { 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 ───────────────────────────────────────────────────────────── 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 submissionsFile = (): string => join(borgesDir(), 'submissions.json') const revisionsDir = (): string => join(borgesDir(), 'revisions') +const telemetryFile = (): string => join(borgesDir(), 'telemetry.json') 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')) return raw.content } + +// ─── Telemetry ──────────────────────────────────────────────────────────────── + +export async function appendTelemetrySession(session: TelemetrySession): Promise { + 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 { + try { + return JSON.parse(await readFile(telemetryFile(), 'utf-8')) + } catch { + return [] + } +} diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index d3928f7..e887e11 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -5,9 +5,10 @@ import { saveOrderList, readSession, writeSession, listMarkets, upsertMarket, deleteMarket, listSubmissions, addSubmission, updateSubmission, - saveRevision, listRevisions, loadRevision + saveRevision, listRevisions, loadRevision, + appendTelemetrySession, readTelemetry } from './fileSystem' -import type { Market, Submission, StoryMeta } from './fileSystem' +import type { Market, Submission, StoryMeta, TelemetrySession } from './fileSystem' import { streamMessage, streamPrompt, resetClient } from './aiService' import type { AIPayload } from './aiService' 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: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 ──────────────────────────────────────────────────────────────────── ipcMain.handle('config:read', async () => readGlobalConfig()) ipcMain.handle('config:write', async (_e, updates: Partial) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 306962d..8a85f92 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ 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 { GlobalConfig } from '../main/globalConfig' @@ -87,6 +87,10 @@ contextBridge.exposeInMainWorld('api', { }) }, + // Telemetry + appendTelemetrySession: (session: TelemetrySession): Promise => ipcRenderer.invoke('telemetry:append', session), + readTelemetry: (): Promise => ipcRenderer.invoke('telemetry:read'), + // Native context menus showEditorContextMenu: (): Promise => ipcRenderer.invoke('menu:editorContext'), showStoryContextMenu: (storyId: string): Promise => ipcRenderer.invoke('menu:storyContext', storyId), diff --git a/src/renderer/components/Dashboard/Dashboard.tsx b/src/renderer/components/Dashboard/Dashboard.tsx index f770969..8c6d0b7 100644 --- a/src/renderer/components/Dashboard/Dashboard.tsx +++ b/src/renderer/components/Dashboard/Dashboard.tsx @@ -1,5 +1,6 @@ import { useBorgesStore } from '../../store/borgesStore' import { PromptHero } from './PromptHero' +import { WritingStats } from './WritingStats' function daysSince(iso: string): number { return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000) @@ -99,6 +100,9 @@ export function Dashboard(): JSX.Element { })} + {/* Writing stats */} + + {/* Word count overview */}
Word counts
diff --git a/src/renderer/components/Dashboard/WritingStats.tsx b/src/renderer/components/Dashboard/WritingStats.tsx new file mode 100644 index 0000000..a04cc7d --- /dev/null +++ b/src/renderer/components/Dashboard/WritingStats.tsx @@ -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() + 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(null) + + useEffect(() => { + window.api.readTelemetry().then((sessions) => { + setStats(computeStats(sessions, stories)) + }).catch(() => setStats(computeStats([], stories))) + }, [stories]) + + if (!stats) return
Writing stats
Loading…
+ + if (stats.totalSessions === 0) { + return ( +
+
Writing stats
+
No writing sessions yet. Start typing to begin tracking.
+
+ ) + } + + return ( +
+
Writing stats
+ +
+
+
{stats.totalWordsWritten.toLocaleString()}
+
words written
+
+
+
{stats.totalSessions}
+
sessions
+
+
+
{stats.currentStreak}
+
day streak
+
+
+
{stats.longestStreak}
+
longest streak
+
+ {stats.avgWpm > 0 && ( +
+
{stats.avgWpm}
+
avg wpm
+
+ )} + {stats.bestWpm > 0 && ( +
+
{stats.bestWpm}
+
best wpm
+
+ )} +
+ + {stats.byStory.length > 0 && ( +
+ {stats.byStory.slice(0, 6).map((s) => ( +
+ {s.title} + {s.wordsWritten.toLocaleString()}w · {s.sessions} {s.sessions === 1 ? 'session' : 'sessions'} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index d9e5a29..39f288d 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -78,6 +78,22 @@ const markdownHighlight = HighlightStyle.define([ { 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 { const { activeStoryPath, activeStoryContent, activeStoryId, annotations, theme, fontSize, revisionPanelOpen, toggleRevisionPanel, revisions, setRevisions } = useBorgesStore() @@ -87,6 +103,70 @@ export function MarkdownEditor(): JSX.Element { const storeTimerRef = useRef | null>(null) const [storyPrompt, setStoryPrompt] = useState(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 | 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 useEffect(() => { @@ -116,6 +196,8 @@ export function MarkdownEditor(): JSX.Element { storeTimerRef.current = setTimeout(() => { useBorgesStore.getState().setContent(content) }, 300) + const { activeStoryId } = useBorgesStore.getState() + if (activeStoryId) onDocChanged(activeStoryId, content) } }), EditorView.domEventHandlers({ @@ -144,7 +226,14 @@ export function MarkdownEditor(): JSX.Element { parent: editorRef.current }) 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 @@ -152,6 +241,7 @@ export function MarkdownEditor(): JSX.Element { const view = viewRef.current if (!view) return if (activeStoryPath !== lastPathRef.current) { + flushSession() lastPathRef.current = activeStoryPath view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: activeStoryContent } }) view.dispatch({ effects: setAnnotationsEffect.of([]) }) diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 2d56b47..8b9aac3 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -376,6 +376,10 @@ textarea { resize: vertical; } .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-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 { diff --git a/src/renderer/types/borges.ts b/src/renderer/types/borges.ts index 8fb98ff..d8f199e 100644 --- a/src/renderer/types/borges.ts +++ b/src/renderer/types/borges.ts @@ -43,6 +43,18 @@ export interface RevisionMeta { 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 { id: string role: 'user' | 'assistant' diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 5f89ef8..201249a 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -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' @@ -53,6 +53,8 @@ declare global { saveRevision(path: string, content: string): Promise listRevisions(path: string): Promise loadRevision(path: string, id: string): Promise + appendTelemetrySession(session: TelemetrySession): Promise + readTelemetry(): Promise readConfig(): Promise writeConfig(updates: Partial): Promise pickFolder(): Promise