@jorgerdz/timeview 0.1.0

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +263 -0
  4. package/dist/cli/timeview.js +6710 -0
  5. package/dist/timeview.cjs +1 -0
  6. package/dist/timeview.js +5667 -0
  7. package/dist/tokens.css +67 -0
  8. package/dist/types/timeview/BandedTimeline.d.ts +11 -0
  9. package/dist/types/timeview/BandedTimeline.d.ts.map +1 -0
  10. package/dist/types/timeview/DensityHeatmap.d.ts +11 -0
  11. package/dist/types/timeview/DensityHeatmap.d.ts.map +1 -0
  12. package/dist/types/timeview/LaneCalendar.d.ts +11 -0
  13. package/dist/types/timeview/LaneCalendar.d.ts.map +1 -0
  14. package/dist/types/timeview/MetricTimeline.d.ts +8 -0
  15. package/dist/types/timeview/MetricTimeline.d.ts.map +1 -0
  16. package/dist/types/timeview/SpanMatrix.d.ts +8 -0
  17. package/dist/types/timeview/SpanMatrix.d.ts.map +1 -0
  18. package/dist/types/timeview/config.d.ts +22 -0
  19. package/dist/types/timeview/config.d.ts.map +1 -0
  20. package/dist/types/timeview/core/aggregate.d.ts +113 -0
  21. package/dist/types/timeview/core/aggregate.d.ts.map +1 -0
  22. package/dist/types/timeview/core/calendar.d.ts +27 -0
  23. package/dist/types/timeview/core/calendar.d.ts.map +1 -0
  24. package/dist/types/timeview/core/intervals.d.ts +8 -0
  25. package/dist/types/timeview/core/intervals.d.ts.map +1 -0
  26. package/dist/types/timeview/core/labels.d.ts +5 -0
  27. package/dist/types/timeview/core/labels.d.ts.map +1 -0
  28. package/dist/types/timeview/core/metric.d.ts +58 -0
  29. package/dist/types/timeview/core/metric.d.ts.map +1 -0
  30. package/dist/types/timeview/core/time.d.ts +22 -0
  31. package/dist/types/timeview/core/time.d.ts.map +1 -0
  32. package/dist/types/timeview/dashboard.d.ts +17 -0
  33. package/dist/types/timeview/dashboard.d.ts.map +1 -0
  34. package/dist/types/timeview/data.d.ts +21 -0
  35. package/dist/types/timeview/data.d.ts.map +1 -0
  36. package/dist/types/timeview/export.d.ts +14 -0
  37. package/dist/types/timeview/export.d.ts.map +1 -0
  38. package/dist/types/timeview/index.d.ts +28 -0
  39. package/dist/types/timeview/index.d.ts.map +1 -0
  40. package/dist/types/timeview/registry.d.ts +285 -0
  41. package/dist/types/timeview/registry.d.ts.map +1 -0
  42. package/dist/types/timeview/shared/Caption.d.ts +9 -0
  43. package/dist/types/timeview/shared/Caption.d.ts.map +1 -0
  44. package/dist/types/timeview/shared/EmptyState.d.ts +16 -0
  45. package/dist/types/timeview/shared/EmptyState.d.ts.map +1 -0
  46. package/dist/types/timeview/shared/Legend.d.ts +10 -0
  47. package/dist/types/timeview/shared/Legend.d.ts.map +1 -0
  48. package/dist/types/timeview/shared/Tooltip.d.ts +15 -0
  49. package/dist/types/timeview/shared/Tooltip.d.ts.map +1 -0
  50. package/dist/types/timeview/shared/useMeasuredWidth.d.ts +2 -0
  51. package/dist/types/timeview/shared/useMeasuredWidth.d.ts.map +1 -0
  52. package/dist/types/timeview/types.d.ts +158 -0
  53. package/dist/types/timeview/types.d.ts.map +1 -0
  54. package/docs/AGENT-USAGE.md +93 -0
  55. package/docs/COMPATIBILITY.md +134 -0
  56. package/docs/STUDIO.md +41 -0
  57. package/examples/README.md +21 -0
  58. package/examples/configs/bandedTimeline.json +31 -0
  59. package/examples/configs/densityHeatmap.json +33 -0
  60. package/examples/configs/laneCalendar.json +31 -0
  61. package/examples/configs/metricTimeline.json +51 -0
  62. package/examples/configs/spanMatrix.json +31 -0
  63. package/package.json +94 -0
  64. package/render.html +12 -0
  65. package/src/render.tsx +67 -0
  66. package/src/styles/tokens.css +67 -0
  67. package/src/timeview/BandedTimeline.tsx +620 -0
  68. package/src/timeview/DensityHeatmap.tsx +513 -0
  69. package/src/timeview/LaneCalendar.tsx +496 -0
  70. package/src/timeview/MetricTimeline.tsx +993 -0
  71. package/src/timeview/SpanMatrix.tsx +721 -0
  72. package/src/timeview/config.ts +399 -0
  73. package/src/timeview/core/aggregate.ts +317 -0
  74. package/src/timeview/core/calendar.ts +81 -0
  75. package/src/timeview/core/intervals.ts +52 -0
  76. package/src/timeview/core/labels.ts +19 -0
  77. package/src/timeview/core/metric.ts +263 -0
  78. package/src/timeview/core/time.ts +103 -0
  79. package/src/timeview/dashboard.ts +80 -0
  80. package/src/timeview/data.ts +242 -0
  81. package/src/timeview/export.ts +48 -0
  82. package/src/timeview/index.ts +106 -0
  83. package/src/timeview/registry.ts +207 -0
  84. package/src/timeview/shared/Caption.tsx +40 -0
  85. package/src/timeview/shared/EmptyState.tsx +90 -0
  86. package/src/timeview/shared/Legend.tsx +67 -0
  87. package/src/timeview/shared/Tooltip.tsx +59 -0
  88. package/src/timeview/shared/useMeasuredWidth.ts +21 -0
  89. package/src/timeview/types.ts +159 -0
  90. package/vite.config.ts +11 -0
