💹 word count activity over time
This commit is contained in:
@@ -138,6 +138,89 @@
|
|||||||
color: var(--text-muted);
|
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 ──────────────────────────────────────────────── */
|
/* ── Resume button ──────────────────────────────────────────────── */
|
||||||
.home-resume-btn {
|
.home-resume-btn {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|||||||
@@ -102,6 +102,132 @@ function pickExcerpt(files: { relativePath: string; content: string }[]): Excerp
|
|||||||
return candidates[seed % candidates.length]
|
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 {
|
function relativeTime(ts: number): string {
|
||||||
const diff = Date.now() - ts
|
const diff = Date.now() - ts
|
||||||
const mins = Math.floor(diff / 60000)
|
const mins = Math.floor(diff / 60000)
|
||||||
@@ -116,6 +242,7 @@ function relativeTime(ts: number): string {
|
|||||||
export function HomeScreen(): JSX.Element {
|
export function HomeScreen(): JSX.Element {
|
||||||
const { setActiveFile, activeFilePath, leaveHome } = useEditorStore()
|
const { setActiveFile, activeFilePath, leaveHome } = useEditorStore()
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [sessions, setSessions] = useState<TelemetrySession[]>([])
|
||||||
const [recentFiles, setRecentFiles] = useState<RecentFile[]>([])
|
const [recentFiles, setRecentFiles] = useState<RecentFile[]>([])
|
||||||
const [excerpt, setExcerpt] = useState<Excerpt | null>(null)
|
const [excerpt, setExcerpt] = useState<Excerpt | null>(null)
|
||||||
const [projectTitle, setProjectTitle] = useState('Your Manuscript')
|
const [projectTitle, setProjectTitle] = useState('Your Manuscript')
|
||||||
@@ -130,6 +257,7 @@ export function HomeScreen(): JSX.Element {
|
|||||||
])
|
])
|
||||||
|
|
||||||
setStats(computeStats(telemetry.sessions, projectWordCount))
|
setStats(computeStats(telemetry.sessions, projectWordCount))
|
||||||
|
setSessions(telemetry.sessions)
|
||||||
setRecentFiles(getRecentFiles(telemetry.sessions))
|
setRecentFiles(getRecentFiles(telemetry.sessions))
|
||||||
setExcerpt(pickExcerpt(allFiles))
|
setExcerpt(pickExcerpt(allFiles))
|
||||||
if (cfg.projectTitle) setProjectTitle(cfg.projectTitle)
|
if (cfg.projectTitle) setProjectTitle(cfg.projectTitle)
|
||||||
@@ -175,6 +303,8 @@ export function HomeScreen(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DailyChart sessions={sessions} />
|
||||||
|
|
||||||
{excerpt && (
|
{excerpt && (
|
||||||
<div className="home-excerpt">
|
<div className="home-excerpt">
|
||||||
<p className="home-excerpt-text">{excerpt.text}</p>
|
<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