@workflow/web-shared 5.0.0-beta.27 → 5.0.0-beta.28

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 (28) hide show
  1. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  2. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  3. package/dist/components/new-trace-viewer/components/event-list.js +3 -5
  4. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  5. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  7. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  8. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  9. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  10. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +6 -2
  11. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/trace-viewer.js +46 -94
  13. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  14. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/utils.js +44 -3
  16. package/dist/lib/utils.d.ts +8 -5
  17. package/dist/lib/utils.d.ts.map +1 -1
  18. package/dist/lib/utils.js +13 -16
  19. package/package.json +5 -5
  20. package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
  21. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  22. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  23. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +17 -0
  24. package/src/components/new-trace-viewer/trace-viewer.tsx +77 -115
  25. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  26. package/src/components/new-trace-viewer/utils.ts +76 -2
  27. package/src/lib/utils.test.ts +33 -0
  28. package/src/lib/utils.ts +12 -16
@@ -12,13 +12,17 @@ import {
12
12
  } from 'react';
13
13
  import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
14
14
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
15
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
15
+ import {
16
+ formatDurationPrecise,
17
+ getHighResInMs,
18
+ } from '../trace-viewer/util/timing';
16
19
  import { IconButton } from '../ui/icon-button';
17
20
  import { Kbd } from '../ui/kbd';
18
21
  import { Spinner } from '../ui/spinner';
19
22
  import { TooltipProvider } from '../ui/tooltip';
20
23
  import { TraceDetailPanel } from './components/detail-panel';
21
24
  import EventList from './components/event-list';
25
+ import { Minimap } from './components/minimap';
22
26
  import { SplitPane } from './components/split-pane';