@@ -0,0 +1,513 @@
1
+ // DensityHeatmap.tsx — Timeview aggregation view.
2
+ //
3
+ // Renders a TimeDataset as a {category × time-bucket} density matrix. The
4
+ // bucketing math lives in core/aggregate; this file is presentational.
5
+
6
+ import { Fragment, useMemo, useState } from "react";
7
+ import type { CSSProperties } from "react";
8
+ import { DAY_MS, fmt } from "./core/time";
9
+ import { tvAggregate, tvFmtDuration } from "./core/aggregate";
10
+ import type { AggregateCell, AggregateGroup, AggregateItem, TimeBucket } from "./core/aggregate";
11
+ import { colorMap, mix } from "./core/labels";
12
+ import { Caption } from "./shared/Caption";
13
+ import { EmptyState } from "./shared/EmptyState";
14
+ import { useMeasuredWidth } from "./shared/useMeasuredWidth";
15
+ import type { Density, DensityHeatmapSpec, TimeDataset } from "./types";
16
+
17
+ export interface DensityHeatmapProps {
18
+ /** schema `timeview.dataset.v1` */
19
+ dataset: TimeDataset;
20
+ /** aggregation projection (bucket, measure, grouping, heat scale, caption...) */
21
+ spec: DensityHeatmapSpec;
22
+ /** colors mapped to `dataset.labels` by index */
23
+ palette: string[];
24
+ }
25
+
26
+ const HM_SLATE = "#475569";
27
+
28
+ interface DensitySpec {
29
+ pad: number;
30
+ rowH: number;
31
+ labelW: number;
32
+ cellMin: number;
33
+ head: number;
34
+ num: number;
35
+ }
36
+
37
+ const DENS: Record<Density, DensitySpec> = {
38
+ comfortable: { pad: 22, rowH: 34, labelW: 138, cellMin: 34, head: 30, num: 11.5 },
39
+ compact: { pad: 16, rowH: 27, labelW: 116, cellMin: 27, head: 26, num: 10.5 },
40
+ };
41
+
42
+ type Hover = {
43
+ id: string;
44
+ g: AggregateGroup;
45
+ b: TimeBucket;
46
+ cell: AggregateCell;
47
+ base: string;
48
+ mx: number;
49
+ my: number;
50
+ };
51
+
52
+ function hmFill(t: number, base: string, empty: boolean): string {
53
+ if (empty) return "var(--tv-rail)";
54
+ const pct = 16 + Math.pow(t, 0.85) * 76;
55
+ return mix(base, pct);
56
+ }
57
+
58
+ function hmInkOn(t: number): string {
59
+ return t > 0.52 ? "#fff" : "var(--tv-ink-2)";
60
+ }
61
+
62
+ export function DensityHeatmap({ dataset, spec, palette }: DensityHeatmapProps) {
63
+ const [wrapRef, w] = useMeasuredWidth<HTMLDivElement>();
64
+ const [hover, setHover] = useState<Hover | null>(null);
65
+ const [sel, setSel] = useState<string | null>(null);
66
+
67
+ const density: Density = spec.density || "comfortable";
68
+ const D = DENS[density] || DENS.comfortable;
69
+ const measure = spec.measure || "count";
70
+ const bucketMode = spec.bucket || "day";
71
+ const groupBy = spec.groupBy || "category";
72
+ const scaleMode = spec.scaleMode || "category";
73
+ const showValues = spec.showValues !== false;
74
+ const captionPos = spec.caption?.position || "bottom";
75
+ const captionText = spec.caption?.text || "";
76
+ const captionOn = captionPos !== "off" && !!captionText;
77
+ const narrow = w < 560;
78
+
79
+ const cmap = useMemo(() => colorMap(dataset, palette), [dataset, palette]);
80
+ const agg = useMemo(() => tvAggregate(dataset, { bucket: bucketMode, measure, groupBy }), [dataset, bucketMode, measure, groupBy]);
81
+ const isEmpty = agg.max === 0;
82
+ const labelW = narrow ? Math.max(84, D.labelW - 30) : D.labelW;
83
+ const baseFor = (g: AggregateGroup) => (scaleMode === "uniform" || groupBy === "none" ? HM_SLATE : cmap[g.labelId || ""] || HM_SLATE);
84
+ const t01 = (v: number) => (agg.max > 0 ? v / agg.max : 0);
85
+
86
+ const track = (e: { clientX: number; clientY: number }) => {
87
+ const r = wrapRef.current!.getBoundingClientRect();
88
+ return { mx: e.clientX - r.left, my: e.clientY - r.top };
89
+ };
90
+
91
+ const cellLabel = (cell: AggregateCell) => {
92
+ if (measure === "duration") return cell.durationMs > 0 ? tvFmtDuration(cell.durationMs) : "";
93
+ return cell.count > 0 ? String(cell.count) : "";
94
+ };
95
+
96
+ const ScaleKey = () => {
97
+ const steps = [0, 0.18, 0.42, 0.7, 1];
98
+ const unit = measure === "duration" ? "duration" : "count";
99
+ const maxLabel = measure === "duration" ? tvFmtDuration(agg.max) : agg.max;
100
+ return (
101
+ <div
102
+ style={{
103
+ display: "flex",
104
+ alignItems: "center",
105
+ gap: 10,
106
+ flexWrap: "wrap",
107
+ padding: "14px 0 2px",
108
+ borderTop: "1px solid var(--tv-line)",
109
+ marginTop: 6,
110
+ }}
111
+ >
112
+ <span
113
+ style={{
114
+ fontFamily: "var(--tv-mono)",
115
+ fontSize: 10,
116
+ letterSpacing: ".08em",
117
+ textTransform: "uppercase",
118
+ color: "var(--tv-ink-4)",
119
+ }}
120
+ >
121
+ Density · {unit}
122
+ </span>
123
+ <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
124
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10.5, color: "var(--tv-ink-4)" }}>0</span>
125
+ <div style={{ display: "flex", gap: 2 }}>
126
+ {steps.map((s, i) => (
127
+ <span
128
+ key={i}
129
+ title={i === 0 ? "empty" : undefined}
130
+ style={{
131
+ width: 22,
132
+ height: 13,
133
+ borderRadius: 3,
134
+ background: hmFill(s, HM_SLATE, s === 0),
135
+ boxShadow: "inset 0 0 0 1px rgba(16,24,40,0.06)",
136
+ }}
137
+ />
138
+ ))}
139
+ </div>
140
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10.5, color: "var(--tv-ink-3)" }}>{maxLabel}</span>
141
+ </div>
142
+ {scaleMode === "category" && groupBy === "category" && (
143
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 11.5, color: "var(--tv-ink-4)" }}>
144
+ · each row ramps its own category hue
145
+ </span>
146
+ )}
147
+ </div>
148
+ );
149
+ };
150
+
151
+ const stickyCol: CSSProperties = { position: "sticky", left: 0, zIndex: 3, background: "var(--tv-canvas)" };
152
+ const gridCols = `${labelW}px repeat(${agg.buckets.length}, minmax(${D.cellMin}px, 1fr))`;
153
+ const innerMin = labelW + agg.buckets.length * D.cellMin;
154
+
155
+ return (
156
+ <div
157
+ ref={wrapRef}
158
+ className="tv-component"
159
+ style={{
160
+ background: "var(--tv-canvas)",
161
+ border: "1px solid var(--tv-line)",
162
+ borderRadius: "var(--tv-r-lg)",
163
+ boxShadow: "var(--tv-shadow-1)",
164
+ padding: D.pad,
165
+ fontFamily: "var(--tv-font)",
166
+ color: "var(--tv-ink)",
167
+ position: "relative",
168
+ width: "100%",
169
+ overflow: "hidden",
170
+ }}
171
+ >
172
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 6, flexWrap: "wrap" }}>
173
+ <div
174
+ style={{
175
+ fontSize: narrow ? 15 : 17,
176
+ fontWeight: 600,
177
+ letterSpacing: "-0.01em",
178
+ color: "var(--tv-ink)",
179
+ lineHeight: 1.25,
180
+ minWidth: 0,
181
+ }}
182
+ >
183
+ {spec.title || dataset.meta?.title}
184
+ </div>
185
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)", whiteSpace: "nowrap", flex: "none" }}>
186
+ {fmt.monDay(agg.t0)} – {fmt.monDay(new Date(agg.t1 - DAY_MS))}
187
+ </div>
188
+ </div>
189
+
190
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", marginBottom: 16 }}>
191
+ {measure === "duration" ? "active duration" : "item count"} · per {bucketMode} · by {groupBy === "none" ? "total" : "category"}
192
+ </div>
193
+
194
+ {captionOn && captionPos === "top" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
195
+
196
+ <div
197
+ style={{
198
+ position: "relative",
199
+ overflowX: "auto",
200
+ overflowY: "hidden",
201
+ border: "1px solid var(--tv-line)",
202
+ borderRadius: "var(--tv-r-md)",
203
+ }}
204
+ >
205
+ <div style={{ display: "grid", gridTemplateColumns: gridCols, minWidth: innerMin }}>
206
+ <div
207
+ style={{
208
+ ...stickyCol,
209
+ height: D.head,
210
+ borderBottom: "1px solid var(--tv-line)",
211
+ display: "flex",
212
+ alignItems: "center",
213
+ paddingLeft: 11,
214
+ }}
215
+ >
216
+ <span
217
+ style={{
218
+ fontFamily: "var(--tv-mono)",
219
+ fontSize: 10,
220
+ letterSpacing: ".06em",
221
+ textTransform: "uppercase",
222
+ color: "var(--tv-ink-4)",
223
+ }}
224
+ >
225
+ {bucketMode === "week" ? "Week" : "Day"}
226
+ </span>
227
+ </div>
228
+
229
+ {agg.buckets.map((b) => {
230
+ const strong = b.mode === "week" || b.isMonday;
231
+ const monthStart = b.mode === "day" && new Date(b.start).getUTCDate() === 1;
232
+ return (
233
+ <div
234
+ key={b.key}
235
+ style={{
236
+ height: D.head,
237
+ borderBottom: "1px solid var(--tv-line)",
238
+ borderLeft: `1px solid ${strong ? "var(--tv-grid-week)" : "var(--tv-grid)"}`,
239
+ display: "flex",
240
+ flexDirection: "column",
241
+ alignItems: "center",
242
+ justifyContent: "center",
243
+ background: b.isWeekend ? "var(--tv-rail)" : "transparent",
244
+ gap: 1,
245
+ overflow: "hidden",
246
+ }}
247
+ >
248
+ <span
249
+ style={{
250
+ fontFamily: "var(--tv-mono)",
251
+ fontSize: 11,
252
+ fontWeight: 600,
253
+ color: monthStart ? "var(--tv-accent)" : "var(--tv-ink-2)",
254
+ lineHeight: 1,
255
+ }}
256
+ >
257
+ {b.label}
258
+ </span>
259
+ {!narrow && (
260
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 9, color: "var(--tv-ink-4)", lineHeight: 1 }}>{b.sub}</span>
261
+ )}
262
+ </div>
263
+ );
264
+ })}
265
+
266
+ {agg.groups.map((g, gi) => {
267
+ const base = baseFor(g);
268
+ const last = gi === agg.groups.length - 1;
269
+ return (
270
+ <Fragment key={g.id}>
271
+ <div
272
+ key={g.id + "-label"}
273
+ style={{
274
+ ...stickyCol,
275
+ height: D.rowH,
276
+ display: "flex",
277
+ alignItems: "center",
278
+ gap: 8,
279
+ padding: "0 10px",
280
+ borderBottom: last ? "none" : "1px solid var(--tv-grid)",
281
+ boxShadow: "1px 0 0 var(--tv-line)",
282
+ }}
283
+ >
284
+ <span
285
+ style={{
286
+ width: 9,
287
+ height: 9,
288
+ borderRadius: 2,
289
+ background: base,
290
+ flex: "none",
291
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.14)",
292
+ }}
293
+ />
294
+ <span
295
+ style={{
296
+ fontFamily: "var(--tv-font)",
297
+ fontSize: narrow ? 11.5 : 12.5,
298
+ fontWeight: 500,
299
+ color: "var(--tv-ink-2)",
300
+ whiteSpace: "nowrap",
301
+ overflow: "hidden",
302
+ textOverflow: "ellipsis",
303
+ }}
304
+ >
305
+ {g.name}
306
+ </span>
307
+ </div>
308
+
309
+ {agg.buckets.map((b) => {
310
+ const cell = agg.cells[g.id][b.key];
311
+ const v = agg.value(cell);
312
+ const t = t01(v);
313
+ const empty = v === 0;
314
+ const strong = b.mode === "week" || b.isMonday;
315
+ const id = g.id + "|" + b.key;
316
+ const hovered = hover?.id === id;
317
+ const selected = sel === id;
318
+ const txt = showValues ? cellLabel(cell) : "";
319
+ return (
320
+ <div
321
+ key={g.id + b.key}
322
+ tabIndex={0}
323
+ role="button"
324
+ aria-label={`${g.name}, ${b.mode === "week" ? "week of " : ""}${fmt.monDay(new Date(b.start))}: ${cell.count} items${
325
+ measure === "duration" ? ", " + tvFmtDuration(cell.durationMs) : ""
326
+ }`}
327
+ onMouseEnter={(e) => setHover({ id, g, b, cell, base, ...track(e) })}
328
+ onMouseMove={(e) => setHover({ id, g, b, cell, base, ...track(e) })}
329
+ onFocus={() => setHover({ id, g, b, cell, base, mx: labelW + b.index * 40, my: 80 + gi * D.rowH })}
330
+ onMouseLeave={() => setHover(null)}
331
+ onBlur={() => setHover(null)}
332
+ onClick={() => setSel((s) => (s === id ? null : id))}
333
+ style={{
334
+ height: D.rowH,
335
+ borderBottom: last ? "none" : "1px solid var(--tv-canvas)",
336
+ borderLeft: `1px solid ${empty ? (strong ? "var(--tv-grid-week)" : "var(--tv-grid)") : "rgba(255,255,255,0.7)"}`,
337
+ background: hmFill(t, base, empty),
338
+ display: "flex",
339
+ alignItems: "center",
340
+ justifyContent: "center",
341
+ cursor: "pointer",
342
+ outline: "none",
343
+ position: "relative",
344
+ boxShadow: selected
345
+ ? "var(--tv-focus-ring)"
346
+ : hovered
347
+ ? "inset 0 0 0 2px rgba(255,255,255,0.85), var(--tv-shadow-pop)"
348
+ : "none",
349
+ zIndex: hovered || selected ? 4 : "auto",
350
+ transition: "box-shadow var(--tv-fast) var(--tv-ease)",
351
+ }}
352
+ >
353
+ {txt && (
354
+ <span
355
+ style={{
356
+ fontFamily: "var(--tv-mono)",
357
+ fontSize: D.num,
358
+ fontWeight: 600,
359
+ color: hmInkOn(t),
360
+ lineHeight: 1,
361
+ pointerEvents: "none",
362
+ }}
363
+ >
364
+ {txt}
365
+ </span>
366
+ )}
367
+ </div>
368
+ );
369
+ })}
370
+ </Fragment>
371
+ );
372
+ })}
373
+ </div>
374
+
375
+ {isEmpty && (
376
+ <EmptyState title="No activity in this window" icon="grid" background narrow={narrow} />
377
+ )}
378
+ </div>
379
+
380
+ <ScaleKey />
381
+ {captionOn && captionPos === "bottom" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
382
+
383
+ {hover && (
384
+ <HeatmapTooltip
385
+ left={Math.max(8, Math.min(w - 248 - 8, hover.mx - 124))}
386
+ top={Math.max(8, hover.my - 96)}
387
+ width={248}
388
+ base={hover.base}
389
+ group={hover.g.name}
390
+ range={
391
+ hover.b.mode === "week"
392
+ ? `${fmt.full(new Date(hover.b.start))} → ${fmt.full(new Date(hover.b.end - DAY_MS))}`
393
+ : fmt.full(new Date(hover.b.start))
394
+ }
395
+ count={hover.cell.count}
396
+ durationMs={hover.cell.durationMs}
397
+ items={hover.cell.items}
398
+ cmap={cmap}
399
+ />
400
+ )}
401
+ </div>
402
+ );
403
+ }
404
+
405
+ function HeatmapTooltip({
406
+ left,
407
+ top,
408
+ width,
409
+ base,
410
+ group,
411
+ range,
412
+ count,
413
+ durationMs,
414
+ items,
415
+ cmap,
416
+ }: {
417
+ left: number;
418
+ top: number;
419
+ width: number;
420
+ base: string;
421
+ group: string;
422
+ range: string;
423
+ count: number;
424
+ durationMs: number;
425
+ items: AggregateItem[];
426
+ cmap: Record<string, string>;
427
+ }) {
428
+ const shown = items.slice(0, 4);
429
+ const more = items.length - shown.length;
430
+ return (
431
+ <div style={{ position: "absolute", left, top, width, pointerEvents: "none", zIndex: 50 }}>
432
+ <div
433
+ style={{
434
+ background: "#fff",
435
+ border: "1px solid var(--tv-line)",
436
+ borderRadius: "var(--tv-r-md)",
437
+ boxShadow: "var(--tv-shadow-pop)",
438
+ padding: "10px 13px",
439
+ }}
440
+ >
441
+ <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
442
+ <span style={{ width: 9, height: 9, borderRadius: 2, background: base, flex: "none" }} />
443
+ <span
444
+ style={{
445
+ fontFamily: "var(--tv-mono)",
446
+ fontSize: 10,
447
+ letterSpacing: ".05em",
448
+ textTransform: "uppercase",
449
+ color: "var(--tv-ink-4)",
450
+ }}
451
+ >
452
+ {group}
453
+ </span>
454
+ </div>
455
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", lineHeight: 1.45, marginBottom: 6 }}>
456
+ {range}
457
+ </div>
458
+ <div style={{ display: "flex", gap: 16, marginBottom: count ? 8 : 0 }}>
459
+ <Metric value={count} label="items" />
460
+ <Metric value={tvFmtDuration(durationMs)} label="active" />
461
+ </div>
462
+ {shown.length > 0 && (
463
+ <div style={{ borderTop: "1px solid var(--tv-line)", paddingTop: 7, display: "flex", flexDirection: "column", gap: 4 }}>
464
+ {shown.map((it) => (
465
+ <div key={it.id + it.kind} style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
466
+ <span
467
+ style={{
468
+ width: 7,
469
+ height: 7,
470
+ flex: "none",
471
+ borderRadius: it.kind === "event" ? 1 : "50%",
472
+ transform: it.kind === "event" ? "rotate(45deg)" : "none",
473
+ background: it.kind === "event" ? "#fff" : cmap[it.labelId || ""] || base,
474
+ border: it.kind === "event" ? `1.5px solid ${cmap[it.labelId || ""] || base}` : "none",
475
+ }}
476
+ />
477
+ <span style={{ fontSize: 11.5, color: "var(--tv-ink-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
478
+ {it.title}
479
+ </span>
480
+ {it.kind === "interval" && (
481
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, color: "var(--tv-ink-4)", marginLeft: "auto", flex: "none" }}>
482
+ {tvFmtDuration(it.overlapMs)}
483
+ </span>
484
+ )}
485
+ </div>
486
+ ))}
487
+ {more > 0 && <div style={{ fontFamily: "var(--tv-mono)", fontSize: 10.5, color: "var(--tv-ink-4)" }}>+{more} more</div>}
488
+ </div>
489
+ )}
490
+ </div>
491
+ </div>
492
+ );
493
+ }
494
+
495
+ function Metric({ value, label }: { value: string | number; label: string }) {
496
+ return (
497
+ <div>
498
+ <div style={{ fontSize: 17, fontWeight: 600, color: "var(--tv-ink)", lineHeight: 1 }}>{value}</div>
499
+ <div
500
+ style={{
501
+ fontFamily: "var(--tv-mono)",
502
+ fontSize: 9.5,
503
+ color: "var(--tv-ink-4)",
504
+ textTransform: "uppercase",
505
+ letterSpacing: ".05em",
506
+ marginTop: 3,
507
+ }}
508
+ >
509
+ {label}
510
+ </div>
511
+ </div>
512
+ );
513
+ }