From cb46c4bbb3f5be443248cb58535ddbdc965b4c3a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:32:46 +0000 Subject: [PATCH 1/2] fix(dashboards): seed duration unit from source precision on output switch Switching a number tile's Output format to Duration (or Time) kept the previous factor (Seconds), so a nanosecond trace Duration value was read as seconds: a 367ms p95 rendered as roughly 11.7 years until the user re-picked Nanoseconds by hand. The output `onChange` set the format and reset the numeric unit but never touched `factor`, and the control had no knowledge of the source's duration precision. Thread the auto-detected duration factor (already resolved into the drawer's `defaultNumberFormat`) into `NumberFormatForm` as `preferredDurationFactor`, and seed it when the output switches to a time-based format. Sources with no detected duration precision, and the per-series drawer which passes no preferred factor, keep the existing behavior. Co-Authored-By: Claude Opus 4.8 --- ...r-tile-duration-factor-on-output-switch.md | 11 ++++ .../components/ChartDisplaySettingsDrawer.tsx | 13 ++++ packages/app/src/components/NumberFormat.tsx | 21 +++++- .../ChartDisplaySettingsDrawer.test.tsx | 65 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .changeset/number-tile-duration-factor-on-output-switch.md diff --git a/.changeset/number-tile-duration-factor-on-output-switch.md b/.changeset/number-tile-duration-factor-on-output-switch.md new file mode 100644 index 0000000000..529c5727f7 --- /dev/null +++ b/.changeset/number-tile-duration-factor-on-output-switch.md @@ -0,0 +1,11 @@ +--- +"@hyperdx/app": patch +--- + +fix(dashboards): seed the duration unit when switching a number tile to Duration + +Switching the Output format to Duration (or Time) on a number tile now seeds the +input unit from the datasource precision. Previously the unit stayed on Seconds, +so a nanosecond trace Duration value was read as seconds (a 367ms value showed as +roughly 11.7 years) until the user re-picked Nanoseconds by hand. Tiles whose +source has no detected duration precision keep the existing behavior. diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index 6c6cab067c..01b1d9445c 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -195,6 +195,18 @@ export default function ChartDisplaySettingsDrawer({ const showBackgroundChart = displayType === DisplayType.Number; const isBackgroundChartDisabled = configType === 'sql'; + // When the datasource auto-detects a time-based format (e.g. a trace Duration + // column, factor 1e-9 for nanoseconds), seed that factor if the user switches + // the output to duration / time in the format control. Without it the prior + // factor sticks and a nanosecond value renders as seconds (367,700,000 ns + // read as ~11.7y). Only meaningful when the detected default is itself + // time-based; otherwise there is no source precision to seed from. + const preferredDurationFactor = + defaultNumberFormat?.output === 'duration' || + defaultNumberFormat?.output === 'time' + ? defaultNumberFormat.factor + : undefined; + return ( diff --git a/packages/app/src/components/NumberFormat.tsx b/packages/app/src/components/NumberFormat.tsx index 4827774e8c..3167c42718 100644 --- a/packages/app/src/components/NumberFormat.tsx +++ b/packages/app/src/components/NumberFormat.tsx @@ -157,7 +157,15 @@ export const NumberFormatForm: React.FC<{ control: Control>; setValue: UseFormSetValue>; disclaimer?: React.ReactNode; -}> = ({ control, setValue, disclaimer }) => { + /** + * Source-derived duration factor (e.g. 1e-9 for a nanosecond trace Duration + * column). When set, switching the output to a time-based format (duration or + * time) seeds `numberFormat.factor` from it instead of keeping the prior + * factor, so the value is read with the right input unit. Left undefined for + * sources with no detected duration precision, where the factor is untouched. + */ + preferredDurationFactor?: number; +}> = ({ control, setValue, disclaimer, preferredDurationFactor }) => { const format = useWatch({ control, name: 'numberFormat' }) ?? DEFAULT_NUMBER_FORMAT; @@ -198,6 +206,17 @@ export const NumberFormatForm: React.FC<{ 'numberFormat.numericUnit', DEFAULT_NUMERIC_UNIT_BY_OUTPUT[newOutput] ?? undefined, ); + // Time-based formats interpret the value through `factor` + // (input unit to seconds). Seed it from the source-derived + // precision so a nanosecond Duration column is not misread as + // seconds; otherwise the prior factor (often Seconds = 1) + // sticks and the user has to re-pick the unit by hand. + if ( + (newOutput === 'duration' || newOutput === 'time') && + preferredDurationFactor != null + ) { + setValue('numberFormat.factor', preferredDurationFactor); + } }} /> )} diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index 365cc5cba7..e73e9c1050 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -347,5 +347,70 @@ describe('ChartDisplaySettingsDrawer', () => { numberFormat: { output: 'currency' }, }); }); + + // Switching the output to Duration on a tile whose source is a nanosecond + // Duration column should seed the factor from the source precision (1e-9), + // not keep the prior Seconds factor (which would read the value as seconds). + it('seeds the duration factor from the source precision when output switches to Duration', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: /output format/i }), + 'duration', + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].numberFormat).toMatchObject({ + output: 'duration', + factor: 1e-9, + }); + }); + + // With no source-derived duration format (non-duration source), switching + // to Duration must not force a factor; the prior value (Seconds = 1) stays + // and the user picks the input unit manually as before. + it('does not force a factor when no source duration format is available', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: /output format/i }), + 'duration', + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].numberFormat).toMatchObject({ + output: 'duration', + factor: 1, + }); + }); }); }); From 551bf8a03bfe25c50f8233e1b376213c1677cb4f Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:40:24 +0000 Subject: [PATCH 2/2] test(dashboards): cover the Time output path for factor seeding The duration/time seeding condition treats Time the same as Duration, but only the Duration switch was tested. Add a case asserting a switch to Time also seeds the source-derived factor. Co-Authored-By: Claude Opus 4.8 --- .../ChartDisplaySettingsDrawer.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index e73e9c1050..6493989799 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -412,5 +412,37 @@ describe('ChartDisplaySettingsDrawer', () => { factor: 1, }); }); + + // The Time output reads the value through the same `factor` select as + // Duration, so switching to Time also seeds the source-derived factor. + it('seeds the duration factor when output switches to Time', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: /output format/i }), + 'time', + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].numberFormat).toMatchObject({ + output: 'time', + factor: 1e-9, + }); + }); }); });