Files
supabase/apps/studio/components/ui/Charts/BarChart.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

234 lines
7.1 KiB
TypeScript

import { ComponentProps, useMemo, useState } from 'react'
import {
Bar,
CartesianGrid,
Cell,
Legend,
BarChart as RechartBarChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { CategoricalChartState } from 'recharts/types/chart/types'
import { ChartHeader } from './ChartHeader'
import type { CommonChartProps, Datum } from './Charts.types'
import { numberFormatter, useChartSize } from './Charts.utils'
import NoDataPlaceholder from './NoDataPlaceholder'
import { useChartHoverState } from './useChartHoverState'
import { CHART_COLORS, DateTimeFormats } from '@/components/ui/Charts/Charts.constants'
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
export interface BarChartProps<D = Datum> extends CommonChartProps<D> {
yAxisKey: string
xAxisKey: string
customDateFormat?: string
displayDateInUtc?: boolean
onBarClick?: (datum: D, tooltipData?: CategoricalChartState) => void
emptyStateMessage?: string
showLegend?: boolean
xAxisIsDate?: boolean
XAxisProps?: ComponentProps<typeof XAxis>
YAxisProps?: ComponentProps<typeof YAxis>
showGrid?: boolean
syncId?: string
}
function BarChart<D extends Datum = Datum>({
data,
yAxisKey,
xAxisKey,
format,
customDateFormat = DateTimeFormats.FULL,
title,
highlightedValue,
highlightedLabel,
displayDateInUtc,
minimalHeader,
valuePrecision,
className = '',
size = 'normal',
emptyStateMessage,
onBarClick,
showLegend = false,
xAxisIsDate = true,
XAxisProps,
YAxisProps,
showGrid = false,
syncId,
}: BarChartProps<D>) {
const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } =
useChartHoverState('default')
const { Container } = useChartSize(size)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
// Transform data to ensure yAxisKey values are numbers
const transformedData = useMemo(() => {
return data.map((item) => ({
...item,
[yAxisKey]: typeof item[yAxisKey] === 'string' ? Number(item[yAxisKey]) : item[yAxisKey],
}))
}, [data, yAxisKey])
// Default props
const _XAxisProps = XAxisProps || {
interval: data.length - 2,
angle: 0,
tick: false,
}
const _YAxisProps = YAxisProps || {
tickFormatter: (value) => numberFormatter(value, valuePrecision),
tick: false,
width: 0,
}
// When `displayDateInUtc` is set the chart explicitly wants UTC labels.
// Otherwise honour the user's selected timezone via the picker, which
// `useFormatDateTime` reads from context.
const formatPickerDate = useFormatDateTime()
const formatChartDate = (value: number | string) =>
displayDateInUtc
? formatDateTime(value, { tz: 'UTC', format: customDateFormat })
: formatPickerDate(value, customDateFormat)
function getHeaderLabel() {
if (!xAxisIsDate) {
if (!focusDataIndex) return highlightedLabel
return data[focusDataIndex]?.[xAxisKey]
}
return (
(focusDataIndex !== null &&
data &&
data[focusDataIndex] !== undefined &&
formatChartDate(data[focusDataIndex][xAxisKey] as number | string)) ||
highlightedLabel
)
}
const resolvedHighlightedLabel = getHeaderLabel()
const resolvedHighlightedValue =
focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue
if (data.length === 0) {
return (
<NoDataPlaceholder
message={emptyStateMessage}
description="It may take up to 24 hours for data to refresh"
size={size}
className={className}
attribute={title}
format={format}
/>
)
}
return (
<div className={['flex flex-col gap-y-3', className].join(' ')}>
<ChartHeader
title={title}
format={format}
customDateFormat={customDateFormat}
highlightedValue={resolvedHighlightedValue}
highlightedLabel={resolvedHighlightedLabel}
minimalHeader={minimalHeader}
syncId={syncId}
data={data}
xAxisKey={xAxisKey}
yAxisKey={yAxisKey}
xAxisIsDate={xAxisIsDate}
displayDateInUtc={displayDateInUtc}
valuePrecision={valuePrecision}
attributes={[]}
/>
<Container>
<RechartBarChart
data={transformedData}
className="overflow-visible"
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
setHover(e.activeTooltipIndex)
}}
onMouseLeave={() => {
setFocusDataIndex(null)
clearHover()
}}
onClick={(tooltipData) => {
const datum = tooltipData?.activePayload?.[0]?.payload
if (onBarClick) onBarClick(datum, tooltipData)
}}
>
{showLegend && <Legend />}
{showGrid && <CartesianGrid stroke={CHART_COLORS.AXIS} />}
<YAxis
{..._YAxisProps}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
key={yAxisKey}
/>
<XAxis
{..._XAxisProps}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
key={xAxisKey}
/>
<Tooltip
content={(_props) =>
syncId && isHovered && isCurrentChart && hoveredIndex !== null ? (
<div className="bg-black/90 text-white p-2 rounded-sm text-xs">
<div className="font-medium">
{formatChartDate(data[hoveredIndex]?.[xAxisKey] as number | string)}
</div>
<div>
{numberFormatter(Number(data[hoveredIndex]?.[yAxisKey]) || 0, valuePrecision)}
{typeof format === 'string' ? format : ''}
</div>
</div>
) : null
}
/>
<Bar
dataKey={yAxisKey}
fill={CHART_COLORS.GREEN_1}
animationDuration={300}
maxBarSize={48}
>
{data?.map((_entry: D, index: number) => (
<Cell
key={`cell-${index}`}
className={`transition-all duration-300 ${onBarClick ? 'cursor-pointer' : ''}`}
fill={
focusDataIndex === index || focusDataIndex === null
? CHART_COLORS.GREEN_1
: CHART_COLORS.GREEN_2
}
enableBackground={12}
/>
))}
</Bar>
</RechartBarChart>
</Container>
{data && (
<div className="text-foreground-lighter -mt-10 flex items-center justify-between text-[10px] font-mono">
<span>
{xAxisIsDate
? formatChartDate(data[0][xAxisKey] as number | string)
: data[0][xAxisKey]}
</span>
<span>
{xAxisIsDate
? formatChartDate(data[data?.length - 1]?.[xAxisKey] as number | string)
: data[data?.length - 1]?.[xAxisKey]}
</span>
</div>
)}
</div>
)
}
export default BarChart