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..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 @@ -79,7 +79,7 @@ const EventRow = ({
- {formatDuration(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 0ff2c8a92e..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 @@ -130,7 +130,7 @@ const TimelineBar = memo(function TimelineBar({ ); const getMinDurationLabelWidthPx = (label: string) => Math.max(40, label.length * 6 + 12); - const totalDurationLabel = formatDuration(totalDurationMs); + const totalDurationLabel = formatDuration(totalDurationMs, { precise: true }); const showBarDurationLabel = isRowHovered && pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel); @@ -152,7 +152,8 @@ const TimelineBar = memo(function TimelineBar({ const segPixelWidth = (seg.endFraction - seg.startFraction) * pixelWidth; const segDurationLabel = formatDuration( - (seg.endFraction - seg.startFraction) * totalDurationMs + (seg.endFraction - seg.startFraction) * totalDurationMs, + { precise: true } ); const showSegmentDurationLabel = isRowHovered && @@ -371,7 +372,7 @@ export function Timeline({ key={gap.rowIndex} leftFrac={gap.leftFrac} rightFrac={gap.rightFrac} - label={formatDuration(gap.gapMs, true)} + 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 64956d78fc..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 @@ -41,11 +41,11 @@ export function DetailPanel({
{span.resource}
Duration
- {formatDuration(durationMs)} + {formatDuration(durationMs, { precise: true })}
Offset
- +{formatDuration(offsetMs)} + +{formatDuration(offsetMs, { precise: true })}
Status
diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index c934721c81..273f17c727 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -54,6 +54,7 @@ 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, diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts index 4e36599785..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 (`{ 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`) * - * 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") + * 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 * - * 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` 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,28 +115,39 @@ 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); + return decomposeLargeUnits(roundedMs, { dropZeroSeconds: false }); +} - const parts: string[] = []; +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); + const seconds = Math.floor((ms % MS_IN_MINUTE) / MS_IN_SECOND); - if (days > 0) { - parts.push(`${days}d`); - } - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0) { - parts.push(`${minutes}m`); + 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 (!dropZeroSeconds || seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); } - parts.push(`${seconds}s`); - return parts.join(' '); } +function trimTrailingZeros(value: string): string { + if (!value.includes('.')) return value; + 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 = 10 ** decimals; + return (Math.floor(value * factor) / factor).toFixed(decimals); +} + /** * 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..0ae83cf3a9 --- /dev/null +++ b/packages/web-shared/test/format-duration.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration } from '../src/lib/utils.js'; + +/** + * 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('formatDuration with { precise: true }', () => { + const precise = (ms: number) => formatDuration(ms, { precise: true }); + + it('renders zero', () => { + expect(precise(0)).toBe('0s'); + }); + + it('renders sub-second values as integer milliseconds', () => { + 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(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(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(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(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'); + }); +});