23
27
  import {
24
28
  TIMELINE_PADDING_PX,
@@ -30,7 +34,14 @@ import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
30
34
  import { ActiveSpanProvider, useActiveSpan } from './context';
31
35
  import { searchSpans } from './search';
32
36
  import type { TraceWithMeta } from './types';
33
- import { computeRootBounds, computeTimeMarkers } from './utils';
37
+ import {
38
+ clampViewportToRoot,
39
+ computeRootBounds,
40
+ computeTimeMarkers,
41
+ type ViewportRange,
42
+ wheelDeltaToPixels,
43
+ wheelZoomScaleFactor,
44
+ } from './utils';
34
45
 
35
46
  interface NewTraceViewerProps {
36
47
  trace: TraceWithMeta;
@@ -43,17 +54,12 @@ const MIN_VIEWPORT_MS = 0.001;
43
54
 
44
55
  const ZOOM_DEBOUNCE_MS = 150;
45
56
 
46
- interface Viewport {
47
- start: number;
48
- end: number;
49
- }
50
-
51
- function useAnimatedViewport(initial: Viewport) {
52
- const [viewport, setViewportState] = useState<Viewport>(initial);
57
+ function useAnimatedViewport(initial: ViewportRange) {
58
+ const [viewport, setViewportState] = useState<ViewportRange>(initial);
53
59
  const animRef = useRef<{
54
60
  raf: number;
55
- from: Viewport;
56
- to: Viewport;
61
+ from: ViewportRange;
62
+ to: ViewportRange;
57
63
  start: number;
58
64
  } | null>(null);
59
65
  const currentRef = useRef(initial);
@@ -68,7 +74,7 @@ function useAnimatedViewport(initial: Viewport) {
68
74
  }, []);
69
75
 
70
76
  const animateTo = useCallback(
71
- (target: Viewport) => {
77
+ (target: ViewportRange) => {
72
78
  cancel();
73
79
 
74
80
  if (reducedMotion) {
@@ -98,7 +104,7 @@ function useAnimatedViewport(initial: Viewport) {
98
104
  );
99
105
 
100
106
  const setViewport = useCallback(
101
- (update: Viewport | ((prev: Viewport) => Viewport)) => {
107
+ (update: ViewportRange | ((prev: ViewportRange) => ViewportRange)) => {
102
108
  cancel();
103
109
  if (typeof update === 'function') {
104
110
  setViewportState((prev) => {
@@ -180,7 +186,7 @@ function NewTraceViewerContent({
180
186
  end: root.startTime + root.duration,
181
187
  });
182
188
 
183
- const prevRootRef = useRef<Viewport>({
189
+ const prevRootRef = useRef<ViewportRange>({
184
190
  start: root.startTime,
185
191
  end: root.startTime + root.duration,
186
192
  });
@@ -221,60 +227,45 @@ function NewTraceViewerContent({
221
227
  animateTo({ start: root.startTime, end: root.startTime + root.duration });
222
228
  }, [animateTo, root.startTime, root.duration]);
223
229
 
230
+ const clampToRoot = useCallback(
231
+ (next: ViewportRange): ViewportRange =>
232
+ clampViewportToRoot(next, root.startTime, root.endTime, MIN_VIEWPORT_MS),
233
+ [root.startTime, root.endTime]
234
+ );
235
+
224
236
  // Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
225
237
  // off-screen marker indicators to scroll their marker into view.
226
238
  const handleRevealTime = useCallback(
227
239
  (timeMs: number) => {
228
- const rootS = root.startTime;
229
- const rootE = root.startTime + root.duration;
230
240
  const { start, end } = viewportRef.current;
231
241
  const duration = end - start;
232
- let newStart = timeMs - duration / 2;
233
- let newEnd = timeMs + duration / 2;
234
- if (newStart < rootS) {
235
- newStart = rootS;
236
- newEnd = rootS + duration;
237
- }
238
- if (newEnd > rootE) {
239
- newEnd = rootE;
240
- newStart = Math.max(rootS, rootE - duration);
241
- }
242
- animateTo({ start: newStart, end: newEnd });
242
+ animateTo(
243
+ clampToRoot({
244
+ start: timeMs - duration / 2,
245
+ end: timeMs + duration / 2,
246
+ })
247
+ );
243
248
  },
244
- [animateTo, root.startTime, root.duration]
249
+ [animateTo, clampToRoot]
245
250
  );
246
251
 
247
252
  const ZOOM_FACTOR = 0.5;
248
253
 
249
254
  const zoomBy = useCallback(
250
255
  (factor: number) => {
251
- const rootS = root.startTime;
252
- const rootE = root.startTime + root.duration;
253
- const rootD = root.duration;
254
-
255
256
  setViewport((prev) => {
256
- const prevDuration = prev.end - prev.start;
257
257
  const center = (prev.start + prev.end) / 2;
258
258
  const newDuration = Math.max(
259
259
  MIN_VIEWPORT_MS,
260
- Math.min(rootD, prevDuration * factor)
260
+ (prev.end - prev.start) * factor
261
261
  );
262
- let newStart = center - newDuration / 2;
263
- let newEnd = center + newDuration / 2;
264
-
265
- if (newStart < rootS) {
266
- newStart = rootS;
267
- newEnd = rootS + newDuration;
268
- }
269
- if (newEnd > rootE) {
270
- newEnd = rootE;
271
- newStart = Math.max(rootS, rootE - newDuration);
272
- }
273
-
274
- return { start: newStart, end: newEnd };
262
+ return clampToRoot({
263
+ start: center - newDuration / 2,
264
+ end: center + newDuration / 2,
265
+ });
275
266
  });
276
267
  },
277
- [setViewport, root.startTime, root.duration]
268
+ [setViewport, clampToRoot]
278
269
  );
279
270
 
280
271
  const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
@@ -291,12 +282,8 @@ function NewTraceViewerContent({
291
282
  const spanEnd = getHighResInMs(span.endTime);
292
283
  const spanDuration = spanEnd - spanStart;
293
284
 
294
- const rootS = root.startTime;
295
- const rootE = root.startTime + root.duration;
296
- const rootD = root.duration;
297
-
298
- if (spanDuration > rootD * 0.8) {
299
- animateTo({ start: rootS, end: rootE });
285
+ if (spanDuration > root.duration * 0.8) {
286
+ animateTo({ start: root.startTime, end: root.endTime });
300
287
  return;
301
288
  }
302
289
 
@@ -310,20 +297,16 @@ function NewTraceViewerContent({
310
297
  newEnd = center + MIN_VIEWPORT_MS / 2;
311
298
  }
312
299
 
313
- if (newStart < rootS) {
314
- const duration = newEnd - newStart;
315
- newStart = rootS;
316
- newEnd = Math.min(rootE, rootS + duration);
317
- }
318
- if (newEnd > rootE) {
319
- const duration = newEnd - newStart;
320
- newEnd = rootE;
321
- newStart = Math.max(rootS, rootE - duration);
322
- }
323
-
324
- animateTo({ start: newStart, end: newEnd });
300
+ animateTo(clampToRoot({ start: newStart, end: newEnd }));
325
301
  },
326
- [animateTo, trace.spans, root.startTime, root.duration]
302
+ [
303
+ animateTo,
304
+ trace.spans,
305
+ root.startTime,
306
+ root.endTime,
307
+ root.duration,
308
+ clampToRoot,
309
+ ]
327
310
  );
328
311
 
329
312
  // Bring a row into view when keyboard/button navigation lands on a span that
@@ -423,7 +406,7 @@ function NewTraceViewerContent({
423
406
  if (hoverFraction == null) return null;
424
407
  const absTime = viewport.start + hoverFraction * viewDuration;
425
408
  const offset = absTime - root.startTime;
426
- return { fraction: hoverFraction, label: formatDuration(offset, true) };
409
+ return { fraction: hoverFraction, label: formatDurationPrecise(offset) };
427
410
  }, [hoverFraction, viewport.start, viewDuration, root.startTime]);
428
411
 
429
412
  const handleTimelineMouseMove = useCallback(
@@ -451,12 +434,7 @@ function NewTraceViewerContent({
451
434
 
452
435
  useEffect(() => {
453
436
  const el = timelineRef.current;
454
- if (!el) return;
455
-
456
- const rootS = root.startTime;
457
- const rootE = root.startTime + root.duration;
458
- const rootD = root.duration;
459
- if (rootD <= 0) return;
437
+ if (!el || root.duration <= 0) return;
460
438
 
461
439
  const onWheel = (e: WheelEvent): void => {
462
440
  const isZoomGesture = e.ctrlKey || e.metaKey;
@@ -470,9 +448,6 @@ function NewTraceViewerContent({
470
448
  if (contentWidth <= 0) return;
471
449
 
472
450
  if (isZoomGesture) {
473
- let dy = e.deltaY;
474
- if (e.deltaMode === 1) dy *= 16;
475
-
476
451
  const cursorFraction = Math.max(
477
452
  0,
478
453
  Math.min(
@@ -480,59 +455,37 @@ function NewTraceViewerContent({
480
455
  (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
481
456
  )
482
457
  );
483
- const isMouseWheel = e.deltaMode === 1 || Math.abs(e.deltaY) >= 50;
484
- const scaleFactor = 2 ** (dy / (isMouseWheel ? 200 : 60));
458
+ const scaleFactor = wheelZoomScaleFactor(e);
485
459
 
486
460
  setViewport((prev) => {
487
461
  const prevDuration = prev.end - prev.start;
488
462
  const cursorTime = prev.start + cursorFraction * prevDuration;
463
+ // Clamp the duration before anchoring so the cursor keeps its
464
+ // fraction even when the zoom hits the min/max bounds.
489
465
  const newDuration = Math.max(
490
466
  MIN_VIEWPORT_MS,
491
- Math.min(rootD, prevDuration * scaleFactor)
467
+ Math.min(root.duration, prevDuration * scaleFactor)
492
468
  );
493
-
494
- let newStart = cursorTime - cursorFraction * newDuration;
495
- let newEnd = newStart + newDuration;
496
-
497
- if (newStart < rootS) {
498
- newStart = rootS;
499
- newEnd = rootS + newDuration;
500
- }
501
- if (newEnd > rootE) {
502
- newEnd = rootE;
503
- newStart = Math.max(rootS, rootE - newDuration);
504
- }
505
-
506
- return { start: newStart, end: newEnd };
469
+ return clampToRoot({
470
+ start: cursorTime - cursorFraction * newDuration,
471
+ end: cursorTime + (1 - cursorFraction) * newDuration,
472
+ });
507
473
  });
508
474
  } else {
509
- let dx = e.deltaX;
510
- if (e.deltaMode === 1) dx *= 16;
511
-
475
+ const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
512
476
  setViewport((prev) => {
513
- const prevDuration = prev.end - prev.start;
514
- const panAmount = (dx / contentWidth) * prevDuration;
515
-
516
- let newStart = prev.start + panAmount;
517
- let newEnd = prev.end + panAmount;
518
-
519
- if (newStart < rootS) {
520
- newStart = rootS;
521
- newEnd = rootS + prevDuration;
522
- }
523
- if (newEnd > rootE) {
524
- newEnd = rootE;
525
- newStart = Math.max(rootS, rootE - prevDuration);
526
- }
527
-
528
- return { start: newStart, end: newEnd };
477
+ const panAmount = (dx / contentWidth) * (prev.end - prev.start);
478
+ return clampToRoot({
479
+ start: prev.start + panAmount,
480
+ end: prev.end + panAmount,
481
+ });
529
482
  });
530
483
  }
531
484
  };
532
485
 
533
486
  el.addEventListener('wheel', onWheel, { passive: false });
534
487
  return () => el.removeEventListener('wheel', onWheel);
535
- }, [root.startTime, root.duration, setViewport]);
488
+ }, [root.duration, setViewport, clampToRoot]);
536
489
 
537
490
  return (
538
491
  <div
@@ -542,8 +495,16 @@ function NewTraceViewerContent({
542
495
  >
543
496
  <div
544
497
  id="trace-parent"
545
- className="flex-1 min-w-0 grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
498
+ className="flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
546
499
  >
500
+ <Minimap
501
+ spans={trace.spans}
502
+ root={root}
503
+ viewport={viewport}
504
+ minViewportMs={MIN_VIEWPORT_MS}
505
+ onViewportChange={setViewport}
506
+ onAnimateTo={animateTo}
507
+ />
547
508
  <SplitPane
548
509
  scrollContainerRef={scrollContainerRef}
549
510
  startHeader={
@@ -603,6 +564,7 @@ function NewTraceViewerContent({
603
564
  {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
604
565
  <div
605
566
  ref={timelineRef}
567
+ id="trace-timeline"
606
568
  className="block min-h-0 overflow-visible relative"
607
569
  onDoubleClick={resetZoom}
608
570
  onMouseMove={handleTimelineMouseMove}
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Span, SpanEvent } from './types';
3
3
  import {
4
+ clampViewportToRoot,
4
5
  computeOffscreenMarkers,
5
6
  computeSpanMarkers,
6
7
  computeSpanSegments,
8
+ computeTimeMarkers,
7
9
  } from './utils';
8
10
 
9
11
  /** Build a high-res timestamp tuple ([seconds, nanoseconds]) for a given ms. */
@@ -180,3 +182,66 @@ describe('computeOffscreenMarkers', () => {
180
182
  });
181
183
  });
182
184
  });
185
+
186
+ describe('clampViewportToRoot', () => {
187
+ const clamp = (next: { start: number; end: number }) =>
188
+ clampViewportToRoot(next, 100, 1100, 10);
189
+
190
+ it('passes through a window already inside the root', () => {
191
+ expect(clamp({ start: 200, end: 400 })).toEqual({ start: 200, end: 400 });
192
+ });
193
+
194
+ it('shifts a window past the left edge without changing its duration', () => {
195
+ expect(clamp({ start: 50, end: 250 })).toEqual({ start: 100, end: 300 });
196
+ });
197
+
198
+ it('shifts a window past the right edge without changing its duration', () => {
199
+ expect(clamp({ start: 1000, end: 1200 })).toEqual({
200
+ start: 900,
201
+ end: 1100,
202
+ });
203
+ });
204
+
205
+ it('clamps a window wider than the root to the full extent', () => {
206
+ expect(clamp({ start: 0, end: 5000 })).toEqual({ start: 100, end: 1100 });
207
+ });
208
+
209
+ it('enforces the minimum duration', () => {
210
+ expect(clamp({ start: 500, end: 502 })).toEqual({ start: 500, end: 510 });
211
+ });
212
+
213
+ it('keeps a minimum-duration window inside the root near the right edge', () => {
214
+ expect(clamp({ start: 1098, end: 1099 })).toEqual({
215
+ start: 1090,
216
+ end: 1100,
217
+ });
218
+ });
219
+ });
220
+
221
+ describe('computeTimeMarkers', () => {
222
+ it('emits distinct, precise labels across a sub-second-step window', () => {
223
+ // A ~3s window drops the tick step to 500ms. Before the fix this rendered
224
+ // duplicate "2s, 2s, 3s, 3s" labels; now each tick is distinct.
225
+ const labels = computeTimeMarkers(3000, 0).map((m) => m.label);
226
+ expect(labels).toEqual(['0s', '500ms', '1s', '1.5s', '2s', '2.5s', '3s']);
227
+ expect(new Set(labels).size).toBe(labels.length);
228
+ });
229
+
230
+ it('keeps clean whole-second labels when the step is >=1s', () => {
231
+ const labels = computeTimeMarkers(10_000, 0).map((m) => m.label);
232
+ expect(labels).toEqual(['0s', '2s', '4s', '6s', '8s', '10s']);
233
+ });
234
+
235
+ it('still reads in ms when super zoomed in', () => {
236
+ const labels = computeTimeMarkers(120, 0).map((m) => m.label);
237
+ expect(labels).toEqual([
238
+ '0s',
239
+ '20ms',
240
+ '40ms',
241
+ '60ms',
242
+ '80ms',
243
+ '100ms',
244
+ '120ms',
245
+ ]);
246
+ });
247
+ });
@@ -1,4 +1,8 @@
1
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
1
+ import {
2
+ formatDuration,
3
+ formatDurationPrecise,
4
+ getHighResInMs,
5
+ } from '../trace-viewer/util/timing';
2
6
  import type { Span, SpanEvent } from './types';
3
7
 
4
8
  // ---------------------------------------------------------------------------
@@ -37,6 +41,64 @@ export function getSpanDurationMs(span: Span): number {
37
41
  );
38
42
  }
39
43
 
44
+ export function isSpanErrored(span: Span): boolean {
45
+ const workflowStatus = (span.attributes.data as Record<string, unknown>)
46
+ ?.status as string | undefined;
47
+ return span.status.code === 2 || workflowStatus === 'failed';
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Viewport
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface ViewportRange {
55
+ start: number;
56
+ end: number;
57
+ }
58
+
59
+ /**
60
+ * Clamp a candidate viewport to the root extent. The requested duration is
61
+ * preserved where possible (clamped to [minDurationMs, root duration]), then
62
+ * the window is shifted back inside the root bounds.
63
+ */
64
+ export function clampViewportToRoot(
65
+ next: ViewportRange,
66
+ rootStart: number,
67
+ rootEnd: number,
68
+ minDurationMs: number
69
+ ): ViewportRange {
70
+ const rootDuration = Math.max(rootEnd - rootStart, minDurationMs);
71
+ const duration = Math.min(
72
+ rootDuration,
73
+ Math.max(minDurationMs, next.end - next.start)
74
+ );
75
+ const maxStart = rootEnd - duration;
76
+ const start = Math.min(Math.max(next.start, rootStart), maxStart);
77
+ return { start, end: start + duration };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Wheel gestures — shared between the timeline and the minimap
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */
85
+ export function wheelDeltaToPixels(delta: number, deltaMode: number): number {
86
+ return deltaMode === 1 ? delta * 16 : delta;
87
+ }
88
+
89
+ /**
90
+ * Exponential zoom factor for a wheel gesture. Coarse mouse-wheel steps are
91
+ * damped harder than trackpad pinches so both feel similar.
92
+ */
93
+ export function wheelZoomScaleFactor(event: {
94
+ deltaY: number;
95
+ deltaMode: number;
96
+ }): number {
97
+ const dy = wheelDeltaToPixels(event.deltaY, event.deltaMode);
98
+ const isMouseWheel = event.deltaMode === 1 || Math.abs(event.deltaY) >= 50;
99
+ return 2 ** (dy / (isMouseWheel ? 200 : 60));
100
+ }
101
+
40
102
  // ---------------------------------------------------------------------------
41
103
  // Time markers
42
104
  // ---------------------------------------------------------------------------
@@ -56,6 +118,8 @@ const NICE_INTERVALS = [
56
118
 
57
119
  const MAX_MARKERS = 8;
58
120
 
121
+ const MS_IN_SECOND = 1000;
122
+
59
123
  function pickInterval(viewDuration: number, maxTicks: number): number {
60
124
  for (const interval of NICE_INTERVALS) {
61
125
  if (viewDuration / interval <= maxTicks) return interval;
@@ -72,6 +136,13 @@ export function computeTimeMarkers(
72
136
  const maxTicks = 6;
73
137
  const interval = pickInterval(viewDuration, maxTicks);
74
138
 
139
+ // Sub-second steps need fractional labels, or ticks past 1s collide as
140
+ // duplicate whole seconds ("…1s, 2s, 2s, 3s"). Scale decimals to the step.
141
+ const fractionDigits =
142
+ interval >= MS_IN_SECOND
143
+ ? 0
144
+ : Math.ceil(-Math.log10(interval / MS_IN_SECOND));
145
+
75
146
  const firstTick = Math.ceil(offset / interval) * interval;
76
147
  const markers: TimeMarker[] = [];
77
148
 
@@ -80,7 +151,10 @@ export function computeTimeMarkers(
80
151
  if (position < -0.01 || position > 1.01) continue;
81
152
  markers.push({
82
153
  position: Math.min(Math.max(position, 0), 1),
83
- label: formatDuration(Math.abs(t), true),
154
+ label:
155
+ fractionDigits === 0
156
+ ? formatDuration(Math.abs(t), true)
157
+ : formatDurationPrecise(Math.abs(t), fractionDigits),
84
158
  value: t,
85
159
  });
86
160
  if (markers.length >= MAX_MARKERS) break;
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatDurationPrecise } from './utils';
3
+
4
+ describe('formatDurationPrecise', () => {
5
+ it('shows whole milliseconds below 1s', () => {
6
+ expect(formatDurationPrecise(0)).toBe('0s');
7
+ expect(formatDurationPrecise(626)).toBe('626ms');
8
+ });
9
+
10
+ it('keeps sub-second precision instead of rounding up to a whole second', () => {
11
+ // The bug this guards: 1626ms must not read as "2s".
12
+ expect(formatDurationPrecise(1626)).toBe('1.63s');
13
+ });
14
+
15
+ it('trims trailing zeros so whole/half seconds read cleanly', () => {
16
+ expect(formatDurationPrecise(2000)).toBe('2s');
17
+ expect(formatDurationPrecise(1500)).toBe('1.5s');
18
+ expect(formatDurationPrecise(1600)).toBe('1.6s');
19
+ expect(formatDurationPrecise(45_000)).toBe('45s');
20
+ });
21
+
22
+ it('honors a custom fraction-digit count (used by the timeline ruler)', () => {
23
+ // Fewer digits also coarsen the rounding: 1 digit rounds to 100ms.
24
+ expect(formatDurationPrecise(1500, 1)).toBe('1.5s');
25
+ expect(formatDurationPrecise(1620, 1)).toBe('1.6s');
26
+ expect(formatDurationPrecise(2000, 1)).toBe('2s');
27
+ });
28
+
29
+ it('decomposes durations of a minute or more', () => {
30
+ expect(formatDurationPrecise(65_000)).toBe('1m 5s');
31
+ expect(formatDurationPrecise(63_450)).toBe('1m 3.45s');
32
+ });
33
+ });
package/src/lib/utils.ts CHANGED
@@ -74,25 +74,20 @@ export function formatDuration(ms: number, compact = false): string {
74
74
  return parts.join(' ');
75
75
  }
76
76
 
77
- // Locale-aware formatter that always renders exactly two fraction digits.
78
- // Used for the seconds component of precise durations so values are never
79
- // snapped to a whole second (and thousands separators stay correct).
80
- const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
81
- minimumFractionDigits: 2,
82
- maximumFractionDigits: 2,
83
- });
84
-
85
77
  /**
86
78
  * Formats a duration in milliseconds without snapping to whole seconds.
87
79
  *
88
- * Unlike {@link formatDuration}, this never rounds a sub-minute value up to
89
- * the next whole second (e.g. 1626ms renders as "1.63s", not "2s").
80
+ * Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
81
+ * renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
82
+ * decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
83
+ * and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
84
+ * to 10ms; the timeline ruler passes fewer digits to match its tick step.
90
85
  *
91
86
  * - < 1s: shows whole milliseconds (e.g. "626ms")
92
- * - 1s – 1m: shows seconds with two decimals (e.g. "1.63s", "45.20s")
93
- * - >= 1m: decomposes into d/h/m with two-decimal seconds (e.g. "1m 13.45s")
87
+ * - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
88
+ * - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
94
89
  */
95
- export function formatDurationPrecise(ms: number): string {
90
+ export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
96
91
  if (ms === 0) {
97
92
  return '0s';
98
93
  }
@@ -104,10 +99,11 @@ export function formatDurationPrecise(ms: number): string {
104
99
  }
105
100
  }
106
101
 
107
- const normalizedMs = Math.round(ms / 10) * 10;
102
+ const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
103
+ const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
108
104
 
109
105
  if (normalizedMs < MS_IN_MINUTE) {
110
- return `${preciseSecondsFormatter.format(normalizedMs / MS_IN_SECOND)}s`;
106
+ return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
111
107
  }
112
108
 
113
109
  const days = Math.floor(normalizedMs / MS_IN_DAY);
@@ -126,7 +122,7 @@ export function formatDurationPrecise(ms: number): string {
126
122
  if (minutes > 0) {
127
123
  parts.push(`${minutes}m`);
128
124
  }
129
- parts.push(`${preciseSecondsFormatter.format(seconds)}s`);
125
+ parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
130
126
 
131
127
  return parts.join(' ');
132
128
  }