Files
supabase/apps/studio/components/ui/Charts/StackedBarChart.tsx
Jordi Enric bef828ae48 feat(studio): apply timezone picker to observability/reports charts (#45667)
## Problem

The dashboard's timezone picker (#45517) propagates to log timestamps
and the shared TimestampInfo component, but observability and reports
charts still render their X-axis labels, range labels, and tooltip
headers in the browser's local timezone. The result is jarring once a
user picks a non-local timezone: hover a chart and you get one tz, hover
a log row and you get another.

## Fix

Routes all display-side timestamp formatting in the chart layer through
the existing picker-aware helpers (\`useFormatDateTime\` /
\`formatDateTime\`) so chart UI matches the rest of the dashboard.

- **ComposedChart.utils** \`CustomTooltip\` (the hotspot — drives every
observability dashboard tooltip): reads the active timezone via
\`useTimezone\` for both the header label and the formatted timestamp.
- **AreaChart** / **BarChart**: introduce a \`formatChartDate\` helper
that honours each component's existing \`displayDateInUtc\` prop,
otherwise routes through the picker.
- **ChartBlock**: the two recharts \`labelFormatter\` arrows now close
over \`useFormatDateTime\`.
- **ChartHighlightActions**: range labels in the zoom dropdown migrated
to the same hook.

Intentionally untouched (must stay UTC):
- \`ChartHandler\` / \`ChartBlock\` \`startDate\`/\`endDate\` (API range
params, day boundary).
- \`ChartBlock.tsx:166\` explicit \`.utc()\` data-key normalisation.
- \`useFillTimeseriesSorted\` and friends (range math, no display).

## How to test

- Sign in. Open the avatar dropdown, pick a timezone different from your
browser local (e.g. Asia/Tokyo).
- Visit any project, then \`/project/<ref>/reports/database\` (or any
\`/observability/...\` page).
- Hover any chart series — the tooltip header should display the chosen
IANA name and times in that timezone.
- Click-drag a range on a chart to open the zoom dropdown — start/end
labels in the menu should also be in the chosen timezone.
- Switch back to "Auto detect" and confirm everything reverts to
browser-local.
- For an AreaChart/BarChart that uses \`displayDateInUtc\` (e.g. some
legacy reports), confirm those still render in UTC regardless of picker.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Standardized date/time formatting across charts, tooltips, axis
labels, header/footer labels, and highlight range labels in Reports and
chart components.
* Switched to a shared, timezone-aware formatter that respects UTC
display mode or the selected picker/timezone, ensuring consistent,
human-readable timestamps throughout the UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-07 19:31:59 +02:00

222 lines
6.8 KiB
TypeScript

import { useState } from 'react'
import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts'
import { ChartHeader } from './ChartHeader'
import {
CHART_COLORS,
DateTimeFormats,
DEFAULT_STACK_COLORS,
genStackColorScales,
ValidStackColor,
} from './Charts.constants'
import type { CommonChartProps } from './Charts.types'
import { numberFormatter, precisionFormatter, useChartSize, useStacked } from './Charts.utils'
import NoDataPlaceholder from './NoDataPlaceholder'
import { useChartHoverState } from './useChartHoverState'
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
interface Props extends CommonChartProps<any> {
xAxisKey: string
yAxisKey: string
stackKey: string
onBarClick?: () => void
variant?: 'values' | 'percentages'
xAxisFormatAsDate?: boolean
displayDateInUtc?: boolean
hideLegend?: boolean
hideHeader?: boolean
stackColors?: ValidStackColor[]
syncId?: string
}
const StackedBarChart: React.FC<Props> = ({
size,
data,
xAxisKey,
stackKey,
yAxisKey,
customDateFormat = DateTimeFormats.FULL,
title,
highlightedValue,
highlightedLabel,
format,
minimalHeader = false,
valuePrecision,
onBarClick,
variant,
xAxisFormatAsDate = true,
displayDateInUtc,
hideLegend = false,
hideHeader = false,
stackColors = DEFAULT_STACK_COLORS,
syncId,
}) => {
const { Container } = useChartSize(size)
const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
syncId || 'default'
)
const { dataKeys, stackedData, percentagesStackedData } = useStacked({
data,
xAxisKey,
stackKey,
yAxisKey,
variant,
})
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
// When `displayDateInUtc` is set the chart explicitly wants UTC labels.
// Otherwise honour the user's selected timezone via the picker.
const formatPickerDate = useFormatDateTime()
const formatChartDate = (value: number | string) =>
displayDateInUtc
? formatDateTime(value, { tz: 'UTC', format: customDateFormat })
: formatPickerDate(value, customDateFormat)
const resolvedHighlightedLabel =
(focusDataIndex !== null &&
data &&
data[focusDataIndex] !== undefined &&
formatChartDate(data[focusDataIndex][xAxisKey])) ||
highlightedLabel
const resolvedHighlightedValue =
focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue
if (!data || data.length === 0) {
return (
<NoDataPlaceholder
description="It may take up to 24 hours for data to refresh"
size={size}
attribute={title}
format={format}
/>
)
}
const stackColorScales = genStackColorScales(stackColors)
return (
<div className="w-full">
{!hideHeader && (
<ChartHeader
title={title}
format={format}
customDateFormat={customDateFormat}
minimalHeader={minimalHeader}
highlightedValue={
typeof resolvedHighlightedValue === 'number'
? numberFormatter(resolvedHighlightedValue, valuePrecision)
: resolvedHighlightedValue
}
highlightedLabel={resolvedHighlightedLabel}
syncId={syncId}
data={data}
xAxisKey={xAxisKey}
yAxisKey={yAxisKey}
xAxisIsDate={xAxisFormatAsDate}
displayDateInUtc={displayDateInUtc}
valuePrecision={valuePrecision}
attributes={[]}
/>
)}
<Container>
<BarChart
data={variant === 'percentages' ? percentagesStackedData : stackedData}
margin={{
top: 20,
right: 20,
left: 20,
bottom: 5,
}}
className="cursor-pointer overflow-visible"
// mouse hover focusing logic
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
setHover(e.activeTooltipIndex)
}}
onMouseLeave={() => {
setFocusDataIndex(null)
clearHover()
}}
>
{!hideLegend && (
<Legend
wrapperStyle={{ top: -6, fontSize: '0.8rem' }}
iconSize={8}
iconType="circle"
verticalAlign="top"
/>
)}
<XAxis
dataKey={xAxisKey}
interval={data.length - 2}
angle={0}
tick={false}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
/>
{dataKeys.map((datum, stackIndex) => (
<Bar
key={stackIndex}
dataKey={datum}
type="monotone"
legendType="circle"
fill={stackColorScales[stackIndex].base}
stackId={1}
animationDuration={300}
maxBarSize={48}
className={onBarClick ? 'cursor-pointer' : ''}
>
{stackedData?.map((_entry: unknown, index: any) => (
<Cell
key={`cell-${index}`}
className={`transition-all duration-300`}
opacity={focusDataIndex === index ? 0.85 : 1}
/>
))}
</Bar>
))}
<Tooltip
labelFormatter={
xAxisFormatAsDate ? (label) => formatChartDate(label as number | string) : undefined
}
formatter={(value, name, props) => {
const suffix = format || ''
if (variant === 'percentages' && percentagesStackedData) {
const index = percentagesStackedData.findIndex(
(pStack) => pStack === props.payload!
)
const val = stackedData[index][name]
const percentage = precisionFormatter(Number(value) * 100, 1) + '%'
return `${percentage} (${val}${suffix})`
}
return String(value) + suffix
}}
cursor={false}
labelClassName="text-white"
contentStyle={{
backgroundColor: '#444444',
borderColor: '#444444',
fontSize: '12px',
}}
wrapperClassName="bg-gray-600 rounded-sm min-w-md"
active={!!syncId && syncTooltip && hoveredIndex !== null}
/>
</BarChart>
</Container>
{stackedData && stackedData[0] && (
<div className="text-foreground-lighter -mt-5 flex items-center justify-between text-xs">
<span>{formatChartDate(stackedData[0][xAxisKey] as number | string)}</span>
<span>
{formatChartDate(stackedData[stackedData?.length - 1][xAxisKey] as number | string)}
</span>
</div>
)}
</div>
)
}
export default StackedBarChart