@heyiam/ui 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@heyiam/ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Shared visualization components for heyi.am",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
- "files": ["src"],
8
+ "files": [
9
+ "src"
10
+ ],
9
11
  "peerDependencies": {
10
12
  "react": ">=18",
11
13
  "react-dom": ">=18"
@@ -0,0 +1,172 @@
1
+ import { Fragment } from 'react';
2
+ import type { Session } from './types';
3
+
4
+ // ── Directory Heatmap ──────────────────────────────────────────
5
+
6
+ interface FileEditData {
7
+ path: string;
8
+ editCount: number;
9
+ }
10
+
11
+ function stripProjectRoot(filePath: string, projectDirName: string, cwd?: string): string {
12
+ if (cwd && filePath.startsWith(cwd)) {
13
+ const relative = filePath.slice(cwd.length).replace(/^\//, '');
14
+ return relative || filePath;
15
+ }
16
+ const root = projectDirName.replace(/^-/, '/').replace(/-/g, '/');
17
+ if (filePath.startsWith(root)) {
18
+ const relative = filePath.slice(root.length).replace(/^\//, '');
19
+ return relative || filePath;
20
+ }
21
+ return filePath;
22
+ }
23
+
24
+ function extractDirectory(filePath: string): string {
25
+ const segments = filePath.split('/').filter(Boolean);
26
+ if (segments.length <= 1) return '/';
27
+ const depth = Math.min(segments.length - 1, 2);
28
+ return segments.slice(0, depth).join('/') + '/';
29
+ }
30
+
31
+ interface HeatmapData {
32
+ directories: string[];
33
+ grid: Map<string, number>;
34
+ maxEdits: number;
35
+ files: FileEditData[];
36
+ totalFiles: number;
37
+ }
38
+
39
+ function buildHeatmapData(sessions: Session[], projectDirName: string): HeatmapData {
40
+ const dirSessionMap = new Map<string, Map<string, number>>();
41
+ const dirTotals = new Map<string, number>();
42
+ const fileMap = new Map<string, number>();
43
+
44
+ for (const session of sessions) {
45
+ if (!session.filesChanged) continue;
46
+ for (const fc of session.filesChanged) {
47
+ if (!fc.path || typeof fc.path !== 'string') continue;
48
+ const edits = fc.editCount ?? (fc.additions + fc.deletions);
49
+ const relativePath = stripProjectRoot(fc.path, projectDirName, session.cwd);
50
+ const dir = extractDirectory(relativePath);
51
+
52
+ if (!dirSessionMap.has(dir)) dirSessionMap.set(dir, new Map());
53
+ const sessionMap = dirSessionMap.get(dir)!;
54
+ sessionMap.set(session.id, (sessionMap.get(session.id) ?? 0) + edits);
55
+ dirTotals.set(dir, (dirTotals.get(dir) ?? 0) + edits);
56
+ fileMap.set(relativePath, (fileMap.get(relativePath) ?? 0) + edits);
57
+ }
58
+ }
59
+
60
+ const directories = Array.from(dirTotals.entries())
61
+ .sort((a, b) => b[1] - a[1])
62
+ .slice(0, 10)
63
+ .map(([dir]) => dir);
64
+
65
+ const grid = new Map<string, number>();
66
+ let maxEdits = 0;
67
+ for (const dir of directories) {
68
+ const sessionMap = dirSessionMap.get(dir);
69
+ if (!sessionMap) continue;
70
+ for (const session of sessions) {
71
+ const edits = sessionMap.get(session.id) ?? 0;
72
+ grid.set(`${dir}|${session.id}`, edits);
73
+ if (edits > maxEdits) maxEdits = edits;
74
+ }
75
+ }
76
+
77
+ const files = Array.from(fileMap.entries())
78
+ .map(([path, editCount]) => ({ path, editCount }))
79
+ .sort((a, b) => b.editCount - a.editCount)
80
+ .slice(0, 10);
81
+
82
+ return { directories, grid, maxEdits, files, totalFiles: fileMap.size };
83
+ }
84
+
85
+ function getCellOpacity(editCount: number, maxEdits: number): number {
86
+ if (editCount === 0) return 0.02;
87
+ if (maxEdits === 0) return 0.05;
88
+ const ratio = editCount / maxEdits;
89
+ return 0.05 + ratio * 0.65;
90
+ }
91
+
92
+ function truncateTitle(title: string, maxLen: number = 15): string {
93
+ if (title.length <= maxLen) return title;
94
+ return title.slice(0, maxLen - 1) + '\u2026';
95
+ }
96
+
97
+ /** @internal Exported for testing */
98
+ export function DirectoryHeatmap({ sessions, projectDirName }: { sessions: Session[]; projectDirName: string }) {
99
+ const { directories, grid, maxEdits, files, totalFiles } = buildHeatmapData(sessions, projectDirName);
100
+
101
+ if (directories.length === 0) {
102
+ return (
103
+ <div className="dir-heatmap">
104
+ <div className="project-preview__timeline-heading">EDIT HEATMAP BY DIRECTORY</div>
105
+ <p className="dir-heatmap__empty">No file data available</p>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ const sessionCount = sessions.length;
111
+
112
+ return (
113
+ <div className="dir-heatmap">
114
+ <div className="project-preview__timeline-heading">EDIT HEATMAP BY DIRECTORY</div>
115
+ <div
116
+ className="dir-heatmap__grid"
117
+ style={{ gridTemplateColumns: `150px repeat(${sessionCount}, 1fr)` }}
118
+ role="table"
119
+ aria-label="Directory edit heatmap"
120
+ >
121
+ <div className="dir-heatmap__corner" role="columnheader" />
122
+ {sessions.map((s) => (
123
+ <div key={s.id} className="dir-heatmap__session-label" role="columnheader" title={s.title}>
124
+ {truncateTitle(s.title)}
125
+ </div>
126
+ ))}
127
+
128
+ {directories.map((dir) => (
129
+ <Fragment key={dir}>
130
+ <div className="dir-heatmap__dir-label" role="rowheader" title={dir}>{dir}</div>
131
+ {sessions.map((s) => {
132
+ const edits = grid.get(`${dir}|${s.id}`) ?? 0;
133
+ const opacity = getCellOpacity(edits, maxEdits);
134
+ return (
135
+ <div
136
+ key={s.id}
137
+ className="dir-heatmap__cell"
138
+ style={{ background: `rgba(8,68,113,${opacity})` }}
139
+ role="cell"
140
+ title={`${dir} in ${s.title}: ${edits} edits`}
141
+ aria-label={`${dir} in ${s.title}: ${edits} edits`}
142
+ />
143
+ );
144
+ })}
145
+ </Fragment>
146
+ ))}
147
+ </div>
148
+
149
+ <div className="dir-heatmap__legend" aria-hidden="true">
150
+ <span>Intensity = edit count</span>
151
+ <span style={{ display: 'inline-block', width: 12, height: 12, background: 'rgba(8,68,113,0.05)', borderRadius: 2 }} />
152
+ <span style={{ display: 'inline-block', width: 12, height: 12, background: 'rgba(8,68,113,0.35)', borderRadius: 2 }} />
153
+ <span style={{ display: 'inline-block', width: 12, height: 12, background: 'rgba(8,68,113,0.7)', borderRadius: 2 }} />
154
+ <span>low &rarr; high</span>
155
+ </div>
156
+
157
+ <details className="dir-heatmap__top-files">
158
+ <summary className="dir-heatmap__top-files-summary">
159
+ Top {files.length} most-edited files (of {totalFiles} total) &rarr;
160
+ </summary>
161
+ <div role="list" aria-label="Most edited files">
162
+ {files.map((f) => (
163
+ <div key={f.path} className="dir-heatmap__file-row" role="listitem">
164
+ <span className="dir-heatmap__file-path" title={f.path}>{f.path}</span>
165
+ <span className="dir-heatmap__file-count">{f.editCount} edits</span>
166
+ </div>
167
+ ))}
168
+ </div>
169
+ </details>
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,404 @@
1
+ import { Fragment } from 'react';
2
+ import type { Session } from './types';
3
+
4
+ // ── Growth Chart ─────────────────────────────────────────────────
5
+
6
+ interface GrowthChartProps {
7
+ sessions: Session[];
8
+ totalLoc: number;
9
+ totalFiles: number;
10
+ onSessionClick?: (session: Session) => void;
11
+ }
12
+
13
+ /** A point on the cumulative LOC time series */
14
+ export interface GrowthPoint {
15
+ /** Visual x position in ms (after gap compression) */
16
+ visualTime: number;
17
+ /** Cumulative LOC at this point */
18
+ cumulativeLoc: number;
19
+ /** Which session this point belongs to (index in sorted array) */
20
+ sessionIndex: number;
21
+ }
22
+
23
+ /** Session boundary marker for vertical dashed lines */
24
+ export interface SessionBoundary {
25
+ visualTime: number;
26
+ title: string;
27
+ sessionIndex: number;
28
+ }
29
+
30
+ function formatLoc(loc: number): string {
31
+ if (loc < 1000) return String(loc);
32
+ return `${(loc / 1000).toFixed(1)}k`;
33
+ }
34
+
35
+ /** @internal Exported for testing */
36
+ export function formatLocAxis(n: number): string {
37
+ if (n === 0) return '0';
38
+ if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
39
+ return String(n);
40
+ }
41
+
42
+ /** @internal Exported for testing */
43
+ export function formatLocDelta(n: number): string {
44
+ if (n >= 1000) return `+${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
45
+ return `+${n}`;
46
+ }
47
+
48
+ /** @internal Exported for testing */
49
+ export function computeAxisTicks(maxVal: number): number[] {
50
+ if (maxVal <= 0) return [0];
51
+ const rawStep = maxVal / 4;
52
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
53
+ const nice = [1, 2, 2.5, 5, 10];
54
+ let step = magnitude;
55
+ for (const n of nice) {
56
+ if (n * magnitude >= rawStep) {
57
+ step = n * magnitude;
58
+ break;
59
+ }
60
+ }
61
+ const ticks: number[] = [];
62
+ for (let v = 0; v <= maxVal + step * 0.1; v += step) {
63
+ ticks.push(Math.round(v));
64
+ }
65
+ if (ticks[ticks.length - 1] < maxVal) {
66
+ ticks.push(ticks[ticks.length - 1] + Math.round(step));
67
+ }
68
+ return ticks;
69
+ }
70
+
71
+ const FIVE_MINUTES_MS = 5 * 60 * 1000;
72
+ const GAP_COMPRESS_THRESHOLD_MS = 60 * 60 * 1000;
73
+ const COMPRESSED_GAP_MS = 10 * 60 * 1000;
74
+
75
+ function bucketTurns(
76
+ turns: Array<{ timestamp: string }>,
77
+ sessionStart: number,
78
+ sessionEnd: number,
79
+ locPerTurn: number,
80
+ ): Array<{ time: number; locDelta: number }> {
81
+ const turnTimes = turns
82
+ .map((t) => new Date(t.timestamp).getTime())
83
+ .filter((t) => !isNaN(t) && t >= sessionStart && t <= sessionEnd + FIVE_MINUTES_MS)
84
+ .sort((a, b) => a - b);
85
+
86
+ if (turnTimes.length === 0) {
87
+ return [{ time: sessionEnd, locDelta: locPerTurn * turns.length }];
88
+ }
89
+
90
+ const buckets = new Map<number, number>();
91
+ for (const t of turnTimes) {
92
+ const bucketStart = sessionStart + Math.floor((t - sessionStart) / FIVE_MINUTES_MS) * FIVE_MINUTES_MS;
93
+ buckets.set(bucketStart, (buckets.get(bucketStart) ?? 0) + 1);
94
+ }
95
+
96
+ return Array.from(buckets.entries())
97
+ .sort(([a], [b]) => a - b)
98
+ .map(([time, count]) => ({
99
+ time: time + FIVE_MINUTES_MS / 2,
100
+ locDelta: count * locPerTurn,
101
+ }));
102
+ }
103
+
104
+ /** @internal Exported for testing */
105
+ export function buildGrowthTimeSeries(
106
+ sessions: Session[],
107
+ ): { points: GrowthPoint[]; boundaries: SessionBoundary[]; totalVisualTime: number } {
108
+ const sorted = [...sessions]
109
+ .filter((s) => s.date)
110
+ .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
111
+
112
+ if (sorted.length === 0) return { points: [], boundaries: [], totalVisualTime: 0 };
113
+
114
+ interface RawPoint {
115
+ realTime: number;
116
+ cumulativeLoc: number;
117
+ sessionIndex: number;
118
+ }
119
+
120
+ const rawPoints: RawPoint[] = [];
121
+ const rawBoundaries: { realTime: number; title: string; sessionIndex: number }[] = [];
122
+ let cumulativeLoc = 0;
123
+
124
+ for (let si = 0; si < sorted.length; si++) {
125
+ const session = sorted[si];
126
+ const sessionStart = new Date(session.date).getTime();
127
+ const sessionEnd = session.endTime
128
+ ? new Date(session.endTime).getTime()
129
+ : sessionStart + session.durationMinutes * 60 * 1000;
130
+ const sessionLoc = Math.max(0, session.linesOfCode);
131
+
132
+ rawBoundaries.push({ realTime: sessionStart, title: session.title, sessionIndex: si });
133
+ rawPoints.push({ realTime: sessionStart, cumulativeLoc, sessionIndex: si });
134
+
135
+ if (sessionLoc === 0) {
136
+ rawPoints.push({ realTime: sessionEnd, cumulativeLoc, sessionIndex: si });
137
+ continue;
138
+ }
139
+
140
+ const timeline = session.turnTimeline;
141
+ if (!timeline || timeline.length === 0) {
142
+ cumulativeLoc += sessionLoc;
143
+ rawPoints.push({ realTime: sessionEnd, cumulativeLoc, sessionIndex: si });
144
+ continue;
145
+ }
146
+
147
+ const editTurns = timeline.filter(
148
+ (t) =>
149
+ t.type === 'tool' &&
150
+ t.tools &&
151
+ t.tools.some((tool) => /edit|write/i.test(tool)),
152
+ );
153
+
154
+ const activeTurns = editTurns.length > 0
155
+ ? editTurns
156
+ : timeline.filter((t) => t.type === 'tool' && t.timestamp);
157
+
158
+ if (activeTurns.length === 0) {
159
+ cumulativeLoc += sessionLoc;
160
+ rawPoints.push({ realTime: sessionEnd, cumulativeLoc, sessionIndex: si });
161
+ continue;
162
+ }
163
+
164
+ const locPerTurn = sessionLoc / activeTurns.length;
165
+ const buckets = bucketTurns(activeTurns, sessionStart, sessionEnd, locPerTurn);
166
+ for (const bucket of buckets) {
167
+ cumulativeLoc += bucket.locDelta;
168
+ rawPoints.push({ realTime: bucket.time, cumulativeLoc, sessionIndex: si });
169
+ }
170
+ }
171
+
172
+ if (rawPoints.length === 0) return { points: [], boundaries: [], totalVisualTime: 0 };
173
+
174
+ let visualTime = 0;
175
+ let prevRealTime = rawPoints[0].realTime;
176
+ const realToVisual = new Map<number, number>();
177
+
178
+ for (const rp of rawPoints) {
179
+ const gap = rp.realTime - prevRealTime;
180
+ if (gap > GAP_COMPRESS_THRESHOLD_MS) {
181
+ visualTime += COMPRESSED_GAP_MS;
182
+ } else {
183
+ visualTime += Math.max(0, gap);
184
+ }
185
+ realToVisual.set(rp.realTime, visualTime);
186
+ prevRealTime = rp.realTime;
187
+ }
188
+
189
+ const points: GrowthPoint[] = rawPoints.map((rp) => ({
190
+ visualTime: realToVisual.get(rp.realTime) ?? 0,
191
+ cumulativeLoc: rp.cumulativeLoc,
192
+ sessionIndex: rp.sessionIndex,
193
+ }));
194
+
195
+ const boundaries: SessionBoundary[] = rawBoundaries.map((b) => {
196
+ let bestVisual = 0;
197
+ let bestDist = Infinity;
198
+ for (const [real, vis] of realToVisual.entries()) {
199
+ const dist = Math.abs(real - b.realTime);
200
+ if (dist < bestDist) {
201
+ bestDist = dist;
202
+ bestVisual = vis;
203
+ }
204
+ }
205
+ return { visualTime: bestVisual, title: b.title, sessionIndex: b.sessionIndex };
206
+ });
207
+
208
+ return { points, boundaries, totalVisualTime: visualTime };
209
+ }
210
+
211
+ /**
212
+ * Build a smooth cubic bezier SVG path through the given points.
213
+ * @internal Exported for testing
214
+ */
215
+ export function buildSmoothPath(
216
+ coords: Array<{ x: number; y: number }>,
217
+ ): string {
218
+ if (coords.length === 0) return '';
219
+ if (coords.length === 1) return `M${coords[0].x.toFixed(1)},${coords[0].y.toFixed(1)}`;
220
+ if (coords.length === 2) {
221
+ return `M${coords[0].x.toFixed(1)},${coords[0].y.toFixed(1)} L${coords[1].x.toFixed(1)},${coords[1].y.toFixed(1)}`;
222
+ }
223
+
224
+ const tension = 0.3;
225
+ let path = `M${coords[0].x.toFixed(1)},${coords[0].y.toFixed(1)}`;
226
+
227
+ for (let i = 0; i < coords.length - 1; i++) {
228
+ const p0 = coords[Math.max(0, i - 1)];
229
+ const p1 = coords[i];
230
+ const p2 = coords[i + 1];
231
+ const p3 = coords[Math.min(coords.length - 1, i + 2)];
232
+
233
+ const cp1x = p1.x + (p2.x - p0.x) * tension;
234
+ const cp1y = p1.y + (p2.y - p0.y) * tension;
235
+ const cp2x = p2.x - (p3.x - p1.x) * tension;
236
+ const cp2y = p2.y - (p3.y - p1.y) * tension;
237
+
238
+ path += ` C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
239
+ }
240
+
241
+ return path;
242
+ }
243
+
244
+ function truncTitle(t: string, max: number = 14): string {
245
+ return t.length > max ? t.slice(0, max - 1) + '\u2026' : t;
246
+ }
247
+
248
+ /** @internal Exported for testing */
249
+ export function GrowthChart({ sessions, totalLoc, totalFiles, onSessionClick }: GrowthChartProps) {
250
+ if (sessions.length === 0) {
251
+ return (
252
+ <div className="growth-chart">
253
+ <div className="growth-chart__svg-container">
254
+ <p style={{ color: 'var(--on-surface-variant)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
255
+ No session data available for growth chart.
256
+ </p>
257
+ </div>
258
+ <div className="growth-chart__summary">
259
+ <div className="growth-chart__total-value">0</div>
260
+ <div className="growth-chart__total-label">LINES OF CODE</div>
261
+ </div>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ const dated = sessions.filter((s) => s.date);
267
+ if (dated.length === 0) {
268
+ return (
269
+ <div className="growth-chart">
270
+ <div className="growth-chart__svg-container">
271
+ <p style={{ color: 'var(--on-surface-variant)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
272
+ No dated sessions available for growth chart.
273
+ </p>
274
+ </div>
275
+ <div className="growth-chart__summary">
276
+ <div className="growth-chart__total-value">{formatLoc(totalLoc)}</div>
277
+ <div className="growth-chart__total-label">LINES OF CODE</div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ const sortedSessions = [...dated].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
284
+ const { points, boundaries, totalVisualTime } = buildGrowthTimeSeries(dated);
285
+
286
+ if (points.length === 0) {
287
+ return (
288
+ <div className="growth-chart">
289
+ <div className="growth-chart__svg-container">
290
+ <p style={{ color: 'var(--on-surface-variant)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
291
+ No dated sessions available for growth chart.
292
+ </p>
293
+ </div>
294
+ <div className="growth-chart__summary">
295
+ <div className="growth-chart__total-value">{formatLoc(totalLoc)}</div>
296
+ <div className="growth-chart__total-label">LINES OF CODE</div>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ const maxLoc = Math.max(...points.map((p) => p.cumulativeLoc), 1);
303
+ const ticks = computeAxisTicks(maxLoc);
304
+ const axisMax = ticks[ticks.length - 1] || 1;
305
+
306
+ const baseWidth = 600;
307
+ const widthPerMinute = 0.8;
308
+ const svgWidth = Math.max(baseWidth, Math.round(totalVisualTime / 60000 * widthPerMinute) + 120);
309
+ const svgHeight = 260;
310
+ const padLeft = 48;
311
+ const padRight = 16;
312
+ const padTop = 32;
313
+ const padBottom = 48;
314
+ const chartW = svgWidth - padLeft - padRight;
315
+ const chartH = svgHeight - padTop - padBottom;
316
+
317
+ const maxVisualTime = totalVisualTime || 1;
318
+ const toX = (vt: number) => padLeft + (vt / maxVisualTime) * chartW;
319
+ const toY = (val: number) => padTop + chartH - (val / axisMax) * chartH;
320
+
321
+ const coords = points.map((p) => ({ x: toX(p.visualTime), y: toY(p.cumulativeLoc) }));
322
+ const linePath = buildSmoothPath(coords);
323
+
324
+ const lastCoord = coords[coords.length - 1];
325
+ const firstCoord = coords[0];
326
+ const areaPath =
327
+ linePath +
328
+ ` L${lastCoord.x.toFixed(1)},${(padTop + chartH).toFixed(1)}` +
329
+ ` L${firstCoord.x.toFixed(1)},${(padTop + chartH).toFixed(1)} Z`;
330
+
331
+ const uniqueBoundaries = boundaries.filter(
332
+ (b, i) => i === 0 || Math.abs(b.visualTime - boundaries[i - 1].visualTime) > 0.001,
333
+ );
334
+
335
+ const sessionCount = dated.length;
336
+ const isScrollable = svgWidth > baseWidth;
337
+
338
+ return (
339
+ <div className="growth-chart">
340
+ <div
341
+ className="growth-chart__svg-container"
342
+ style={isScrollable ? { overflowX: 'auto' } : undefined}
343
+ >
344
+ <svg
345
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
346
+ width={isScrollable ? svgWidth : '100%'}
347
+ height={isScrollable ? svgHeight : undefined}
348
+ preserveAspectRatio="xMidYMid meet"
349
+ role="img"
350
+ aria-label={`Growth chart showing cumulative lines of code across ${sessionCount} sessions`}
351
+ >
352
+ {ticks.map((tick) => (
353
+ <g key={`y-${tick}`}>
354
+ <line x1={padLeft} y1={toY(tick)} x2={svgWidth - padRight} y2={toY(tick)} stroke="var(--outline-variant)" strokeWidth="0.5" strokeDasharray="4,4" />
355
+ <text x={padLeft - 8} y={toY(tick) + 3} textAnchor="end" fontFamily="var(--font-mono)" fontSize="9" fill="var(--on-surface-variant)">{formatLocAxis(tick)}</text>
356
+ </g>
357
+ ))}
358
+
359
+ {uniqueBoundaries.map((b, i) => {
360
+ const clickable = onSessionClick && sortedSessions[b.sessionIndex];
361
+ return (
362
+ <g key={`boundary-${i}`} style={clickable ? { cursor: 'pointer' } : undefined} onClick={clickable ? () => onSessionClick(sortedSessions[b.sessionIndex]) : undefined}>
363
+ <line x1={toX(b.visualTime)} y1={padTop} x2={toX(b.visualTime)} y2={padTop + chartH} stroke="var(--outline-variant)" strokeWidth="0.5" strokeDasharray="3,3" />
364
+ <text x={toX(b.visualTime)} y={padTop + chartH + 16} textAnchor="middle" fontFamily="var(--font-mono)" fontSize="8" fill={clickable ? 'var(--primary)' : 'var(--on-surface-variant)'} textDecoration={clickable ? 'underline' : undefined}>{truncTitle(b.title)}</text>
365
+ </g>
366
+ );
367
+ })}
368
+
369
+ <path d={areaPath} fill="rgba(8,68,113,0.06)" />
370
+ <path d={linePath} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
371
+
372
+ {uniqueBoundaries.map((b, i) => {
373
+ const sessionPts = points.filter((p) => p.sessionIndex === b.sessionIndex);
374
+ if (sessionPts.length === 0) return null;
375
+ const lastPt = sessionPts[sessionPts.length - 1];
376
+ const firstPt = sessionPts[0];
377
+ const delta = lastPt.cumulativeLoc - firstPt.cumulativeLoc +
378
+ (firstPt === points[0] ? firstPt.cumulativeLoc : 0);
379
+ return (
380
+ <g key={`dot-${i}`}>
381
+ <circle cx={toX(lastPt.visualTime)} cy={toY(lastPt.cumulativeLoc)} r="3" fill="var(--secondary)" />
382
+ {delta > 0 && (
383
+ <text x={toX(lastPt.visualTime)} y={toY(lastPt.cumulativeLoc) - 8} textAnchor="middle" fontFamily="var(--font-mono)" fontSize="8" fill="var(--secondary)">{formatLocDelta(delta)}</text>
384
+ )}
385
+ </g>
386
+ );
387
+ })}
388
+ </svg>
389
+ </div>
390
+ <div className="growth-chart__summary">
391
+ <div className="growth-chart__total-value">{formatLoc(totalLoc)}</div>
392
+ <div className="growth-chart__total-label">LINES OF CODE</div>
393
+ <div className="growth-chart__stat">
394
+ <div className="growth-chart__stat-value">{totalFiles}</div>
395
+ <div className="growth-chart__stat-label">FILES TOUCHED</div>
396
+ </div>
397
+ <div className="growth-chart__stat">
398
+ <div className="growth-chart__stat-value">{sessionCount}</div>
399
+ <div className="growth-chart__stat-label">SESSIONS</div>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ );
404
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { WorkTimeline, computeSegments } from './WorkTimeline';
2
2
  export type { WorkTimelineProps } from './WorkTimeline';
3
+
4
+ export { GrowthChart, buildGrowthTimeSeries, buildSmoothPath, formatLocAxis, formatLocDelta, computeAxisTicks } from './GrowthChart';
5
+ export type { GrowthPoint, SessionBoundary } from './GrowthChart';
6
+
7
+ export { DirectoryHeatmap } from './DirectoryHeatmap';
8
+
3
9
  export type {
4
10
  Session,
5
11
  ChildSessionSummary,