From 46458beb8ebb79bf169e19b922b124e4e1106e2b Mon Sep 17 00:00:00 2001 From: TC Date: Tue, 9 Jun 2026 23:02:50 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20telemetry=20and=20home=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/fileSystem.ts | 31 +++ src/main/ipcHandlers.ts | 19 +- src/main/telemetry.ts | 67 ++++++ src/preload/index.ts | 14 +- src/renderer/App.tsx | 30 ++- .../components/Editor/MarkdownEditor.tsx | 15 ++ src/renderer/components/Home/HomeScreen.css | 158 +++++++++++++ src/renderer/components/Home/HomeScreen.tsx | 209 ++++++++++++++++++ src/renderer/store/editorStore.ts | 18 +- src/renderer/styles/app.css | 38 ++++ src/renderer/types/editor.ts | 17 ++ tsconfig.node.tsbuildinfo | 2 +- 12 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 src/main/telemetry.ts create mode 100644 src/renderer/components/Home/HomeScreen.css create mode 100644 src/renderer/components/Home/HomeScreen.tsx diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index a028a91..3761606 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -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): Promise 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 +} + +export interface TelemetryData { + sessions: CompletedSession[] +} + +export async function readTelemetry(): Promise { + try { + return JSON.parse(await readFile(telemetryFile(), 'utf-8')) + } catch { + return { sessions: [] } + } +} + +export async function writeTelemetry(data: TelemetryData): Promise { + await mkdir(hohoffDir(), { recursive: true }) + await writeFile(telemetryFile(), JSON.stringify(data), 'utf-8') +} + export interface ProjectConfig { projectTitle?: string authorName?: string diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 049592c..269a2eb 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -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 diff --git a/src/main/telemetry.ts b/src/main/telemetry.ts new file mode 100644 index 0000000..51c03a0 --- /dev/null +++ b/src/main/telemetry.ts @@ -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 + idleTimer: ReturnType | null +} + +let active: ActiveSession | null = null + +async function persistSession(session: CompletedSession): Promise { + 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() +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 41cdc34..4e13edc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('export:projectPdf'), + + readAllDraftFiles: (): Promise<{ relativePath: string; content: string }[]> => + ipcRenderer.invoke('fs:readAllFiles'), + + trackWordSnapshot: (filePath: string, wordCount: number): Promise => + ipcRenderer.invoke('telemetry:wordSnapshot', filePath, wordCount), + + flushTelemetry: (): Promise => + ipcRenderer.invoke('telemetry:flush'), + + readTelemetry: (): Promise => + ipcRenderer.invoke('telemetry:read'), }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 135f4f5..1bdf861 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 {
- -
- - -
- {revisionPanelOpen && } + {showHome ? ( + + ) : ( + <> + +
+ + +
+ {revisionPanelOpen && } + + )}