210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|