✨ telemetry and home screen
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user