feat(dashboards): background trend sparkline on number tiles#2489
Conversation
Number tiles can render a faint line or area sparkline behind the value, derived from a time-bucketed version of the same query, so the trend over the selected range is visible at a glance. Useful for SLO / error-budget tiles where the burn over time matters as much as the current number. - common-utils: BackgroundChartSchema (line / area + optional palette color override) on SharedChartSettingsSchema, mirroring color / colorRules. - app: NumberTileBackgroundChart draws the sparkline behind DBNumberChart, reusing convertToTimeChartConfig + formatResponseForTimeChart for the bucketed series. Builder number tiles only; raw SQL has no time dimension to bucket. - Display Settings: a Background chart control (type + optional color), gated on builder number tiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an optional horizontal reference line to the background sparkline
(value plus optional label and palette color), so a number tile can mark a
0 error budget, an SLA, or a target. Rendered via a recharts ReferenceLine
with a hidden YAxis and ifOverflow="extendDomain" so it stays visible when
the value sits outside the data range.
- common-utils: backgroundChart.referenceLine { value, label?, color? }.
- app: NumberTileBackgroundChart draws the line on the area / line sparkline;
BackgroundChartInput gains value / label / color controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 273f8ed The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds an optional background trend sparkline (line or area) to number tiles, rendered via a new
Confidence Score: 4/5Safe to merge once the groupBy stripping issue in the sparkline query is resolved. The implementation is well-structured and the previously flagged refetch and NaN guard issues were fixed before this review. One remaining defect: packages/app/src/components/NumberTileBackgroundChart.tsx — the Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant DB as DBNumberChart
participant BG as NumberTileBackgroundChart
participant TQ as TanStack Query
participant API as ClickHouse API
DB->>TQ: useQueriedChartConfig(numberConfig)
TQ->>API: single-value query (groupBy stripped)
API-->>TQ: "{ data: [{value: 42}] }"
TQ-->>DB: "formattedValue = 42"
DB->>BG: mount if config.backgroundChart set
BG->>BG: strip backgroundChart/color/colorRules/numberFormat
BG->>TQ: useQueriedChartConfig(timeConfig)
TQ->>API: time-bucketed query (groupBy NOT stripped)
API-->>TQ: "[{ts:100,y:5}, {ts:200,y:8}, ...]"
TQ-->>BG: graphResults to SparklinePoint[]
BG->>BG: sparklinePointsFromGraphResults()
BG->>BG: render AreaChart or LineChart behind value
DB->>DB: render SafeAutoSizeNumber on top
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant DB as DBNumberChart
participant BG as NumberTileBackgroundChart
participant TQ as TanStack Query
participant API as ClickHouse API
DB->>TQ: useQueriedChartConfig(numberConfig)
TQ->>API: single-value query (groupBy stripped)
API-->>TQ: "{ data: [{value: 42}] }"
TQ-->>DB: "formattedValue = 42"
DB->>BG: mount if config.backgroundChart set
BG->>BG: strip backgroundChart/color/colorRules/numberFormat
BG->>TQ: useQueriedChartConfig(timeConfig)
TQ->>API: time-bucketed query (groupBy NOT stripped)
API-->>TQ: "[{ts:100,y:5}, {ts:200,y:8}, ...]"
TQ-->>BG: graphResults to SparklinePoint[]
BG->>BG: sparklinePointsFromGraphResults()
BG->>BG: render AreaChart or LineChart behind value
DB->>DB: render SafeAutoSizeNumber on top
Reviews (5): Last reviewed commit: "Merge branch 'main' into alex/HDX-1360-n..." | Re-trigger Greptile |
E2E Test Results✅ All tests passed • 218 passed • 3 skipped • 1477s
Tests ran across 4 shards in parallel. |
…isual edits Addresses code review on NumberTileBackgroundChart: - Strip display-only fields (backgroundChart, color, colorRules, numberFormat) before building the sparkline's time-series query, so they no longer enter the TanStack query key and refetch identical data on a purely visual edit (sparkline type, reference line, tile color, number format). - Guard the bucket timestamp with Number.isFinite as well, mirroring the value check, so a degenerate timestamp cannot reach recharts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
Per review, a reference line on the axis-less background sparkline reads as confusing: there is no visible scale to anchor it, and it blurs against the value and the conditional color rules. Reference lines belong on full line / stacked-bar tiles, where they have real axes, and will ship in that slice instead. This keeps the number-tile feature to just the background sparkline. Removes backgroundChart.referenceLine from the schema, the recharts ReferenceLine + hidden YAxis from the renderer, and the reference-line controls from the editor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| </> | ||
| )} | ||
|
|
||
| {showBackgroundChart && ( |
There was a problem hiding this comment.
ux: maybe if it's a sql chart we can just show a disabled state with a message instead of disappearing?
There was a problem hiding this comment.
Good call. Done in 4fa65c9. On a SQL number tile the control now stays visible but disabled, with a hint ("Available on query-builder number tiles"), instead of disappearing. Keeps it discoverable, and the slot is already in place for when the SQL background query lands.
| * dimension to bucket) and wraps the renderer in an error boundary so a | ||
| * sparkline failure never blanks the tile's value. | ||
| */ | ||
| export default function NumberTileBackgroundChart({ |
There was a problem hiding this comment.
any reason we couldn't reuse/extend the usual time chart instead?
There was a problem hiding this comment.
Good question. The shared part is actually already reused: this runs the same data pipeline as the time chart (convertToTimeChartConfig, useQueriedChartConfig, formatResponseForTimeChart, useTimeChartSettings, shouldFillNullsWithZero), so the only new code is the ~40-line render shell.
I kept that shell separate because MemoChart is close to the opposite of a sparkline and has no knobs to slim it down: it always renders CartesianGrid + XAxis + YAxis + Tooltip plus the full mouse-interaction, click-to-drilldown, and hover/series-selection machinery, with showLegend as the only off switch. A background sparkline wants none of that (no axes/grid/tooltip/legend, non-interactive, low-opacity, sitting behind the value), so reusing it would mean bolting a minimal mode onto the component that renders every line/area/stacked-bar tile, which is more code and more blast radius than the small dedicated renderer.
It also matches what we already do for small specialized charts: DBRowTable's pattern-trend column, DBHistogramChart, and DBPieChart render their own recharts directly and share the data layer rather than going through MemoChart.
If you'd rather consolidate, I think the cleaner form is a small shared <Sparkline> primitive (which DBRowTable's trend could adopt too) rather than a MemoChart mode, and I'd be glad to do that as a follow-up once the SQL and drill-down PRs show how much shared surface there will be. Happy to take that route now if you would prefer.
…led on SQL tiles
On a raw SQL number tile the Background chart control was hidden entirely.
Show it disabled with a hint ("Available on query-builder number tiles")
instead, so the option stays discoverable and the slot is already in place
for when SQL background queries land. Builder number tiles are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| const timeConfig = useMemo<ChartConfigWithDateRange>(() => { | ||
| const { | ||
| backgroundChart: _backgroundChart, | ||
| color: _color, | ||
| colorRules: _colorRules, | ||
| numberFormat: _numberFormat, | ||
| ...rest | ||
| } = config; | ||
| return { | ||
| ...rest, | ||
| displayType: DisplayType.Line, | ||
| granularity: config.granularity ?? 'auto', | ||
| }; | ||
| }, [config]); |
There was a problem hiding this comment.
Residual
groupBy causes sparkline/value mismatch
convertToNumberChartConfig strips groupBy before sending the number tile's own query, collapsing the result to one aggregate value. But timeConfig spreads ...rest which retains any groupBy present in the stored config (common when a tile started as a Line chart with a group-by and was later switched to Number display). The sparkline query would then return multiple series, and only lineData[0]?.dataKey is used — so the trend shown represents just the first group, not the aggregate that the number tile is displaying. Adding groupBy: _groupBy to the same destructure/strip block that already handles backgroundChart, color, colorRules, and numberFormat would keep the sparkline and value in sync.
… apply (#2507) Fix a number tile losing its auto-detected format when Display Settings is applied. On a builder number tile that shows p95 of a trace `Duration` column, the value auto-detects a duration format (for example `367.7ms`). Opening **Display Settings** and clicking **Apply** without touching the format flipped it to plain Number, so the value then rendered as a raw nanosecond integer. This was a latent bug; #2489 surfaced it by adding the background-chart control, which is a new reason to open that drawer on a number tile. ## Summary Two things were wrong, both fixed here: - The drawer's auto-detected fallback (`defaultNumberFormat`) read the form's `select` field. `select` is only synced from `series` on submit and on display-type / source resets, so after the user edits the aggregation in the builder it goes stale, `getFirstSeriesNumberFormat` resolves `undefined`, and the drawer falls through to the Number default. It now reads the live `series` (the field the builder actually edits), so the drawer reflects the datasource format. - **Apply** unconditionally wrote `numberFormat` into the config, freezing whatever the drawer happened to show (an inferred value the user never chose). It now persists `numberFormat` only on an explicit override: either the tile already had a saved format, or the user changed the format control in this session. Otherwise it stays unset and render-time auto-detection keeps driving the format. This matches the contract render already uses (`useSingleSeriesNumberFormat`): an explicit `numberFormat` wins, otherwise the datasource-derived format applies. ## Changes - `EditTimeChartForm.tsx`: source `autoDetectedNumberFormat` from the live `series` watch instead of the stale `select`, and persist the drawer's `numberFormat` only when it is defined. - `ChartDisplaySettingsDrawer.tsx`: on Apply, emit `numberFormat` only when it is an explicit override (an existing saved format, or a field the user changed this session), otherwise emit `undefined`. ## Test plan - [x] `make ci-lint` (eslint + tsc + stylelint, app package) - [x] `make ci-unit` (app package, 2081 passed) - [x] New drawer tests in `ChartDisplaySettingsDrawer.test.tsx`: - Apply without touching the format emits `numberFormat: undefined` (does not clobber the auto-detected format). - Changing the output format and applying persists the chosen format. - Editing only another setting (color) preserves an existing explicit format. ### UI verification This is a persistence-logic fix with no styling or layout change. The visible effect (the tile value staying duration-formatted, and the drawer showing Duration rather than Number) depends on a trace source with a `Duration` column and seeded data, which the fresh local dev stack does not have. The behavior is covered by the new drawer tests, which drive the real Apply pipeline and assert exactly what gets persisted; the render path that formats the value is unchanged. [ui-check: allow] ### What's not in this PR (follow-up) Switching the output format to Duration or Time still keeps the previous factor (for example Seconds), so a nanosecond value can be misread as seconds. A follow-up will seed the factor from the source's duration precision when the output switches to a time-based format. It stacks on this change.
…splayed value (#2501) Follow-up to #2489. The background trend sparkline that #2489 added to number tiles can plot the wrong data when a tile carries a leftover `groupBy`. ## Summary The big number on a number tile is a single aggregate: `convertToNumberChartConfig` strips `groupBy` from its query. The background sparkline builds its own query from the tile config but kept `groupBy`, so a tile that still carried one queried multiple series and the renderer plotted only the first. The faint trend behind the value then belonged to a single group while the value itself aggregated every group. A residual `groupBy` is reachable in normal use: switching a grouped Line chart to the Number display type only hides the group-by input, it does not clear the value, so a saved Number tile can still carry it. The fix extracts the sparkline's query derivation into a small pure helper, `buildSparklineTimeConfig`, and drops `groupBy` there, mirroring the value query's strip. `granularity` is still kept (defaulting to `auto`) so the trend stays bucketed. There is no visual or layout change: the sparkline renders exactly as before, it just plots the same single series as the value it sits behind. ## Changes - Extract `buildSparklineTimeConfig(config)` from the inline `useMemo` in `NumberTileBackgroundChart` and strip `groupBy` alongside the existing display-only fields. - Add a render test that asserts the issued query has no `groupBy`, plus unit tests for the helper. - Patch changeset. ## Test plan - [x] `nx run @hyperdx/app:ci:lint` (lint + tsc) - [x] `nx run @hyperdx/app:ci:unit` (2069 passed, 0 failures) - [x] New render test mounts `NumberTileBackgroundChart` with a tile that has a residual `groupBy` and asserts the config passed to `useQueriedChartConfig` drops it, exercising the real `buildSparklineTimeConfig` then `convertToTimeChartConfig` path rather than the helper in isolation. [ui-check: allow] Data-correctness fix: it changes which series the sparkline queries, not how anything is drawn. There is no visual, layout, color, or state surface to capture, and the new render test proves the issued query is correct. [viewport: allow] No layout change.
…hboards API (#2509) Add `backgroundChart` (the number-tile background trend sparkline) to the external dashboards API, so a tile authored in the editor round-trips through `/api/v2/dashboards`. Builds on #2489, which added the sparkline to the dashboard editor but left the v2 REST surface unaware of the field. Follows the same pattern as the number-tile `color` parity in #2428. Part of #1360. ## Summary Builder number tiles can now carry an optional `backgroundChart` over the v2 REST API. The field mirrors the internal `BackgroundChartSchema` (imported from `common-utils`, not re-declared, so the surfaces cannot drift): a required `type` (`line` or `area`) and an optional palette-token `color` override. Raw SQL number tiles do not expose it, matching the editor and the save path. ## What - Add `backgroundChart: BackgroundChartSchema.optional()` to `externalDashboardNumberChartConfigSchema` (`packages/api/src/utils/zod.ts`). - Carry `backgroundChart` through both conversion directions for the builder number arm in `packages/api/src/routers/external-api/v2/utils/dashboards.ts`. - Add a `BackgroundChart` OpenAPI component and a `backgroundChart` property on `NumberBuilderChartConfig`; regenerate `openapi.json`. - Tests: positive round-trips (line; area + color; PUT), rejection rules (type outside `line`/`area`, missing `type`, non-palette and legacy-numeric color), the raw-SQL strip case, and the no-sparkline backward-compat case. ## Why builder-only The sparkline derives from a time-bucketed version of the tile's structured query, so it only makes sense for builder number tiles. The editor reflects this: the control renders for number tiles but is disabled when `configType === 'sql'` (`ChartDisplaySettingsDrawer`), and `convertFormStateToSavedChartConfig` persists `backgroundChart` only on the builder branch (the raw SQL and promql picks omit it, exactly like `colorRules`). The external schema mirrors that: `backgroundChart` lives on the builder number schema only. A raw SQL number tile sent with the field has it stripped on save, the same way `colorRules` is. No legacy-token normalization is needed on the way out: `backgroundChart` shipped after the palette hue rename, so its optional `color` can only hold a hue-named token (unlike the static `color`, which still maps legacy `chart-1..chart-10` tokens from older documents). ## Backward compatibility `backgroundChart` is optional everywhere. Existing dashboards and payloads without it are unchanged; a number tile with no sparkline round-trips with the field absent. ## Test plan - [x] `make ci-lint` (eslint + tsc + openapi spectral) - [x] integration: `external-api/__tests__/dashboards.test.ts` (round-trip + rejection + strip + backward-compat) - [x] `openapi.json` regenerated via `yarn workspace @hyperdx/api docgen` ### What's NOT in this PR (follow-up) - MCP authoring parity for `backgroundChart` (follows in a separate PR, mirroring the number-tile color MCP work). - Customer docs for the sparkline option (tracked separately). - Raw SQL background-query mode and the reference line are out of scope here (separate work on the editor side).
Number tiles can now show a faint trend sparkline (line or area) behind the value, derived from a time-bucketed version of the tile's own query. This is the first in a short series building toward SLO / error-budget tiles; it covers builder number tiles. Raw SQL support and click-through drill-down follow in separate PRs.
Builds on #1360.
Summary
backgroundChartto the number-tile config: alineorareasparkline drawn behind the value, derived from a time-bucketed version of the tile's own query.Changes
common-utils:BackgroundChartSchema({ type, color? }) on the shared chart settings, mirroring the existingcolor/colorRulesplacement so it flows through the saved-config types unchanged.app:NumberTileBackgroundChartrenders the sparkline (recharts area / line) behindDBNumberChart. NewBackgroundChartInputcontrol in the display-settings drawer, wired throughEditTimeChartForm. Display-only fields are stripped before the sparkline's query key, so visual edits do not refetch identical data.Why
Number tiles are the natural KPI surface, and a trend behind the value is the common "stat with sparkline" pattern. Builder tiles can auto-derive the sparkline because the query is structured; raw SQL needs an explicit query, which is a follow-up.
Test plan
eslint,tsc --noEmit) on the touched packagescommon-utilsschema round-trip andapprender-wiring + the sparkline points helperWhat's not in this PR (follow-ups)
backgroundChart.Screenshots
Verified on a live build in both light and dark mode: the value renders in high-contrast theme text over a faint area sparkline. Builder number tile, count over a demo dataset.
Light
Dark
[ui-states: allow]
[viewport: allow]
[no-story: allow]
Notes on the markers above: the sparkline is decorative behind the value and only mounts in the success branch, so the tile's empty / loading / error states are unchanged; it uses
ResponsiveContainer, so it is size-independent; and the two new components render only with live time-series data, so they are demonstrated in product rather than in Storybook. The sparkline color resolves through theme tokens (getColorFromCSSToken), so it reflows across light and dark.