💹 word count activity over time
This commit is contained in:
@@ -138,6 +138,89 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Activity chart ─────────────────────────────────────────────── */
|
||||
.home-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-section-heading {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.home-chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chart-bar-add {
|
||||
fill: var(--text-muted);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.chart-bar-rem {
|
||||
fill: var(--text-muted);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.chart-baseline {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 8px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-bar-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 7px;
|
||||
fill: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-legend-add::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.45;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chart-legend-rem::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.18;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Resume button ──────────────────────────────────────────────── */
|
||||
.home-resume-btn {
|
||||
align-self: flex-start;
|
||||
|
||||
@@ -102,6 +102,132 @@ function pickExcerpt(files: { relativePath: string; content: string }[]): Excerp
|
||||
return candidates[seed % candidates.length]
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
date: string // 'YYYY-MM-DD'
|
||||
label: string // 'Jun 12'
|
||||
added: number
|
||||
removed: number
|
||||
net: number
|
||||
}
|
||||
|
||||
function buildDailyBuckets(sessions: TelemetrySession[], days = 30): DayBucket[] {
|
||||
const buckets: Record<string, { added: number; removed: number }> = {}
|
||||
|
||||
// Pre-fill last N days so empty days still render
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
buckets[key] = { added: 0, removed: 0 }
|
||||
}
|
||||
|
||||
for (const s of sessions) {
|
||||
const key = new Date(s.startedAt).toISOString().slice(0, 10)
|
||||
if (!(key in buckets)) continue
|
||||
for (const f of Object.values(s.files)) {
|
||||
buckets[key].added += Math.max(0, f.wordsAdded)
|
||||
buckets[key].removed += Math.max(0, f.wordsRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(buckets).map(([date, v]) => {
|
||||
const d = new Date(date + 'T12:00:00')
|
||||
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return { date, label, added: v.added, removed: v.removed, net: v.added - v.removed }
|
||||
})
|
||||
}
|
||||
|
||||
function DailyChart({ sessions }: { sessions: TelemetrySession[] }): JSX.Element {
|
||||
const buckets = buildDailyBuckets(sessions, 30)
|
||||
const maxVal = Math.max(...buckets.map(b => Math.max(b.added, b.removed)), 1)
|
||||
|
||||
const W = 560
|
||||
const H = 100
|
||||
const barAreaH = 80
|
||||
const barW = Math.floor((W - 1) / buckets.length) - 1
|
||||
const gap = Math.floor((W - 1) / buckets.length)
|
||||
|
||||
// Show a label every ~7 days
|
||||
const labelStep = Math.ceil(buckets.length / 5)
|
||||
|
||||
return (
|
||||
<div className="home-chart">
|
||||
<div className="home-section-heading">Activity</div>
|
||||
<svg viewBox={`0 0 ${W} ${H + 16}`} className="home-chart-svg" aria-label="Daily word activity">
|
||||
{buckets.map((b, i) => {
|
||||
const x = i * gap
|
||||
const addH = Math.round((b.added / maxVal) * barAreaH)
|
||||
const remH = Math.round((b.removed / maxVal) * barAreaH)
|
||||
const showLabel = i % labelStep === 0
|
||||
|
||||
const topBarH = Math.max(addH, remH)
|
||||
const hasActivity = b.added > 0 || b.removed > 0
|
||||
const netLabel = b.net >= 0 ? `+${b.net}` : `−${Math.abs(b.net)}`
|
||||
|
||||
return (
|
||||
<g key={b.date}>
|
||||
{/* additions bar */}
|
||||
{b.added > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
y={barAreaH - addH}
|
||||
width={barW}
|
||||
height={addH}
|
||||
className="chart-bar-add"
|
||||
rx={1}
|
||||
>
|
||||
<title>{b.label}: +{b.added} / −{b.removed}</title>
|
||||
</rect>
|
||||
)}
|
||||
{/* removals bar (overlaid, semi-transparent) */}
|
||||
{b.removed > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
y={barAreaH - remH}
|
||||
width={barW}
|
||||
height={remH}
|
||||
className="chart-bar-rem"
|
||||
rx={1}
|
||||
>
|
||||
<title>{b.label}: +{b.added} / −{b.removed}</title>
|
||||
</rect>
|
||||
)}
|
||||
{/* net word count above bar */}
|
||||
{hasActivity && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={barAreaH - topBarH - 4}
|
||||
className="chart-bar-label"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{netLabel}
|
||||
</text>
|
||||
)}
|
||||
{/* day label */}
|
||||
{showLabel && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={H + 12}
|
||||
className="chart-label"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{b.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* baseline */}
|
||||
<line x1={0} y1={barAreaH} x2={W} y2={barAreaH} className="chart-baseline" />
|
||||
</svg>
|
||||
<div className="chart-legend">
|
||||
<span className="chart-legend-add">additions</span>
|
||||
<span className="chart-legend-rem">deletions</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
@@ -116,6 +242,7 @@ function relativeTime(ts: number): string {
|
||||
export function HomeScreen(): JSX.Element {
|
||||
const { setActiveFile, activeFilePath, leaveHome } = useEditorStore()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [sessions, setSessions] = useState<TelemetrySession[]>([])
|
||||
const [recentFiles, setRecentFiles] = useState<RecentFile[]>([])
|
||||
const [excerpt, setExcerpt] = useState<Excerpt | null>(null)
|
||||
const [projectTitle, setProjectTitle] = useState('Your Manuscript')
|
||||
@@ -130,6 +257,7 @@ export function HomeScreen(): JSX.Element {
|
||||
])
|
||||
|
||||
setStats(computeStats(telemetry.sessions, projectWordCount))
|
||||
setSessions(telemetry.sessions)
|
||||
setRecentFiles(getRecentFiles(telemetry.sessions))
|
||||
setExcerpt(pickExcerpt(allFiles))
|
||||
if (cfg.projectTitle) setProjectTitle(cfg.projectTitle)
|
||||
@@ -175,6 +303,8 @@ export function HomeScreen(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DailyChart sessions={sessions} />
|
||||
|
||||
{excerpt && (
|
||||
<div className="home-excerpt">
|
||||
<p className="home-excerpt-text">{excerpt.text}</p>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user