Files
supabase/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
T
Jordi Enric 9069e8d21e fix(reports): chart click selects bucket range, fix logs datepicker crash (#48009)
## Problem

In a database report, dragging over a chart to open logs had two bugs.
Clicking a bar instead of dragging produced a zero-width selection (its
=== ite), so the logs view showed no results. Separately, the logs
datepicker crashed with "Invalid time value" because the chart put raw
epoch-ms values into the its/ite URL params, which get parsed with new
Date(string) and yield an Invalid Date that react-day-picker cannot
format.

## Fix

The chart now emits ISO timestamps in the logs URL so its/ite match the
format every other logs consumer already uses, and the datepicker guards
against unparseable values from any source (such as old bookmarked
links). A single click now expands the highlight to the clicked bucket,
from the bar's start to the next bar's start, so the selection, popover,
and logs range all cover the bar the user picked.

## How to test

1. Open a database report with a chart (for example Postgres, project
logs).
2. Drag across the chart, choose "Open in Postgres Logs", then open the
timepicker. Expected: it opens without crashing and shows the selected
range.
3. Go back and single-click one bar instead of dragging. Expected: that
bar's bucket is selected and "Open in Postgres Logs" loads logs for a
real, non-empty range.
4. Run the chart tests, expected all pass: pnpm --filter studio exec
vitest --run components/ui/Charts


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

* **Bug Fixes**
* Prevented invalid log date inputs from creating invalid date
selections.
* Improved chart “open logs” links by normalizing the selected time
bounds (now consistently sent as ISO timestamps).
* Refined chart highlight behavior for click-to-advance and more
accurate left/right range selection, supporting both numeric and string
coordinate values.
* Updated the chart highlight dropdown display for clearer formatting of
numeric vs non-numeric dates.
* **Tests**
* Added Vitest coverage for chart highlight click, drag, and left/right
ordering scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-16 17:49:18 +02:00

394 lines
12 KiB
TypeScript

import dayjs from 'dayjs'
import { List, Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'
import { Card, cn, WarningIcon } from 'ui'
import type { ChartHighlightAction } from './ChartHighlightActions'
import type { ChartData } from './Charts.types'
import { ComposedChart } from './ComposedChart'
import { MultiAttribute } from './ComposedChart.utils'
import { useChartHighlight } from './useChartHighlight'
import Panel from '@/components/ui/Panel'
import { AnalyticsInterval, DataPoint } from '@/data/analytics/constants'
import { useInfraMonitoringQueries } from '@/data/analytics/infra-monitoring-queries'
import { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query'
import { useProjectDailyStatsQueries } from '@/data/analytics/project-daily-stats-queries'
import { ProjectDailyStatsAttribute } from '@/data/analytics/project-daily-stats-query'
import type { UpdateDateRange } from '@/pages/project/[ref]/observability/database'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
const parseChartTimestamp = (value: string) => {
const asNumber = Number(value)
return value !== '' && Number.isFinite(asNumber) ? dayjs(asNumber) : dayjs(value)
}
export interface ComposedChartHandlerProps {
id?: string
label: string
attributes: MultiAttribute[]
startDate: string
endDate: string
interval?: string
customDateFormat?: string
defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
hideChartType?: boolean
data?: ChartData | DataPoint[]
isLoading?: boolean
format?: string
highlightedValue?: string | number
className?: string
showTooltip?: boolean
showLegend?: boolean
showTotal?: boolean
showMaxValue?: boolean
normalizeVisibleStackToPercent?: boolean
updateDateRange?: UpdateDateRange
valuePrecision?: number
isVisible?: boolean
docsUrl?: string
hide?: boolean
syncId?: string
YAxisProps?: {
width?: number
tickFormatter?: (value: number) => string
domain?: [number | string, number | string]
allowDataOverflow?: boolean
}
}
/**
* Wrapper component that handles intersection observer logic for lazy loading
*/
const LazyChartWrapper = ({ children }: PropsWithChildren) => {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{
rootMargin: '150px 0px', // Start loading before the component enters viewport
threshold: 0,
}
)
const currentRef = ref.current
if (currentRef) {
observer.observe(currentRef)
}
return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [])
return (
<div ref={ref}>
{React.cloneElement(children as React.ReactElement<{ isVisible: boolean }>, { isVisible })}
</div>
)
}
/**
* Controls chart display state. Optionally fetches static chart data if data is not provided.
*
* If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render.
* - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided.
* - if `isLoading=true` and `data` is `undefined`, loading error message will be shown.
*
* Provided data must be in the expected chart format.
*/
const ComposedChartHandler = ({
label,
attributes,
startDate,
endDate,
interval,
customDateFormat,
children = null,
defaultChartStyle = 'bar',
hideChartType = false,
data,
isLoading,
format,
highlightedValue,
className,
showTooltip,
showLegend,
showMaxValue,
showTotal,
updateDateRange,
valuePrecision,
isVisible = true,
id,
syncId,
...otherProps
}: PropsWithChildren<ComposedChartHandlerProps>) => {
const router = useRouter()
const { ref } = router.query
const state = useDatabaseSelectorStateSnapshot()
const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
const chartHighlight = useChartHighlight()
const databaseIdentifier = state.selectedDatabaseId
const attributeQueries = useAttributeQueries(
attributes,
ref,
startDate,
endDate,
interval as AnalyticsInterval,
databaseIdentifier,
Array.isArray(data) ? undefined : data,
isVisible
)
const combinedData = useMemo(() => {
if (data) return Array.isArray(data) ? data : data.data
const isLoading = attributeQueries.some((query: any) => query.isLoading)
if (isLoading) return undefined
const hasError = attributeQueries.some((query: any) => !query.data)
if (hasError) return undefined
const timestamps = new Set<string>()
attributeQueries.forEach((query: any) => {
query.data?.data?.forEach((point: any) => {
if (point?.period_start) {
timestamps.add(point.period_start)
}
})
})
const referenceLineQueries = attributeQueries.filter(
(_, index) => attributes[index].provider === 'reference-line'
)
const combined = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { timestamp }
attributes.forEach((attr, index) => {
if (!attr) return
if (attr.customValue !== undefined) {
point[attr.attribute] = attr.customValue
return
}
if (attr.provider === 'reference-line') return
const queryData = attributeQueries[index]?.data?.data
const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp)
let value = matchingPoint?.[attr.attribute] ?? 0
if (attr.manipulateValue && typeof attr.manipulateValue === 'function') {
const numericValue = typeof value === 'number' ? value : Number(value) || 0
value = attr.manipulateValue(numericValue)
}
point[attr.attribute] = value
})
referenceLineQueries.forEach((query: any) => {
const attr = query.data.attribute
const value = query.data.total
point[attr] = value
})
const formattedDataPoint: DataPoint =
!('period_start' in point) && 'timestamp' in point
? { ...point, period_start: dayjs.utc(point.timestamp).unix() * 1000 }
: point
return formattedDataPoint
})
return combined as DataPoint[]
}, [data, attributeQueries, attributes])
const loading = isLoading || attributeQueries.some((query: any) => query.isLoading)
const _highlightedValue = useMemo(() => {
if (highlightedValue !== undefined) return highlightedValue
const firstAttr = attributes[0]
const firstQuery = attributeQueries[0]
const firstData = firstQuery?.data
if (!firstData) return undefined
const shouldHighlightMaxValue =
firstAttr.provider === 'daily-stats' &&
!firstAttr.attribute.includes('ingress') &&
!firstAttr.attribute.includes('egress') &&
'maximum' in firstData
const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData
return shouldHighlightMaxValue
? firstData.maximum
: firstAttr.provider === 'daily-stats'
? firstData.total
: shouldHighlightTotalGroupedValue
? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
: (firstData.data[firstData.data.length - 1] as any)?.[firstAttr.attribute]
}, [highlightedValue, attributes, attributeQueries])
const highlightActions: ChartHighlightAction[] = useMemo(() => {
return [
{
id: 'open-logs',
label: 'Open in Postgres Logs',
icon: <List size={12} />,
onSelect: ({ start, end }) => {
const projectRef = ref as string
if (!projectRef) return
const its = parseChartTimestamp(start).toISOString()
const ite = parseChartTimestamp(end).toISOString()
const url = `/project/${projectRef}/logs/postgres-logs?its=${its}&ite=${ite}`
router.push(url)
},
},
]
}, [ref])
if (loading) {
return (
<Card
className={cn(
'flex min-h-[280px] w-full flex-col items-center justify-center gap-y-2',
className
)}
>
<Loader2 size={18} className="animate-spin text-border-strong" />
<p className="text-xs text-foreground-lighter">Loading data for {label}</p>
</Card>
)
}
if (!combinedData) {
return (
<div className="flex h-64 w-full flex-col items-center justify-center gap-y-2 border border-dashed rounded-md">
<WarningIcon />
<p className="text-xs text-foreground-lighter">Unable to load data for {label}</p>
</div>
)
}
return (
<Panel
noMargin
noHideOverflow
className={cn('relative w-full scroll-mt-16', className)}
wrapWithLoading={false}
id={id ?? label.toLowerCase().replaceAll(' ', '-')}
>
<Panel.Content className="flex flex-col gap-4">
<div className="absolute right-6 z-50 flex justify-between scroll-mt-16">{children}</div>
<ComposedChart
attributes={attributes}
data={combinedData as DataPoint[]}
format={format}
// [Joshen] This is where it's messing up
xAxisKey="period_start"
yAxisKey={attributes[0].attribute}
highlightedValue={_highlightedValue}
title={label}
customDateFormat={customDateFormat}
chartHighlight={chartHighlight}
chartStyle={chartStyle}
showTooltip={showTooltip}
showLegend={showLegend}
showTotal={showTotal}
showMaxValue={showMaxValue}
onChartStyleChange={setChartStyle}
updateDateRange={updateDateRange}
valuePrecision={valuePrecision}
hideChartType={hideChartType}
syncId={syncId}
highlightActions={highlightActions}
{...otherProps}
/>
</Panel.Content>
</Panel>
)
}
const useAttributeQueries = (
attributes: MultiAttribute[],
ref: string | string[] | undefined,
startDate: string,
endDate: string,
interval: AnalyticsInterval,
databaseIdentifier: string | undefined,
data: ChartData | undefined,
isVisible: boolean
) => {
const infraAttributes = attributes
.filter((attr) => attr?.provider === 'infra-monitoring')
.map((attr) => attr.attribute as InfraMonitoringAttribute)
const dailyStatsAttributes = attributes
.filter((attr) => attr?.provider === 'daily-stats')
.map((attr) => attr.attribute as ProjectDailyStatsAttribute)
const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line')
const infraQueries = useInfraMonitoringQueries(
infraAttributes,
ref,
startDate,
endDate,
interval,
databaseIdentifier,
data,
isVisible
)
const dailyStatsQueries = useProjectDailyStatsQueries(
dailyStatsAttributes,
ref,
startDate,
endDate,
data,
isVisible
)
const referenceLineQueries = referenceLines.map((line) => {
let value = line.value ?? line.customValue ?? 0
return {
data: {
data: [],
attribute: line.attribute,
total: value,
maximum: value,
totalGrouped: { [line.attribute]: value },
},
isLoading: false,
isError: false,
}
})
return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries]
}
export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
if (props.hide) return null
return (
<LazyChartWrapper>
<ComposedChartHandler {...props} />
</LazyChartWrapper>
)
}