From fb31476eecb62c7b7fda1a9b08734565e36182ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 14 May 2026 15:33:02 +0000 Subject: [PATCH 1/3] Use precise (non-rounding) duration formatter in new trace viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new trace viewer was using formatDuration() for hover labels, the event list, and the detail pane. formatDuration() rounds to whole seconds for any value >= 1s, so a 1.5s span renders as '2s' — which overstates the duration and is confusing on hover. Add formatDurationPrecise(), which preserves up to two decimals of seconds (and one decimal in minute/second form) without ever rounding up to the next-larger unit. Switch the new trace viewer's bar/segment hover labels, gap delta indicators, event list rows, and span detail pane (duration + offset) to use it. --- .changeset/trace-viewer-precise-duration.md | 5 ++ .../components/event-list.tsx | 4 +- .../new-trace-viewer/components/timeline.tsx | 11 ++-- .../new-trace-viewer/detail-panel.tsx | 9 ++- .../components/trace-viewer/util/timing.ts | 4 +- packages/web-shared/src/index.ts | 1 + packages/web-shared/src/lib/utils.ts | 58 +++++++++++++++++++ .../web-shared/test/format-duration.test.ts | 50 ++++++++++++++++ 8 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 .changeset/trace-viewer-precise-duration.md create mode 100644 packages/web-shared/test/format-duration.test.ts diff --git a/.changeset/trace-viewer-precise-duration.md b/.changeset/trace-viewer-precise-duration.md new file mode 100644 index 0000000000..807ea73189 --- /dev/null +++ b/.changeset/trace-viewer-precise-duration.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Show precise, non-rounded durations in the new trace viewer hover labels, event list, and detail pane (e.g. `1.5s` instead of `2s`). diff --git a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx index 5bda8b74aa..530a499b6c 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx @@ -1,7 +1,7 @@ import { Circle } from 'lucide-react'; import { cn } from '../../../lib/utils'; import type { Span } from '../../trace-viewer/types'; -import { formatDuration } from '../../trace-viewer/util/timing'; +import { formatDurationPrecise } from '../../trace-viewer/util/timing'; import { WorkflowIcon, WebhookIcon, @@ -79,7 +79,7 @@ const EventRow = ({
- {formatDuration(durationMs)} + {formatDurationPrecise(durationMs)}
diff --git a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx index 0ff2c8a92e..f7bdc56d14 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx @@ -5,7 +5,10 @@ import type { ReactNode } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '../../../lib/utils'; import type { Span } from '../../trace-viewer/types'; -import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing'; +import { + formatDurationPrecise, + getHighResInMs, +} from '../../trace-viewer/util/timing'; import type { SegmentStatus, TimeMarker } from '../utils'; import { computeSpanGaps, @@ -130,7 +133,7 @@ const TimelineBar = memo(function TimelineBar({ ); const getMinDurationLabelWidthPx = (label: string) => Math.max(40, label.length * 6 + 12); - const totalDurationLabel = formatDuration(totalDurationMs); + const totalDurationLabel = formatDurationPrecise(totalDurationMs); const showBarDurationLabel = isRowHovered && pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel); @@ -151,7 +154,7 @@ const TimelineBar = memo(function TimelineBar({ {segments.map((seg, i) => { const segPixelWidth = (seg.endFraction - seg.startFraction) * pixelWidth; - const segDurationLabel = formatDuration( + const segDurationLabel = formatDurationPrecise( (seg.endFraction - seg.startFraction) * totalDurationMs ); const showSegmentDurationLabel = @@ -371,7 +374,7 @@ export function Timeline({ key={gap.rowIndex} leftFrac={gap.leftFrac} rightFrac={gap.rightFrac} - label={formatDuration(gap.gapMs, true)} + label={formatDurationPrecise(gap.gapMs)} rowIndex={gap.rowIndex} /> ))} diff --git a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx index 64956d78fc..0b08e5e180 100644 --- a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx @@ -3,7 +3,10 @@ import { X } from 'lucide-react'; import type { ReactNode } from 'react'; import type { Span } from '../trace-viewer/types'; -import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing'; +import { + formatDurationPrecise, + getHighResInMs, +} from '../trace-viewer/util/timing'; import { getSpanDurationMs } from './utils'; interface DetailPanelProps { @@ -41,11 +44,11 @@ export function DetailPanel({
{span.resource}
Duration
- {formatDuration(durationMs)} + {formatDurationPrecise(durationMs)}
Offset
- +{formatDuration(offsetMs)} + +{formatDurationPrecise(offsetMs)}
Status
diff --git a/packages/web-shared/src/components/trace-viewer/util/timing.ts b/packages/web-shared/src/components/trace-viewer/util/timing.ts index 60ae65582b..6cde6e6bff 100644 --- a/packages/web-shared/src/components/trace-viewer/util/timing.ts +++ b/packages/web-shared/src/components/trace-viewer/util/timing.ts @@ -1,6 +1,6 @@ -import { formatDuration } from '../../../lib/utils'; +import { formatDuration, formatDurationPrecise } from '../../../lib/utils'; -export { formatDuration }; +export { formatDuration, formatDurationPrecise }; export function getHighResInMs([seconds, nanoseconds]: [ number, diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index c934721c81..a40fd7dc06 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -57,6 +57,7 @@ export type { StreamStep } from './lib/utils'; export { extractConversation, formatDuration, + formatDurationPrecise, identifyStreamSteps, isDoStreamStep, } from './lib/utils'; diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts index 4e36599785..83c46e63ff 100644 --- a/packages/web-shared/src/lib/utils.ts +++ b/packages/web-shared/src/lib/utils.ts @@ -80,6 +80,64 @@ export function formatDuration(ms: number, compact = false): string { return parts.join(' '); } +/** + * Formats a duration in milliseconds with as much precision as can fit in + * a compact label, without rounding up to the next-larger unit. + * + * Unlike `formatDuration`, this preserves sub-second / sub-minute detail so + * the displayed value never overstates the underlying duration (e.g. 1500ms + * renders as `1.5s` rather than `2s`). Use for hover labels, detail panes, + * and other places where the value is meant to be read as an exact figure. + * + * Format: + * - < 1s: integer milliseconds (e.g. `380ms`) + * - < 1m: seconds with up to 2 decimal places, trailing zeros trimmed + * (e.g. `1.5s`, `12.34s`, `59.99s`) + * - < 1h: `Xm Y.Zs` with one decimal of seconds (e.g. `1m 5.2s`) + * - >= 1h / >= 1d: same decomposition as `formatDuration`, but seconds are + * floored rather than rounded so the label can't exceed the true value. + */ +export function formatDurationPrecise(ms: number): string { + if (ms === 0) { + return '0s'; + } + + if (ms < MS_IN_SECOND) { + return `${Math.round(ms)}ms`; + } + + if (ms < MS_IN_MINUTE) { + const s = ms / MS_IN_SECOND; + return `${trimTrailingZeros(s.toFixed(2))}s`; + } + + if (ms < MS_IN_HOUR) { + const m = Math.floor(ms / MS_IN_MINUTE); + const s = (ms % MS_IN_MINUTE) / MS_IN_SECOND; + if (s === 0) { + return `${m}m`; + } + return `${m}m ${trimTrailingZeros(s.toFixed(1))}s`; + } + + const days = Math.floor(ms / MS_IN_DAY); + const hours = Math.floor((ms % MS_IN_DAY) / MS_IN_HOUR); + const minutes = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE); + const seconds = Math.floor((ms % MS_IN_MINUTE) / MS_IN_SECOND); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + return parts.join(' '); +} + +function trimTrailingZeros(value: string): string { + if (!value.includes('.')) return value; + return value.replace(/\.?0+$/, ''); +} + /** * Returns a formatted pagination display string * @param currentPage - The current page number diff --git a/packages/web-shared/test/format-duration.test.ts b/packages/web-shared/test/format-duration.test.ts new file mode 100644 index 0000000000..2c4bea1fec --- /dev/null +++ b/packages/web-shared/test/format-duration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { formatDurationPrecise } from '../src/lib/utils.js'; + +/** + * `formatDurationPrecise` is used in the new trace viewer for span durations, + * timeline hover labels, and detail-pane offsets. Unlike `formatDuration`, it + * must never round up to the next-larger unit — a 1500ms span should never + * render as `2s`. + */ +describe('formatDurationPrecise', () => { + it('renders zero', () => { + expect(formatDurationPrecise(0)).toBe('0s'); + }); + + it('renders sub-second values as integer milliseconds', () => { + expect(formatDurationPrecise(1)).toBe('1ms'); + expect(formatDurationPrecise(380)).toBe('380ms'); + expect(formatDurationPrecise(999)).toBe('999ms'); + expect(formatDurationPrecise(999.4)).toBe('999ms'); + }); + + it('renders sub-minute values with up to two decimals of seconds', () => { + expect(formatDurationPrecise(1000)).toBe('1s'); + expect(formatDurationPrecise(1500)).toBe('1.5s'); + expect(formatDurationPrecise(1530)).toBe('1.53s'); + expect(formatDurationPrecise(8500)).toBe('8.5s'); + expect(formatDurationPrecise(12340)).toBe('12.34s'); + expect(formatDurationPrecise(59990)).toBe('59.99s'); + }); + + it('never rounds up to the next-larger unit', () => { + expect(formatDurationPrecise(1500)).not.toBe('2s'); + expect(formatDurationPrecise(8500)).not.toBe('9s'); + expect(formatDurationPrecise(59999)).not.toBe('1m'); + }); + + it('renders sub-hour values as minutes + decimal seconds', () => { + expect(formatDurationPrecise(60_000)).toBe('1m'); + expect(formatDurationPrecise(65_000)).toBe('1m 5s'); + expect(formatDurationPrecise(65_200)).toBe('1m 5.2s'); + expect(formatDurationPrecise(125_500)).toBe('2m 5.5s'); + }); + + it('renders longer durations as hour/day decomposition', () => { + expect(formatDurationPrecise(3_600_000)).toBe('1h'); + expect(formatDurationPrecise(3_660_000)).toBe('1h 1m'); + expect(formatDurationPrecise(3_665_000)).toBe('1h 1m 5s'); + expect(formatDurationPrecise(90_061_000)).toBe('1d 1h 1m 1s'); + }); +}); From e7c5f127a42b2b6d564f442c3fe588a3071108a2 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:52:51 +0000 Subject: [PATCH 2/3] Fix: `formatDurationPrecise` produces nonsensical output at unit boundaries (e.g., "1000ms", "60s", "1m 60s") due to rounding overflow, violating its documented guarantee of never overstating duration. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/web-shared/src/lib/utils.ts:106 ## Bug Analysis The `formatDurationPrecise` function in `packages/web-shared/src/lib/utils.ts` has three rounding overflow bugs at unit boundaries: 1. **`ms = 999.5`**: The `ms < MS_IN_SECOND` branch uses `Math.round(ms)` which yields `1000`, producing `"1000ms"` — a value that should have been formatted as seconds. The original `formatDuration` function has a guard for this exact case, but `formatDurationPrecise` lacks it. 2. **`ms = 59999`** (59.999s): The `ms < MS_IN_MINUTE` branch computes `s = 59.999` then calls `s.toFixed(2)` which rounds to `"60.00"`. After trimming trailing zeros this becomes `"60s"` — overstating the value and producing an output that belongs in the next unit bracket. 3. **`ms = 119950`** (1m 59.95s): The `ms < MS_IN_HOUR` branch computes `s = 59.95` then calls `s.toFixed(1)` which rounds to `"60.0"`, producing `"1m 60s"` — a nonsensical time representation. All three bugs were confirmed by executing the actual JavaScript logic and observing the incorrect outputs. ## Fix The fix uses truncation (floor) instead of rounding throughout the function, consistent with its documented purpose of "never overstating the underlying duration": 1. Changed `Math.round(ms)` to `Math.floor(ms)` in the sub-second branch. 2. Added a `truncateToFixed(value, decimals)` helper that uses `Math.floor(value * 10^decimals) / 10^decimals` to truncate to a fixed number of decimal places without rounding up. 3. Replaced `s.toFixed(2)` and `s.toFixed(1)` calls with `truncateToFixed(s, 2)` and `truncateToFixed(s, 1)` respectively. After the fix: - `ms=999.5` → `"999ms"` (truncated, not rounded) - `ms=59999` → `"59.99s"` (truncated to 2 decimal places) - `ms=119950` → `"1m 59.9s"` (truncated to 1 decimal place) - All normal cases continue to work correctly. Co-authored-by: Vercel Co-authored-by: mitul-s --- packages/web-shared/src/lib/utils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts index 83c46e63ff..6bafa708d9 100644 --- a/packages/web-shared/src/lib/utils.ts +++ b/packages/web-shared/src/lib/utils.ts @@ -103,12 +103,12 @@ export function formatDurationPrecise(ms: number): string { } if (ms < MS_IN_SECOND) { - return `${Math.round(ms)}ms`; + return `${Math.floor(ms)}ms`; } if (ms < MS_IN_MINUTE) { const s = ms / MS_IN_SECOND; - return `${trimTrailingZeros(s.toFixed(2))}s`; + return `${trimTrailingZeros(truncateToFixed(s, 2))}s`; } if (ms < MS_IN_HOUR) { @@ -117,7 +117,7 @@ export function formatDurationPrecise(ms: number): string { if (s === 0) { return `${m}m`; } - return `${m}m ${trimTrailingZeros(s.toFixed(1))}s`; + return `${m}m ${trimTrailingZeros(truncateToFixed(s, 1))}s`; } const days = Math.floor(ms / MS_IN_DAY); @@ -138,6 +138,12 @@ function trimTrailingZeros(value: string): string { return value.replace(/\.?0+$/, ''); } +/** Truncate (floor) a number to a fixed number of decimal places, without rounding up. */ +function truncateToFixed(value: number, decimals: number): string { + const factor = Math.pow(10, decimals); + return (Math.floor(value * factor) / factor).toFixed(decimals); +} + /** * Returns a formatted pagination display string * @param currentPage - The current page number From 1c8867ef461be44d745a3a9193b97004eb6635e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 14 May 2026 19:59:59 +0000 Subject: [PATCH 3/3] Fold precise mode into formatDuration instead of a separate function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, expand the existing formatDuration() with a new `precise` option rather than introducing formatDurationPrecise() as a parallel API. The second arg now accepts either an options object ({ compact?, precise? }) or the original boolean shorthand for backwards compatibility — all existing call sites that pass `true` keep working unchanged. The precise branch carries over the truncation semantics from the upstream boundary-overflow fix (e7c5f127): sub-second values floor to integer ms, sub-minute and sub-hour values truncate seconds via a `truncateToFixed` helper so 999.5ms / 59.999s / 119.95s never roll over into the next-larger unit. Update the new-trace-viewer call sites to pass { precise: true } and drop the formatDurationPrecise export. Default and compact behavior is unchanged; the test file now also covers the legacy compact form and the three boundary cases identified by the upstream fix. --- .../components/event-list.tsx | 4 +- .../new-trace-viewer/components/timeline.tsx | 14 +- .../new-trace-viewer/detail-panel.tsx | 9 +- .../components/trace-viewer/util/timing.ts | 4 +- packages/web-shared/src/index.ts | 2 +- packages/web-shared/src/lib/utils.ts | 152 +++++++++--------- .../web-shared/test/format-duration.test.ts | 86 +++++++--- 7 files changed, 152 insertions(+), 119 deletions(-) diff --git a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx index 530a499b6c..3c60fc1e78 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx @@ -1,7 +1,7 @@ import { Circle } from 'lucide-react'; import { cn } from '../../../lib/utils'; import type { Span } from '../../trace-viewer/types'; -import { formatDurationPrecise } from '../../trace-viewer/util/timing'; +import { formatDuration } from '../../trace-viewer/util/timing'; import { WorkflowIcon, WebhookIcon, @@ -79,7 +79,7 @@ const EventRow = ({
- {formatDurationPrecise(durationMs)} + {formatDuration(durationMs, { precise: true })}
diff --git a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx index f7bdc56d14..a5f3cebdf9 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx @@ -5,10 +5,7 @@ import type { ReactNode } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '../../../lib/utils'; import type { Span } from '../../trace-viewer/types'; -import { - formatDurationPrecise, - getHighResInMs, -} from '../../trace-viewer/util/timing'; +import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing'; import type { SegmentStatus, TimeMarker } from '../utils'; import { computeSpanGaps, @@ -133,7 +130,7 @@ const TimelineBar = memo(function TimelineBar({ ); const getMinDurationLabelWidthPx = (label: string) => Math.max(40, label.length * 6 + 12); - const totalDurationLabel = formatDurationPrecise(totalDurationMs); + const totalDurationLabel = formatDuration(totalDurationMs, { precise: true }); const showBarDurationLabel = isRowHovered && pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel); @@ -154,8 +151,9 @@ const TimelineBar = memo(function TimelineBar({ {segments.map((seg, i) => { const segPixelWidth = (seg.endFraction - seg.startFraction) * pixelWidth; - const segDurationLabel = formatDurationPrecise( - (seg.endFraction - seg.startFraction) * totalDurationMs + const segDurationLabel = formatDuration( + (seg.endFraction - seg.startFraction) * totalDurationMs, + { precise: true } ); const showSegmentDurationLabel = isRowHovered && @@ -374,7 +372,7 @@ export function Timeline({ key={gap.rowIndex} leftFrac={gap.leftFrac} rightFrac={gap.rightFrac} - label={formatDurationPrecise(gap.gapMs)} + label={formatDuration(gap.gapMs, { precise: true })} rowIndex={gap.rowIndex} /> ))} diff --git a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx index 0b08e5e180..ca667b3a46 100644 --- a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx @@ -3,10 +3,7 @@ import { X } from 'lucide-react'; import type { ReactNode } from 'react'; import type { Span } from '../trace-viewer/types'; -import { - formatDurationPrecise, - getHighResInMs, -} from '../trace-viewer/util/timing'; +import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing'; import { getSpanDurationMs } from './utils'; interface DetailPanelProps { @@ -44,11 +41,11 @@ export function DetailPanel({
{span.resource}
Duration
- {formatDurationPrecise(durationMs)} + {formatDuration(durationMs, { precise: true })}
Offset
- +{formatDurationPrecise(offsetMs)} + +{formatDuration(offsetMs, { precise: true })}
Status
diff --git a/packages/web-shared/src/components/trace-viewer/util/timing.ts b/packages/web-shared/src/components/trace-viewer/util/timing.ts index 6cde6e6bff..60ae65582b 100644 --- a/packages/web-shared/src/components/trace-viewer/util/timing.ts +++ b/packages/web-shared/src/components/trace-viewer/util/timing.ts @@ -1,6 +1,6 @@ -import { formatDuration, formatDurationPrecise } from '../../../lib/utils'; +import { formatDuration } from '../../../lib/utils'; -export { formatDuration, formatDurationPrecise }; +export { formatDuration }; export function getHighResInMs([seconds, nanoseconds]: [ number, diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index a40fd7dc06..273f17c727 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -54,10 +54,10 @@ export type { DecodedStreamChunkSource } from './lib/stream-display'; export type { ToastAdapter } from './lib/toast'; export { ToastProvider, useToast } from './lib/toast'; export type { StreamStep } from './lib/utils'; +export type { FormatDurationOptions } from './lib/utils'; export { extractConversation, formatDuration, - formatDurationPrecise, identifyStreamSteps, isDoStreamStep, } from './lib/utils'; diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts index 6bafa708d9..90864abc9c 100644 --- a/packages/web-shared/src/lib/utils.ts +++ b/packages/web-shared/src/lib/utils.ts @@ -12,29 +12,86 @@ const MS_IN_MINUTE = 60 * MS_IN_SECOND; const MS_IN_HOUR = 60 * MS_IN_MINUTE; const MS_IN_DAY = 24 * MS_IN_HOUR; +export interface FormatDurationOptions { + /** + * Compact multi-unit format that drops the smallest unit at the + * hour/minute boundary (e.g. `2h 30m` instead of `2h 30m 15s`). + */ + compact?: boolean; + /** + * Preserve sub-second / sub-minute precision instead of rounding to + * whole seconds. Use for labels where the value is meant to be read + * as an exact figure — a 1500ms duration renders as `1.5s` rather + * than `2s`. Values are truncated (floored) rather than rounded so + * the label can never overstate the underlying duration or roll over + * into the next-larger unit at the boundary. + */ + precise?: boolean; +} + /** * Formats a duration in milliseconds to a human-readable string. * * @param ms - Duration in milliseconds - * @param compact - If true, returns a compact format (e.g., "380ms", "2m 30s"). - * If false (default), returns multi-part format (e.g., "1m 13s", "2d 5h 3m 12s"). + * @param options - Either an options object, or a boolean shorthand + * equivalent to `{ compact: true }` for backwards + * compatibility with the original two-arg signature. + * + * Default format (no options): + * - < 1s: milliseconds (e.g. `380ms`) + * - < 1m: whole seconds, rounded (e.g. `45s`) + * - >= 1m: decomposed into days/hours/minutes/seconds (e.g. `1m 13s`, + * `2d 5h 3m 12s`) * - * Compact format (timeline markers): - * - < 1s: shows milliseconds (e.g., "380ms") - * - < 1m: shows seconds (e.g., "45s") - * - < 1h: shows minutes and seconds (e.g., "2m 30s") - * - >= 1h: shows hours and minutes (e.g., "2h 30m") + * Compact format (`{ compact: true }`, used for timeline tick labels): + * - < 1s: milliseconds (e.g. `380ms`) + * - < 1m: whole seconds (e.g. `45s`) + * - < 1h: minutes + seconds (e.g. `2m 30s`) + * - >= 1h: hours + minutes (e.g. `2h 30m`) * - * Full format: - * - < 1s: shows milliseconds (e.g., "380ms") - * - < 1m: shows seconds (e.g., "45s") - * - >= 1m: shows decomposed format with whole seconds (e.g., "1m 13s", "2m 13s") + * Precise format (`{ precise: true }`, used for hover labels and detail + * panes — never rounds up into the next-larger unit): + * - < 1s: truncated milliseconds (e.g. `380ms`, `999ms`) + * - < 1m: seconds truncated to up to two decimal places, trailing + * zeros trimmed (e.g. `1.5s`, `12.34s`, `59.99s`) + * - < 1h: `Xm Y.Zs` with one decimal of seconds, truncated + * (e.g. `1m 5.2s`, `1m 59.9s`) + * - >= 1h / >= 1d: same decomposition as the default format, but + * seconds are floored so the label can't exceed the true value + * + * `precise` takes precedence over `compact` when both are set. */ -export function formatDuration(ms: number, compact = false): string { +export function formatDuration( + ms: number, + options: boolean | FormatDurationOptions = false +): string { + const { compact = false, precise = false } = + typeof options === 'boolean' ? { compact: options } : options; + if (ms === 0) { return '0s'; } + if (precise) { + if (ms < MS_IN_SECOND) { + return `${Math.floor(ms)}ms`; + } + + if (ms < MS_IN_MINUTE) { + return `${trimTrailingZeros(truncateToFixed(ms / MS_IN_SECOND, 2))}s`; + } + + if (ms < MS_IN_HOUR) { + const m = Math.floor(ms / MS_IN_MINUTE); + const s = (ms % MS_IN_MINUTE) / MS_IN_SECOND; + return s === 0 + ? `${m}m` + : `${m}m ${trimTrailingZeros(truncateToFixed(s, 1))}s`; + } + + return decomposeLargeUnits(ms, { dropZeroSeconds: true }); + } + if (ms < MS_IN_SECOND) { const roundedMs = Math.round(ms); return roundedMs < MS_IN_SECOND ? `${roundedMs}ms` : '1s'; @@ -58,68 +115,13 @@ export function formatDuration(ms: number, compact = false): string { return m > 0 ? `${h}h ${m}m` : `${h}h`; } - // Full format: decompose into larger units + whole seconds. - const days = Math.floor(roundedMs / MS_IN_DAY); - const hours = Math.floor((roundedMs % MS_IN_DAY) / MS_IN_HOUR); - const minutes = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE); - const seconds = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND); - - const parts: string[] = []; - - if (days > 0) { - parts.push(`${days}d`); - } - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0) { - parts.push(`${minutes}m`); - } - parts.push(`${seconds}s`); - - return parts.join(' '); + return decomposeLargeUnits(roundedMs, { dropZeroSeconds: false }); } -/** - * Formats a duration in milliseconds with as much precision as can fit in - * a compact label, without rounding up to the next-larger unit. - * - * Unlike `formatDuration`, this preserves sub-second / sub-minute detail so - * the displayed value never overstates the underlying duration (e.g. 1500ms - * renders as `1.5s` rather than `2s`). Use for hover labels, detail panes, - * and other places where the value is meant to be read as an exact figure. - * - * Format: - * - < 1s: integer milliseconds (e.g. `380ms`) - * - < 1m: seconds with up to 2 decimal places, trailing zeros trimmed - * (e.g. `1.5s`, `12.34s`, `59.99s`) - * - < 1h: `Xm Y.Zs` with one decimal of seconds (e.g. `1m 5.2s`) - * - >= 1h / >= 1d: same decomposition as `formatDuration`, but seconds are - * floored rather than rounded so the label can't exceed the true value. - */ -export function formatDurationPrecise(ms: number): string { - if (ms === 0) { - return '0s'; - } - - if (ms < MS_IN_SECOND) { - return `${Math.floor(ms)}ms`; - } - - if (ms < MS_IN_MINUTE) { - const s = ms / MS_IN_SECOND; - return `${trimTrailingZeros(truncateToFixed(s, 2))}s`; - } - - if (ms < MS_IN_HOUR) { - const m = Math.floor(ms / MS_IN_MINUTE); - const s = (ms % MS_IN_MINUTE) / MS_IN_SECOND; - if (s === 0) { - return `${m}m`; - } - return `${m}m ${trimTrailingZeros(truncateToFixed(s, 1))}s`; - } - +function decomposeLargeUnits( + ms: number, + { dropZeroSeconds }: { dropZeroSeconds: boolean } +): string { const days = Math.floor(ms / MS_IN_DAY); const hours = Math.floor((ms % MS_IN_DAY) / MS_IN_HOUR); const minutes = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE); @@ -129,7 +131,9 @@ export function formatDurationPrecise(ms: number): string { if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); - if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + if (!dropZeroSeconds || seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } return parts.join(' '); } @@ -140,7 +144,7 @@ function trimTrailingZeros(value: string): string { /** Truncate (floor) a number to a fixed number of decimal places, without rounding up. */ function truncateToFixed(value: number, decimals: number): string { - const factor = Math.pow(10, decimals); + const factor = 10 ** decimals; return (Math.floor(value * factor) / factor).toFixed(decimals); } diff --git a/packages/web-shared/test/format-duration.test.ts b/packages/web-shared/test/format-duration.test.ts index 2c4bea1fec..0ae83cf3a9 100644 --- a/packages/web-shared/test/format-duration.test.ts +++ b/packages/web-shared/test/format-duration.test.ts @@ -1,50 +1,84 @@ import { describe, expect, it } from 'vitest'; -import { formatDurationPrecise } from '../src/lib/utils.js'; +import { formatDuration } from '../src/lib/utils.js'; /** - * `formatDurationPrecise` is used in the new trace viewer for span durations, - * timeline hover labels, and detail-pane offsets. Unlike `formatDuration`, it + * The `precise` option on `formatDuration` is used in the new trace viewer + * for span durations, timeline hover labels, and detail-pane offsets. It * must never round up to the next-larger unit — a 1500ms span should never * render as `2s`. */ -describe('formatDurationPrecise', () => { +describe('formatDuration with { precise: true }', () => { + const precise = (ms: number) => formatDuration(ms, { precise: true }); + it('renders zero', () => { - expect(formatDurationPrecise(0)).toBe('0s'); + expect(precise(0)).toBe('0s'); }); it('renders sub-second values as integer milliseconds', () => { - expect(formatDurationPrecise(1)).toBe('1ms'); - expect(formatDurationPrecise(380)).toBe('380ms'); - expect(formatDurationPrecise(999)).toBe('999ms'); - expect(formatDurationPrecise(999.4)).toBe('999ms'); + expect(precise(1)).toBe('1ms'); + expect(precise(380)).toBe('380ms'); + expect(precise(999)).toBe('999ms'); + expect(precise(999.4)).toBe('999ms'); }); it('renders sub-minute values with up to two decimals of seconds', () => { - expect(formatDurationPrecise(1000)).toBe('1s'); - expect(formatDurationPrecise(1500)).toBe('1.5s'); - expect(formatDurationPrecise(1530)).toBe('1.53s'); - expect(formatDurationPrecise(8500)).toBe('8.5s'); - expect(formatDurationPrecise(12340)).toBe('12.34s'); - expect(formatDurationPrecise(59990)).toBe('59.99s'); + expect(precise(1000)).toBe('1s'); + expect(precise(1500)).toBe('1.5s'); + expect(precise(1530)).toBe('1.53s'); + expect(precise(8500)).toBe('8.5s'); + expect(precise(12340)).toBe('12.34s'); + expect(precise(59990)).toBe('59.99s'); }); it('never rounds up to the next-larger unit', () => { - expect(formatDurationPrecise(1500)).not.toBe('2s'); - expect(formatDurationPrecise(8500)).not.toBe('9s'); - expect(formatDurationPrecise(59999)).not.toBe('1m'); + expect(precise(1500)).not.toBe('2s'); + expect(precise(8500)).not.toBe('9s'); + expect(precise(59999)).not.toBe('1m'); + }); + + it('truncates at unit boundaries instead of overflowing', () => { + // 999.5ms must stay in the ms bucket — `Math.round` would emit "1000ms". + expect(precise(999.5)).toBe('999ms'); + // 59.999s must stay in the seconds bucket — `toFixed(2)` would emit "60s". + expect(precise(59_999)).toBe('59.99s'); + // 1m 59.95s must not roll over to "1m 60s" via `toFixed(1)`. + expect(precise(119_950)).toBe('1m 59.9s'); }); it('renders sub-hour values as minutes + decimal seconds', () => { - expect(formatDurationPrecise(60_000)).toBe('1m'); - expect(formatDurationPrecise(65_000)).toBe('1m 5s'); - expect(formatDurationPrecise(65_200)).toBe('1m 5.2s'); - expect(formatDurationPrecise(125_500)).toBe('2m 5.5s'); + expect(precise(60_000)).toBe('1m'); + expect(precise(65_000)).toBe('1m 5s'); + expect(precise(65_200)).toBe('1m 5.2s'); + expect(precise(125_500)).toBe('2m 5.5s'); }); it('renders longer durations as hour/day decomposition', () => { - expect(formatDurationPrecise(3_600_000)).toBe('1h'); - expect(formatDurationPrecise(3_660_000)).toBe('1h 1m'); - expect(formatDurationPrecise(3_665_000)).toBe('1h 1m 5s'); - expect(formatDurationPrecise(90_061_000)).toBe('1d 1h 1m 1s'); + expect(precise(3_600_000)).toBe('1h'); + expect(precise(3_660_000)).toBe('1h 1m'); + expect(precise(3_665_000)).toBe('1h 1m 5s'); + expect(precise(90_061_000)).toBe('1d 1h 1m 1s'); + }); +}); + +/** + * Guard the pre-existing default and compact behaviors of `formatDuration` + * — the precise refactor must not change them, and the legacy two-arg + * `formatDuration(ms, true)` form is still in use across the codebase. + */ +describe('formatDuration default + compact (unchanged behavior)', () => { + it('preserves the legacy boolean compact shorthand', () => { + expect(formatDuration(125_000, true)).toBe('2m 5s'); + expect(formatDuration(125_000, { compact: true })).toBe('2m 5s'); + }); + + it('still rounds seconds in the default and compact modes', () => { + expect(formatDuration(1500)).toBe('2s'); + expect(formatDuration(1500, true)).toBe('2s'); + }); + + it('renders the long form when no options are passed', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(380)).toBe('380ms'); + expect(formatDuration(73_000)).toBe('1m 13s'); }); });