telemetry and home screen

This commit is contained in:
TC
2026-06-09 23:02:50 +10:00
parent 519b3e8711
commit 46458beb8e
12 changed files with 606 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ import { getDraftRoot } from './globalConfig'
const hohoffDir = (): string => join(getDraftRoot(), '.hohoff')
const orderFile = (): string => join(hohoffDir(), 'order.json')
const sessionFile = (): string => join(hohoffDir(), 'session.json')
const telemetryFile = (): string => join(hohoffDir(), 'telemetry.json')
const revisionsDir = (): string => join(hohoffDir(), 'revisions')
export const getStoryBiblePath = (): string => join(hohoffDir(), 'Story Bible.md')
@@ -71,6 +72,36 @@ export async function writeSession(data: Record<string, unknown>): Promise<void>
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
}
export interface FileTelemetry {
wordsAdded: number
wordsRemoved: number
netWords: number
}
export interface CompletedSession {
id: string
startedAt: number
endedAt: number
files: Record<string, FileTelemetry>
}
export interface TelemetryData {
sessions: CompletedSession[]
}
export async function readTelemetry(): Promise<TelemetryData> {
try {
return JSON.parse(await readFile(telemetryFile(), 'utf-8'))
} catch {
return { sessions: [] }
}
}
export async function writeTelemetry(data: TelemetryData): Promise<void> {
await mkdir(hohoffDir(), { recursive: true })
await writeFile(telemetryFile(), JSON.stringify(data), 'utf-8')
}
export interface ProjectConfig {
projectTitle?: string
authorName?: string

View File

@@ -2,9 +2,10 @@ import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync, writeFileSync, unlinkSync } from 'fs'
import { extname, basename, join } from 'path'
import { tmpdir } from 'os'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS } from './fileSystem'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry } from './fileSystem'
import type { SearchOptions, ProjectConfig } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
import { onWordSnapshot, flushTelemetry } from './telemetry'
import type { AIPayload, Attachment } from '../renderer/types/editor'
import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
import type { GlobalConfig } from './globalConfig'
@@ -141,6 +142,22 @@ export function registerIpcHandlers(): void {
return await replaceInFiles(query, replacement, opts, filePaths)
})
ipcMain.handle('fs:readAllFiles', async () => {
return await readAllDraftFiles()
})
ipcMain.handle('telemetry:wordSnapshot', (_event, filePath: string, wordCount: number) => {
onWordSnapshot(filePath, wordCount)
})
ipcMain.handle('telemetry:flush', () => {
flushTelemetry()
})
ipcMain.handle('telemetry:read', async () => {
return await readTelemetry()
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
const storyBibleContent = (await readStoryBibleFile()) ?? undefined

67
src/main/telemetry.ts Normal file
View File

@@ -0,0 +1,67 @@
import { randomUUID } from 'crypto'
import { readTelemetry, writeTelemetry } from './fileSystem'
import type { CompletedSession } from './fileSystem'
const IDLE_MS = 15 * 60 * 1000
interface FileSnapshot {
baseline: number
current: number
}
interface ActiveSession {
id: string
startedAt: number
files: Record<string, FileSnapshot>
idleTimer: ReturnType<typeof setTimeout> | null
}
let active: ActiveSession | null = null
async function persistSession(session: CompletedSession): Promise<void> {
const data = await readTelemetry()
data.sessions.push(session)
await writeTelemetry(data)
}
function endSession(): void {
if (!active) return
const endedAt = Date.now()
const files: CompletedSession['files'] = {}
for (const [path, snap] of Object.entries(active.files)) {
const diff = snap.current - snap.baseline
files[path] = {
wordsAdded: Math.max(0, diff),
wordsRemoved: Math.max(0, -diff),
netWords: diff
}
}
const session: CompletedSession = { id: active.id, startedAt: active.startedAt, endedAt, files }
active = null
persistSession(session).catch(console.error)
}
export function onWordSnapshot(filePath: string, wordCount: number): void {
if (!active) {
active = {
id: randomUUID(),
startedAt: Date.now(),
files: { [filePath]: { baseline: wordCount, current: wordCount } },
idleTimer: null
}
} else {
if (!active.files[filePath]) {
active.files[filePath] = { baseline: wordCount, current: wordCount }
} else {
active.files[filePath].current = wordCount
}
}
if (active.idleTimer) clearTimeout(active.idleTimer)
active.idleTimer = setTimeout(endSession, IDLE_MS)
}
export function flushTelemetry(): void {
if (active?.idleTimer) clearTimeout(active.idleTimer)
endSession()
}

View File

@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig } from '../renderer/types/editor'
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData } from '../renderer/types/editor'
interface SearchOptions {
caseSensitive: boolean
@@ -124,4 +124,16 @@ contextBridge.exposeInMainWorld('api', {
exportProjectPDF: (): Promise<void> =>
ipcRenderer.invoke('export:projectPdf'),
readAllDraftFiles: (): Promise<{ relativePath: string; content: string }[]> =>
ipcRenderer.invoke('fs:readAllFiles'),
trackWordSnapshot: (filePath: string, wordCount: number): Promise<void> =>
ipcRenderer.invoke('telemetry:wordSnapshot', filePath, wordCount),
flushTelemetry: (): Promise<void> =>
ipcRenderer.invoke('telemetry:flush'),
readTelemetry: (): Promise<TelemetryData> =>
ipcRenderer.invoke('telemetry:read'),
})

View File

@@ -7,6 +7,7 @@ import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
import { RevisionPanel } from './components/Revisions/RevisionPanel'
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
import { SettingsDialog } from './components/Settings/SettingsDialog'
import { HomeScreen } from './components/Home/HomeScreen'
import { useEditorStore } from './store/editorStore'
import './styles/app.css'
@@ -14,7 +15,8 @@ export default function App(): JSX.Element {
const {
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode,
showHome, goHome
} = useEditorStore()
const [sidebarOpen, setSidebarOpen] = useState(
() => localStorage.getItem('sidebarOpen') !== 'false'
@@ -173,15 +175,29 @@ export default function App(): JSX.Element {
</div>
</div>
<aside className="sidebar">
<button
className={`sidebar-home-btn${showHome ? ' active' : ''}`}
onClick={goHome}
title="Home"
>
<span className="sidebar-home-icon"></span>
<span className="sidebar-home-label">Home</span>
</button>
<FileTree />
</aside>
<main className="editor-area" style={{ position: 'relative' }}>
<AnalysisToolbar />
<div className="editor-body">
<DocumentOutline />
<MarkdownEditor />
</div>
{revisionPanelOpen && <RevisionPanel />}
{showHome ? (
<HomeScreen />
) : (
<>
<AnalysisToolbar />
<div className="editor-body">
<DocumentOutline />
<MarkdownEditor />
</div>
{revisionPanelOpen && <RevisionPanel />}
</>
)}
</main>
<aside className="chat-area">
<ChatPanel />

View File

@@ -724,6 +724,19 @@ export function MarkdownEditor(): JSX.Element {
wordTimer = setTimeout(() => { wordTimer = null; setWordStats(getStats()) }, 150)
}
// Debounced telemetry snapshot — fires 5s after the user stops typing
let telemetryTimer: ReturnType<typeof setTimeout> | null = null
function scheduleTelemetrySnapshot(wordCount: number): void {
if (telemetryTimer) clearTimeout(telemetryTimer)
telemetryTimer = setTimeout(() => {
telemetryTimer = null
const { activeFilePath: fp } = useEditorStore.getState()
if (!fp) return
const api = (window as unknown as { api?: { trackWordSnapshot: (p: string, n: number) => Promise<void> } }).api
api?.trackWordSnapshot(fp, wordCount).catch(console.error)
}, 5_000)
}
const view = new EditorView({
state: EditorState.create({
doc: '',
@@ -788,6 +801,7 @@ export function MarkdownEditor(): JSX.Element {
scheduleSetContent(text)
const total = countWords(text)
wordTotalRef.current = total
scheduleTelemetrySnapshot(total)
const head = update.state.selection.main.head
scheduleWordStats(() => ({
atCursor: countWords(update.state.doc.sliceString(0, head)),
@@ -893,6 +907,7 @@ export function MarkdownEditor(): JSX.Element {
if (scrollTimer) clearTimeout(scrollTimer)
if (contentTimer) clearTimeout(contentTimer)
if (wordTimer) clearTimeout(wordTimer)
if (telemetryTimer) clearTimeout(telemetryTimer)
view.destroy()
viewRef.current = null
currentEditorView = null

View File

@@ -0,0 +1,158 @@
.home-screen {
display: flex;
align-items: flex-start;
justify-content: center;
height: 100%;
overflow-y: auto;
padding: 80px 40px 60px;
}
.home-content {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 48px;
}
.home-title {
font-family: var(--font-serif);
font-size: 22px;
font-weight: 400;
color: var(--text-primary);
letter-spacing: 0.02em;
margin: 0;
}
/* ── Stats ──────────────────────────────────────────────────────── */
.home-stats {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.home-stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 20px 12px;
border-right: 1px solid var(--border);
}
.home-stat:last-child {
border-right: none;
}
.home-stat-value {
font-family: var(--font-serif);
font-size: 22px;
color: var(--text-primary);
line-height: 1;
}
.home-stat-label {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
/* ── Excerpt ────────────────────────────────────────────────────── */
.home-excerpt {
display: flex;
flex-direction: column;
gap: 12px;
padding: 28px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.home-excerpt-text {
font-family: var(--font-serif);
font-size: 15px;
line-height: 1.8;
color: var(--text-secondary, var(--text-muted));
margin: 0;
font-style: italic;
}
.home-excerpt-source {
font-family: var(--font-sans);
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
align-self: flex-end;
}
/* ── Recent files ───────────────────────────────────────────────── */
.home-recent-heading {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 12px;
}
.home-recent-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.home-recent-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 5px;
cursor: pointer;
transition: background 0.12s;
}
.home-recent-item:hover {
background: var(--active-bg);
}
.home-recent-name {
font-family: var(--font-sans);
font-size: 13px;
color: var(--text-primary);
}
.home-recent-meta {
display: flex;
gap: 16px;
font-family: var(--font-sans);
font-size: 11px;
color: var(--text-muted);
}
/* ── Resume button ──────────────────────────────────────────────── */
.home-resume-btn {
align-self: flex-start;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0.06em;
padding: 8px 18px;
border-radius: 5px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.home-resume-btn:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}

View File

@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react'
import type { TelemetrySession } from '../../types/editor'
import { useEditorStore } from '../../store/editorStore'
import './HomeScreen.css'
interface Stats {
projectWordCount: number
wordsToday: number
streak: number
avgWpm: number
}
interface RecentFile {
path: string
name: string
lastSeen: number
wordsAdded: number
}
interface Excerpt {
text: string
source: string
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
}
function computeStats(sessions: TelemetrySession[], projectWordCount: number): Stats {
const todayStart = new Date().setHours(0, 0, 0, 0)
let wordsToday = 0
let totalWordsForWpm = 0
let totalMinutesForWpm = 0
const daySet = new Set<string>()
for (const s of sessions) {
const sessionWords = Object.values(s.files).reduce((sum, f) => sum + Math.max(0, f.wordsAdded), 0)
const sessionMinutes = (s.endedAt - s.startedAt) / 60000
if (s.startedAt >= todayStart) wordsToday += sessionWords
if (sessionWords > 5 && sessionMinutes > 0.5) {
totalWordsForWpm += sessionWords
totalMinutesForWpm += sessionMinutes
}
if (sessionWords > 0) {
daySet.add(new Date(s.startedAt).toDateString())
}
}
let streak = 0
const checkDate = new Date()
while (daySet.has(checkDate.toDateString())) {
streak++
checkDate.setDate(checkDate.getDate() - 1)
}
const avgWpm = totalMinutesForWpm > 0 ? Math.round(totalWordsForWpm / totalMinutesForWpm) : 0
return { projectWordCount, wordsToday, streak, avgWpm }
}
function getRecentFiles(sessions: TelemetrySession[]): RecentFile[] {
const fileMap: Record<string, { lastSeen: number; wordsAdded: number }> = {}
for (const s of sessions) {
for (const [path, stats] of Object.entries(s.files)) {
if (!fileMap[path] || s.endedAt > fileMap[path].lastSeen) {
fileMap[path] = { lastSeen: s.endedAt, wordsAdded: (fileMap[path]?.wordsAdded ?? 0) + Math.max(0, stats.wordsAdded) }
}
}
}
return Object.entries(fileMap)
.sort((a, b) => b[1].lastSeen - a[1].lastSeen)
.slice(0, 6)
.map(([path, data]) => ({
path,
name: path.split('/').pop()?.replace(/\.md$/, '') ?? path,
lastSeen: data.lastSeen,
wordsAdded: data.wordsAdded
}))
}
function pickExcerpt(files: { relativePath: string; content: string }[]): Excerpt | null {
const candidates: { text: string; source: string }[] = []
for (const file of files) {
const name = file.relativePath.split('/').pop()?.replace(/\.md$/, '') ?? file.relativePath
const paragraphs = file.content.split(/\n\n+/).filter(p => {
const clean = p.replace(/^#{1,6}\s.*/, '').trim()
const words = countWords(clean)
return words >= 20 && words <= 80 && !clean.startsWith('#') && !clean.startsWith('>')
})
for (const p of paragraphs) {
candidates.push({ text: p.trim(), source: name })
}
}
if (candidates.length === 0) return null
return candidates[Math.floor(Math.random() * candidates.length)]
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60000)
const hours = Math.floor(mins / 60)
const days = Math.floor(hours / 24)
if (mins < 60) return `${mins}m ago`
if (hours < 24) return `${hours}h ago`
if (days === 1) return 'yesterday'
return `${days}d ago`
}
export function HomeScreen(): JSX.Element {
const { setActiveFile, activeFilePath, leaveHome } = useEditorStore()
const [stats, setStats] = useState<Stats | null>(null)
const [recentFiles, setRecentFiles] = useState<RecentFile[]>([])
const [excerpt, setExcerpt] = useState<Excerpt | null>(null)
const [projectTitle, setProjectTitle] = useState('Your Manuscript')
useEffect(() => {
async function load(): Promise<void> {
const [telemetry, projectWordCount, allFiles, cfg] = await Promise.all([
window.api.readTelemetry(),
window.api.getProjectWordCount(),
window.api.readAllDraftFiles(),
window.api.readConfig()
])
setStats(computeStats(telemetry.sessions, projectWordCount))
setRecentFiles(getRecentFiles(telemetry.sessions))
setExcerpt(pickExcerpt(allFiles))
if (cfg.projectTitle) setProjectTitle(cfg.projectTitle)
}
load().catch(console.error)
}, [])
async function openFile(path: string): Promise<void> {
const content = await window.api.readFile(path)
setActiveFile(path, content)
}
async function resumeWriting(): Promise<void> {
if (activeFilePath) {
leaveHome()
return
}
if (recentFiles.length > 0) {
await openFile(recentFiles[0].path)
}
}
return (
<div className="home-screen">
<div className="home-content">
<h1 className="home-title">{projectTitle}</h1>
{stats && (
<div className="home-stats">
<div className="home-stat">
<span className="home-stat-value">{stats.projectWordCount.toLocaleString()}</span>
<span className="home-stat-label">total words</span>
</div>
<div className="home-stat">
<span className="home-stat-value">{stats.wordsToday > 0 ? `+${stats.wordsToday.toLocaleString()}` : '—'}</span>
<span className="home-stat-label">today</span>
</div>
<div className="home-stat">
<span className="home-stat-value">{stats.streak > 0 ? stats.streak : '—'}</span>
<span className="home-stat-label">{stats.streak === 1 ? 'day streak' : 'day streak'}</span>
</div>
<div className="home-stat">
<span className="home-stat-value">{stats.avgWpm > 0 ? stats.avgWpm : '—'}</span>
<span className="home-stat-label">avg wpm</span>
</div>
</div>
)}
{excerpt && (
<div className="home-excerpt">
<p className="home-excerpt-text">{excerpt.text}</p>
<span className="home-excerpt-source"> {excerpt.source}</span>
</div>
)}
{recentFiles.length > 0 && (
<div className="home-recent">
<h2 className="home-recent-heading">Recent</h2>
<ul className="home-recent-list">
{recentFiles.map(f => (
<li key={f.path} className="home-recent-item" onClick={() => openFile(f.path)}>
<span className="home-recent-name">{f.name}</span>
<span className="home-recent-meta">
{f.wordsAdded > 0 && <span>+{f.wordsAdded.toLocaleString()} words</span>}
<span>{relativeTime(f.lastSeen)}</span>
</span>
</li>
))}
</ul>
</div>
)}
{(activeFilePath || recentFiles.length > 0) && (
<button className="home-resume-btn" onClick={resumeWriting}>
Resume writing
</button>
)}
</div>
</div>
)
}

View File

@@ -103,6 +103,11 @@ interface EditorState {
scrollEditorToLine: (line: number) => void
clearPendingScrollToLine: () => void
// Home screen
showHome: boolean
goHome: () => void
leaveHome: () => void
// Session persistence
loadSession: () => Promise<void>
}
@@ -181,6 +186,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeFilePath: path,
activeFileContent: content,
isDirty: false,
showHome: false,
chatHistory: existing,
annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
analysisMode: savedAnnotationState?.mode ?? 'none'
@@ -653,6 +659,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
scrollEditorToLine: (line) => set({ pendingScrollToLine: line }),
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
showHome: false,
goHome: () => set({ showHome: true }),
leaveHome: () => set({ showHome: false }),
loadSession: async () => {
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
if (!api) return
@@ -693,11 +703,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const content = await api.readFile(data.activeFilePath)
get().setActiveFile(data.activeFilePath, content)
} catch {
// File may have been moved/deleted — open nothing
// File may have been moved/deleted — show home instead
set({ showHome: true })
}
} else {
set({ showHome: true })
}
} catch {
// No session yet — start fresh
// No session yet — show home
set({ showHome: true })
}
}
}))

View File

@@ -194,6 +194,44 @@
flex-direction: column;
}
.sidebar-home-btn {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
text-align: left;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.sidebar-home-btn:hover {
color: var(--text-primary);
background: var(--active-bg);
}
.sidebar-home-btn.active {
color: var(--accent);
}
.sidebar-home-icon {
font-size: 14px;
line-height: 1;
}
.sidebar-home-label {
line-height: 1;
}
/* ─── Editor area ──────────────────────────────────────────────── */
.editor-area {
grid-row: 2;

View File

@@ -77,6 +77,23 @@ export interface RevisionMeta {
wordCount: number
}
export interface FileTelemetry {
wordsAdded: number
wordsRemoved: number
netWords: number
}
export interface TelemetrySession {
id: string
startedAt: number
endedAt: number
files: Record<string, FileTelemetry>
}
export interface TelemetryData {
sessions: TelemetrySession[]
}
export interface SearchMatch {
lineNumber: number // 1-based
lineText: string