Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trace-viewer-precise-duration.md
Original file line number Diff line number Diff line change
@@ -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`).
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const EventRow = ({
</div>
<div className="ml-2 shrink-0">
<span className="text-label-14 text-gray-900 tabular-nums">
{formatDuration(durationMs)}
{formatDuration(durationMs, { precise: true })}
</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 &&
Expand Down Expand Up @@ -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}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ export function DetailPanel({
<dd className="text-gray-1000 font-mono">{span.resource}</dd>
<dt className="text-gray-900">Duration</dt>
<dd className="text-gray-1000 tabular-nums font-mono">
{formatDuration(durationMs)}
{formatDuration(durationMs, { precise: true })}
</dd>
<dt className="text-gray-900">Offset</dt>
<dd className="text-gray-1000 tabular-nums font-mono">
+{formatDuration(offsetMs)}
+{formatDuration(offsetMs, { precise: true })}
</dd>
<dt className="text-gray-900">Status</dt>
<dd className="text-gray-1000 font-mono">
Expand Down
1 change: 1 addition & 0 deletions packages/web-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 96 additions & 28 deletions packages/web-shared/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions packages/web-shared/test/format-duration.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading