From c166bfc04470a541617808b1fbb6f8fd3e99358a Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:34:35 +0200 Subject: [PATCH] fix(studio): stop stacking overlaid area series in report charts (#48034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line-style report charts (auth processing time, percentiles, edge functions, realtime, etc.) hardcoded `stackId="1"` on every ``, so recharts summed the series additively instead of overlaying them. When multiple series share a value (e.g. Max/Min/Avg all `153.98`), they rendered as three stacked bands at 1x/2x/3x the value, even though the tooltip showed the true identical values. ## Fix Default each area to its own `stackId` (its attribute name) so series overlay, while still honoring an explicit per-attribute `stackId` from config — matching the existing bar-chart path directly above it. `normalizeVisibleStackToPercent`, the only flag that would make stacked areas intentional, is never enabled anywhere. ## Before / after Before: three equal values drawn at `0→153.98`, `153.98→307.96`, `307.96→461.94`. After: all three overlay at `153.98`. Affects every line-style multi-series report, not just auth. ## Summary by CodeRabbit - **Bug Fixes** - Improved stacked chart behavior by consistently applying configured series stacking settings. - Ensured area charts can overlay correctly when no explicit stacking configuration is provided. - Added safeguards for missing or invalid chart attribute data. - **Tests** - Added coverage for configured stack IDs, fallback behavior, empty values, and invalid inputs. --- .../components/ui/Charts/ComposedChart.tsx | 5 +- .../ui/Charts/ComposedChart.utils.test.ts | 60 +++++++++++++++++++ .../ui/Charts/ComposedChart.utils.tsx | 20 +++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 apps/studio/components/ui/Charts/ComposedChart.utils.test.ts diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index c8410e63a48..b778bd848bc 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -38,6 +38,7 @@ import { calculateTotalChartAggregate, CustomLabel, CustomTooltip, + getStackId, MultiAttribute, } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' @@ -560,7 +561,7 @@ export function ComposedChart({ a.attribute === attribute?.name)?.stackId ?? '1'} + stackId={getStackId(attributes, attribute?.name, '1')} fill={attribute.color} radius={0.75} opacity={1} @@ -576,7 +577,7 @@ export function ComposedChart({ key={attribute.name} type="linear" dataKey={attribute.name} - stackId="1" + stackId={getStackId(attributes, attribute.name, attribute.name)} fill={`url(#gradient-${attribute.name})`} fillOpacity={1} stroke={attribute.color} diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.test.ts b/apps/studio/components/ui/Charts/ComposedChart.utils.test.ts new file mode 100644 index 00000000000..eec90bb5fe3 --- /dev/null +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { getStackId } from './ComposedChart.utils' +import type { MultiAttribute } from './ComposedChart.utils' + +const attr = (attribute: string, stackId?: string): MultiAttribute => ({ attribute, stackId }) + +describe('getStackId', () => { + it('returns the explicit stackId when the attribute configures one', () => { + const attributes = [attr('ingress', 'traffic'), attr('egress', 'traffic')] + expect(getStackId(attributes, 'ingress', '1')).toBe('traffic') + expect(getStackId(attributes, 'egress', '1')).toBe('traffic') + }) + + it('falls back when the attribute has no stackId (overlay case)', () => { + const attributes = [attr('avg'), attr('min'), attr('max')] + expect(getStackId(attributes, 'avg', 'avg')).toBe('avg') + expect(getStackId(attributes, 'min', 'min')).toBe('min') + expect(getStackId(attributes, 'max', 'max')).toBe('max') + }) + + it('falls back to the shared bar id when no stackId is configured', () => { + const attributes = [attr('reads'), attr('writes')] + expect(getStackId(attributes, 'reads', '1')).toBe('1') + expect(getStackId(attributes, 'writes', '1')).toBe('1') + }) + + it('falls back when the attribute is not found', () => { + expect(getStackId([attr('reads', 'io')], 'writes', 'fallback')).toBe('fallback') + }) + + it('does not crash on undefined or null attributes', () => { + expect(getStackId(undefined, 'reads', '1')).toBe('1') + expect(getStackId(null, 'reads', '1')).toBe('1') + }) + + it('does not crash on undefined or null name', () => { + expect(getStackId([attr('reads', 'io')], undefined, '1')).toBe('1') + expect(getStackId([attr('reads', 'io')], null, '1')).toBe('1') + }) + + it('does not crash on falsy entries in the attributes array', () => { + const attributes: (MultiAttribute | false | null | undefined)[] = [ + false, + null, + undefined, + attr('reads', 'io'), + ] + expect(getStackId(attributes, 'reads', '1')).toBe('io') + expect(getStackId(attributes, 'writes', '1')).toBe('1') + }) + + it('does not crash on a non-array value', () => { + expect(getStackId('nope' as unknown as MultiAttribute[], 'reads', '1')).toBe('1') + }) + + it('treats an empty-string stackId as configured', () => { + expect(getStackId([attr('reads', '')], 'reads', '1')).toBe('') + }) +}) diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 9cfce65eec8..3aa6b215d15 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -127,6 +127,26 @@ interface TooltipProps { const isMaxAttribute = (attributes?: MultiAttribute[]) => attributes?.find((a) => a.isMaxValue) +/** + * Resolve the recharts `stackId` for a series. + * + * Series that share a `stackId` are stacked additively, so overlaid series + * (e.g. min/max/avg of the same metric) must each get a distinct id. Bar + * charts pass `'1'` as the fallback to stack together; area charts pass the + * attribute name so each series overlays independently. An explicit per- + * attribute `stackId` always wins. + */ +export const getStackId = ( + attributes: (MultiAttribute | false | null | undefined)[] | null | undefined, + name: string | null | undefined, + fallback: string +): string => { + const configured = Array.isArray(attributes) + ? attributes.find((a): a is MultiAttribute => !!a && a.attribute === name)?.stackId + : undefined + return configured ?? fallback +} + /** * Calculate the total aggregate of the chart values * by summing the values of the attributes