diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index b4f4f54ea71d13..94b6939ed641bf 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -500,11 +500,25 @@ describe('lib/utils', () => { }) }) describe('humanFriendlyDuration()', () => { - it('returns correct value for <= 60', () => { + it('returns correct value for 0 <= t < 1', () => { + expect(humanFriendlyDuration(0)).toEqual('0s') + expect(humanFriendlyDuration(0.001)).toEqual('1ms') + expect(humanFriendlyDuration(0.02)).toEqual('20ms') + expect(humanFriendlyDuration(0.3)).toEqual('300ms') + expect(humanFriendlyDuration(0.999)).toEqual('999ms') + }) + + it('returns correct value for 1 < t <= 60', () => { expect(humanFriendlyDuration(60)).toEqual('1m') expect(humanFriendlyDuration(45)).toEqual('45s') expect(humanFriendlyDuration(44.8)).toEqual('45s') expect(humanFriendlyDuration(45.2)).toEqual('45s') + expect(humanFriendlyDuration(45.2, { secondsFixed: 1 })).toEqual('45.2s') + expect(humanFriendlyDuration(1.23)).toEqual('1s') + expect(humanFriendlyDuration(1.23, { secondsPrecision: 3 })).toEqual('1.23s') + expect(humanFriendlyDuration(1, { secondsPrecision: 3 })).toEqual('1s') + expect(humanFriendlyDuration(1, { secondsFixed: 1 })).toEqual('1s') + expect(humanFriendlyDuration(1)).toEqual('1s') }) it('returns correct value for 60 < t < 120', () => { expect(humanFriendlyDuration(90)).toEqual('1m 30s') @@ -524,13 +538,13 @@ describe('lib/utils', () => { expect(humanFriendlyDuration(86400.12)).toEqual('1d') }) it('truncates to specified # of units', () => { - expect(humanFriendlyDuration(3961, 2)).toEqual('1h 6m') - expect(humanFriendlyDuration(30, 2)).toEqual('30s') // no change - expect(humanFriendlyDuration(30, 0)).toEqual('') // returns no units (useless) + expect(humanFriendlyDuration(3961, { maxUnits: 2 })).toEqual('1h 6m') + expect(humanFriendlyDuration(30, { maxUnits: 2 })).toEqual('30s') // no change + expect(humanFriendlyDuration(30, { maxUnits: 0 })).toEqual('') // returns no units (useless) }) it('returns an empty string for nullish inputs', () => { - expect(humanFriendlyDuration('', 2)).toEqual('') - expect(humanFriendlyDuration(null, 2)).toEqual('') + expect(humanFriendlyDuration('', { maxUnits: 2 })).toEqual('') + expect(humanFriendlyDuration(null, { maxUnits: 2 })).toEqual('') }) }) diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index d5120b9dc1e056..2ca7d3e5f71a08 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -497,13 +497,36 @@ export const humanFriendlyMilliseconds = (timestamp: number | undefined): string return `${(timestamp / 1000).toFixed(2)}s` } -export function humanFriendlyDuration(d: string | number | null | undefined, maxUnits?: number): string { +export function humanFriendlyDuration( + d: string | number | null | undefined, + { + maxUnits, + secondsPrecision, + secondsFixed, + }: { maxUnits?: number; secondsPrecision?: number; secondsFixed?: number } = {} +): string { // Convert `d` (seconds) to a human-readable duration string. // Example: `1d 10hrs 9mins 8s` - if (d === '' || d === null || d === undefined) { + if (d === '' || d === null || d === undefined || maxUnits === 0) { return '' } d = Number(d) + if (d < 0) { + return `-${humanFriendlyDuration(-d)}` + } + if (d === 0) { + return `0s` + } + if (d < 1) { + return `${Math.round(d * 1000)}ms` + } + if (d < 60) { + if (secondsPrecision != null) { + return `${parseFloat(d.toPrecision(secondsPrecision))}s` // round to s.f. then throw away trailing zeroes + } + return `${parseFloat(d.toFixed(secondsFixed ?? 0))}s` // round to fixed point then throw away trailing zeroes + } + const days = Math.floor(d / 86400) const h = Math.floor((d % 86400) / 3600) const m = Math.floor((d % 3600) / 60) @@ -520,7 +543,7 @@ export function humanFriendlyDuration(d: string | number | null | undefined, max } else { units = [hDisplay, mDisplay, sDisplay].filter(Boolean) } - return units.slice(0, maxUnits).join(' ') + return units.slice(0, maxUnits ?? undefined).join(' ') } export function humanFriendlyDiff(from: dayjs.Dayjs | string, to: dayjs.Dayjs | string): string { diff --git a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx index f9aeb562c34332..10e724fc7c5249 100644 --- a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx +++ b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx @@ -139,20 +139,6 @@ const formatPercentage = (x: number, options?: { precise?: boolean }): string => return (x / 100).toLocaleString(undefined, { style: 'percent', maximumFractionDigits: 0 }) } -const formatSeconds = (x: number): string => { - // if over than a minute, show minutes and seconds - if (x >= 60) { - return humanFriendlyDuration(x) - } - // if over 1 second, show 3 significant figures - if (x >= 1) { - return `${x.toPrecision(3)}s` - } - - // show the number of milliseconds - return `${x * 1000}ms` -} - const formatUnit = (x: number, options?: { precise?: boolean }): string => { if (options?.precise) { return x.toLocaleString() @@ -170,7 +156,7 @@ const formatItem = ( } else if (kind === 'percentage') { return formatPercentage(value, options) } else if (kind === 'duration_s') { - return formatSeconds(value) + return humanFriendlyDuration(value, { secondsPrecision: 3 }) } return formatUnit(value, options) } diff --git a/frontend/src/scenes/funnels/FunnelBarHorizontal/FunnelBarHorizontal.tsx b/frontend/src/scenes/funnels/FunnelBarHorizontal/FunnelBarHorizontal.tsx index 07feedadd3ec82..892ab7d5d37456 100644 --- a/frontend/src/scenes/funnels/FunnelBarHorizontal/FunnelBarHorizontal.tsx +++ b/frontend/src/scenes/funnels/FunnelBarHorizontal/FunnelBarHorizontal.tsx @@ -91,7 +91,7 @@ export function FunnelBarHorizontal({ {step.average_conversion_time && step.average_conversion_time >= Number.EPSILON ? (
Average time to convert:{' '} - {humanFriendlyDuration(step.average_conversion_time, 2)} + {humanFriendlyDuration(step.average_conversion_time, { maxUnits: 2 })}
) : null} diff --git a/frontend/src/scenes/funnels/FunnelBarVertical/StepLegend.tsx b/frontend/src/scenes/funnels/FunnelBarVertical/StepLegend.tsx index 2c688b9cf82d6a..d357175f8a7204 100644 --- a/frontend/src/scenes/funnels/FunnelBarVertical/StepLegend.tsx +++ b/frontend/src/scenes/funnels/FunnelBarVertical/StepLegend.tsx @@ -121,7 +121,7 @@ export function StepLegend({ step, stepIndex, showTime, showPersonsModal }: Step {showTime && ( } title="Median time of conversion from previous step"> - {humanFriendlyDuration(step.median_conversion_time, 3) || '–'} + {humanFriendlyDuration(step.median_conversion_time, { maxUnits: 3 }) || '–'} )} diff --git a/frontend/src/scenes/funnels/FunnelHistogram.tsx b/frontend/src/scenes/funnels/FunnelHistogram.tsx index 0c1c95ebbc80f0..1709b0eb514b46 100644 --- a/frontend/src/scenes/funnels/FunnelHistogram.tsx +++ b/frontend/src/scenes/funnels/FunnelHistogram.tsx @@ -40,7 +40,7 @@ export function FunnelHistogram(): JSX.Element | null { width={width} isDashboardItem={isInDashboardContext} height={height} - formatXTickLabel={(v) => humanFriendlyDuration(v, 2)} + formatXTickLabel={(v) => humanFriendlyDuration(v, { maxUnits: 2 })} /> ) diff --git a/frontend/src/scenes/funnels/FunnelTooltip.tsx b/frontend/src/scenes/funnels/FunnelTooltip.tsx index 7def251b4c5abc..db1c8cdbb5d80b 100644 --- a/frontend/src/scenes/funnels/FunnelTooltip.tsx +++ b/frontend/src/scenes/funnels/FunnelTooltip.tsx @@ -89,13 +89,13 @@ export function FunnelTooltip({ {stepIndex > 0 && series.median_conversion_time != null && ( Median time from previous - {humanFriendlyDuration(series.median_conversion_time, 3)} + {humanFriendlyDuration(series.median_conversion_time, { maxUnits: 3 })} )} {stepIndex > 0 && series.average_conversion_time != null && ( Average time from previous - {humanFriendlyDuration(series.average_conversion_time, 3)} + {humanFriendlyDuration(series.average_conversion_time, { maxUnits: 3 })} )} diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx index f07a3dd67e4a0d..aa6703deebf868 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx @@ -252,7 +252,9 @@ export function FunnelStepsTable(): JSX.Element | null { ), render: (_: void, breakdown: FlattenedFunnelStepByBreakdown) => breakdown.steps?.[step.order]?.median_conversion_time != undefined - ? humanFriendlyDuration(breakdown.steps[step.order].median_conversion_time, 3) + ? humanFriendlyDuration(breakdown.steps[step.order].median_conversion_time, { + maxUnits: 3, + }) : '–', align: 'right', width: 0, @@ -268,7 +270,9 @@ export function FunnelStepsTable(): JSX.Element | null { ), render: (_: void, breakdown: FlattenedFunnelStepByBreakdown) => breakdown.steps?.[step.order]?.average_conversion_time != undefined - ? humanFriendlyDuration(breakdown.steps[step.order].average_conversion_time, 3) + ? humanFriendlyDuration(breakdown.steps[step.order].average_conversion_time, { + maxUnits: 3, + }) : '–', align: 'right', width: 0,