✨ telemetry and home screen
This commit is contained in:
@@ -6,6 +6,7 @@ import { getDraftRoot } from './globalConfig'
|
|||||||
const hohoffDir = (): string => join(getDraftRoot(), '.hohoff')
|
const hohoffDir = (): string => join(getDraftRoot(), '.hohoff')
|
||||||
const orderFile = (): string => join(hohoffDir(), 'order.json')
|
const orderFile = (): string => join(hohoffDir(), 'order.json')
|
||||||
const sessionFile = (): string => join(hohoffDir(), 'session.json')
|
const sessionFile = (): string => join(hohoffDir(), 'session.json')
|
||||||
|
const telemetryFile = (): string => join(hohoffDir(), 'telemetry.json')
|
||||||
const revisionsDir = (): string => join(hohoffDir(), 'revisions')
|
const revisionsDir = (): string => join(hohoffDir(), 'revisions')
|
||||||
export const getStoryBiblePath = (): string => join(hohoffDir(), 'Story Bible.md')
|
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')
|
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 {
|
export interface ProjectConfig {
|
||||||
projectTitle?: string
|
projectTitle?: string
|
||||||
authorName?: string
|
authorName?: string
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { ipcMain, dialog, BrowserWindow } from 'electron'
|
|||||||
import { readFileSync, writeFileSync, unlinkSync } from 'fs'
|
import { readFileSync, writeFileSync, unlinkSync } from 'fs'
|
||||||
import { extname, basename, join } from 'path'
|
import { extname, basename, join } from 'path'
|
||||||
import { tmpdir } from 'os'
|
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 type { SearchOptions, ProjectConfig } from './fileSystem'
|
||||||
import { streamMessage, resetClient } from './aiService'
|
import { streamMessage, resetClient } from './aiService'
|
||||||
|
import { onWordSnapshot, flushTelemetry } from './telemetry'
|
||||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
|
import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
|
||||||
import type { GlobalConfig } from './globalConfig'
|
import type { GlobalConfig } from './globalConfig'
|
||||||
@@ -141,6 +142,22 @@ export function registerIpcHandlers(): void {
|
|||||||
return await replaceInFiles(query, replacement, opts, filePaths)
|
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) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
||||||
|
|||||||
67
src/main/telemetry.ts
Normal file
67
src/main/telemetry.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
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 {
|
interface SearchOptions {
|
||||||
caseSensitive: boolean
|
caseSensitive: boolean
|
||||||
@@ -124,4 +124,16 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
exportProjectPDF: (): Promise<void> =>
|
exportProjectPDF: (): Promise<void> =>
|
||||||
ipcRenderer.invoke('export:projectPdf'),
|
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'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
|||||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||||
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
|
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
|
||||||
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
||||||
|
import { HomeScreen } from './components/Home/HomeScreen'
|
||||||
import { useEditorStore } from './store/editorStore'
|
import { useEditorStore } from './store/editorStore'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ export default function App(): JSX.Element {
|
|||||||
const {
|
const {
|
||||||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||||
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
|
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
|
||||||
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode
|
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode,
|
||||||
|
showHome, goHome
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(
|
const [sidebarOpen, setSidebarOpen] = useState(
|
||||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||||
@@ -173,15 +175,29 @@ export default function App(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="sidebar">
|
<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 />
|
<FileTree />
|
||||||
</aside>
|
</aside>
|
||||||
<main className="editor-area" style={{ position: 'relative' }}>
|
<main className="editor-area" style={{ position: 'relative' }}>
|
||||||
|
{showHome ? (
|
||||||
|
<HomeScreen />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<AnalysisToolbar />
|
<AnalysisToolbar />
|
||||||
<div className="editor-body">
|
<div className="editor-body">
|
||||||
<DocumentOutline />
|
<DocumentOutline />
|
||||||
<MarkdownEditor />
|
<MarkdownEditor />
|
||||||
</div>
|
</div>
|
||||||
{revisionPanelOpen && <RevisionPanel />}
|
{revisionPanelOpen && <RevisionPanel />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<aside className="chat-area">
|
<aside className="chat-area">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
|
|||||||
@@ -724,6 +724,19 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
wordTimer = setTimeout(() => { wordTimer = null; setWordStats(getStats()) }, 150)
|
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({
|
const view = new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc: '',
|
doc: '',
|
||||||
@@ -788,6 +801,7 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
scheduleSetContent(text)
|
scheduleSetContent(text)
|
||||||
const total = countWords(text)
|
const total = countWords(text)
|
||||||
wordTotalRef.current = total
|
wordTotalRef.current = total
|
||||||
|
scheduleTelemetrySnapshot(total)
|
||||||
const head = update.state.selection.main.head
|
const head = update.state.selection.main.head
|
||||||
scheduleWordStats(() => ({
|
scheduleWordStats(() => ({
|
||||||
atCursor: countWords(update.state.doc.sliceString(0, head)),
|
atCursor: countWords(update.state.doc.sliceString(0, head)),
|
||||||
@@ -893,6 +907,7 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
if (scrollTimer) clearTimeout(scrollTimer)
|
if (scrollTimer) clearTimeout(scrollTimer)
|
||||||
if (contentTimer) clearTimeout(contentTimer)
|
if (contentTimer) clearTimeout(contentTimer)
|
||||||
if (wordTimer) clearTimeout(wordTimer)
|
if (wordTimer) clearTimeout(wordTimer)
|
||||||
|
if (telemetryTimer) clearTimeout(telemetryTimer)
|
||||||
view.destroy()
|
view.destroy()
|
||||||
viewRef.current = null
|
viewRef.current = null
|
||||||
currentEditorView = null
|
currentEditorView = null
|
||||||
|
|||||||
158
src/renderer/components/Home/HomeScreen.css
Normal file
158
src/renderer/components/Home/HomeScreen.css
Normal 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);
|
||||||
|
}
|
||||||
209
src/renderer/components/Home/HomeScreen.tsx
Normal file
209
src/renderer/components/Home/HomeScreen.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -103,6 +103,11 @@ interface EditorState {
|
|||||||
scrollEditorToLine: (line: number) => void
|
scrollEditorToLine: (line: number) => void
|
||||||
clearPendingScrollToLine: () => void
|
clearPendingScrollToLine: () => void
|
||||||
|
|
||||||
|
// Home screen
|
||||||
|
showHome: boolean
|
||||||
|
goHome: () => void
|
||||||
|
leaveHome: () => void
|
||||||
|
|
||||||
// Session persistence
|
// Session persistence
|
||||||
loadSession: () => Promise<void>
|
loadSession: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -181,6 +186,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
activeFilePath: path,
|
activeFilePath: path,
|
||||||
activeFileContent: content,
|
activeFileContent: content,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
showHome: false,
|
||||||
chatHistory: existing,
|
chatHistory: existing,
|
||||||
annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
|
annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
|
||||||
analysisMode: savedAnnotationState?.mode ?? 'none'
|
analysisMode: savedAnnotationState?.mode ?? 'none'
|
||||||
@@ -653,6 +659,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
scrollEditorToLine: (line) => set({ pendingScrollToLine: line }),
|
scrollEditorToLine: (line) => set({ pendingScrollToLine: line }),
|
||||||
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
|
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
|
||||||
|
|
||||||
|
showHome: false,
|
||||||
|
goHome: () => set({ showHome: true }),
|
||||||
|
leaveHome: () => set({ showHome: false }),
|
||||||
|
|
||||||
loadSession: async () => {
|
loadSession: async () => {
|
||||||
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -693,11 +703,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
const content = await api.readFile(data.activeFilePath)
|
const content = await api.readFile(data.activeFilePath)
|
||||||
get().setActiveFile(data.activeFilePath, content)
|
get().setActiveFile(data.activeFilePath, content)
|
||||||
} catch {
|
} 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 {
|
} catch {
|
||||||
// No session yet — start fresh
|
// No session yet — show home
|
||||||
|
set({ showHome: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -194,6 +194,44 @@
|
|||||||
flex-direction: column;
|
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 ──────────────────────────────────────────────── */
|
||||||
.editor-area {
|
.editor-area {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ export interface RevisionMeta {
|
|||||||
wordCount: number
|
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 {
|
export interface SearchMatch {
|
||||||
lineNumber: number // 1-based
|
lineNumber: number // 1-based
|
||||||
lineText: string
|
lineText: string
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user