From 9e179c2db425ea8125adc824d85801e82cd9f775 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 11:21:43 -0800 Subject: [PATCH 01/14] Implement line chart --- src/CONST/index.ts | 2 + .../Charts/LineChart/LineChartContent.tsx | 222 ++++++++++++++++++ .../Charts/LineChart/index.native.tsx | 12 + src/components/Charts/LineChart/index.tsx | 27 +++ src/components/Charts/constants.ts | 16 ++ src/components/Charts/index.ts | 5 +- src/components/Charts/types.ts | 36 ++- src/components/Search/SearchChartView.tsx | 8 +- src/components/Search/SearchLineChart.tsx | 91 +++++++ src/components/Search/index.tsx | 3 +- src/components/Search/types.ts | 4 +- src/styles/index.ts | 8 + 12 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/components/Charts/LineChart/LineChartContent.tsx create mode 100644 src/components/Charts/LineChart/index.native.tsx create mode 100644 src/components/Charts/LineChart/index.tsx create mode 100644 src/components/Search/SearchLineChart.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 55eaf64d919f1..f7398158f3455 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7182,6 +7182,8 @@ const CONST = { VIEW: { TABLE: 'table', BAR: 'bar', + LINE: 'line', + PIE: 'pie', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx new file mode 100644 index 0000000000000..710fb24a47a8e --- /dev/null +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -0,0 +1,222 @@ +import {useFont} from '@shopify/react-native-skia'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; +import {CartesianChart, Line, Scatter} from 'victory-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import {getChartColor} from '@components/Charts/chartColors'; +import ChartHeader from '@components/Charts/ChartHeader'; +import ChartTooltip from '@components/Charts/ChartTooltip'; +import { + CHART_CONTENT_MIN_HEIGHT, + CHART_PADDING, + DOT_INNER_RADIUS, + DOT_OUTER_RADIUS, + LINE_CHART_FRAME, + X_AXIS_LINE_WIDTH, + Y_AXIS_DOMAIN, + Y_AXIS_LABEL_OFFSET, + Y_AXIS_LINE_WIDTH, + Y_AXIS_TICK_COUNT, +} from '@components/Charts/constants'; +import fontSource from '@components/Charts/font'; +import type {HitTestArgs} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; +import type {LineChartProps} from '@components/Charts/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +/** Symmetric domain padding for line charts */ +const LINE_DOMAIN_PADDING = { + left: 20, + right: 20, + top: 20, + bottom: 20, +}; + +function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const font = useFont(fontSource, variables.iconSizeExtraSmall); + const [chartWidth, setChartWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + const chartData = useMemo(() => { + return data.map((point, index) => ({ + x: index, + y: point.total, + })); + }, [data]); + + const handlePointPress = useCallback( + (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onPointPress) { + onPointPress(dataPoint, index); + } + }, + [data, onPointPress], + ); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }, []); + + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ + data, + font, + chartWidth, + barAreaWidth: chartWidth, + containerHeight, + }); + + const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ + data, + yAxisUnit, + yAxisUnitPosition, + labelSkipInterval, + labelRotation, + truncatedLabels, + }); + + const checkIsOverDot = useCallback( + (args: HitTestArgs) => { + 'worklet'; + + const dx = args.cursorX - args.targetX; + const dy = args.cursorY - args.targetY; + return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; + }, + [], + ); + + const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ + handlePress: handlePointPress, + checkIsOver: checkIsOverDot, + }); + + const tooltipData = useMemo(() => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + const formatted = dataPoint.total.toLocaleString(); + let formattedAmount = formatted; + if (yAxisUnit) { + const separator = yAxisUnit.length > 1 ? ' ' : ''; + formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + } + const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; + return { + label: dataPoint.label, + amount: formattedAmount, + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; + }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + + const dynamicChartStyle = useMemo( + () => ({ + height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), + }), + [maxLabelLength], + ); + + if (isLoading || !font) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + + {chartWidth > 0 && ( + + {({points}) => ( + <> + + + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + + + )} + + + ); +} + +export default LineChartContent; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx new file mode 100644 index 0000000000000..f0e5af8da0122 --- /dev/null +++ b/src/components/Charts/LineChart/index.native.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type {LineChartProps} from '@components/Charts/types'; +import LineChartContent from './LineChartContent'; + +function LineChart(props: LineChartProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx new file mode 100644 index 0000000000000..2fefabd884cb6 --- /dev/null +++ b/src/components/Charts/LineChart/index.tsx @@ -0,0 +1,27 @@ +import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; +import React from 'react'; +import {View} from 'react-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import type {LineChartProps} from '@components/Charts/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function LineChart(props: LineChartProps) { + const styles = useThemeStyles(); + + return ( + `/${file}`}} + getComponent={() => import('./LineChartContent')} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index b0ed40872622d..7c597f66bb150 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -87,6 +87,18 @@ const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; /** Ellipsis character for truncated labels */ const LABEL_ELLIPSIS = '...'; +/** Inner dot radius for line chart data points */ +const DOT_INNER_RADIUS = 6; + +/** Outer dot radius for line chart data points (background ring) */ +const DOT_OUTER_RADIUS = 8; + +/** Y-axis domain anchored at zero for line charts */ +const Y_AXIS_DOMAIN: [number] = [0]; + +/** Frame configuration for line charts - only left and bottom borders */ +const LINE_CHART_FRAME = {lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}; + export { CHART_COLORS, Y_AXIS_TICK_COUNT, @@ -110,4 +122,8 @@ export { LABEL_PADDING, X_AXIS_LABEL_MAX_HEIGHT_RATIO, LABEL_ELLIPSIS, + DOT_INNER_RADIUS, + DOT_OUTER_RADIUS, + Y_AXIS_DOMAIN, + LINE_CHART_FRAME, }; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index aabb568439238..5804e553c8f40 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,6 +1,7 @@ import BarChart from './BarChart'; import ChartHeader from './ChartHeader'; import ChartTooltip from './ChartTooltip'; +import LineChart from './LineChart'; -export {BarChart, ChartHeader, ChartTooltip}; -export type {BarChartDataPoint, BarChartProps} from './types'; +export {BarChart, ChartHeader, ChartTooltip, LineChart}; +export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps} from './types'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index e2ec4fe540726..678a0dd58b982 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -40,4 +40,38 @@ type BarChartProps = { useSingleColor?: boolean; }; -export type {BarChartDataPoint, BarChartProps}; +type LineChartDataPoint = { + /** Label displayed under the data point (e.g., "Nov 2025", "Week 3") */ + label: string; + + /** Total amount (pre-formatted, e.g., dollars not cents) */ + total: number; + + /** Query string for navigation when point is clicked (optional) */ + onClickQuery?: string; +}; + +type LineChartProps = { + /** Data points to display */ + data: LineChartDataPoint[]; + + /** Chart title (e.g., "Spend over time") */ + title?: string; + + /** Icon displayed next to the title */ + titleIcon?: IconAsset; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Callback when a data point is pressed */ + onPointPress?: (dataPoint: LineChartDataPoint, index: number) => void; + + /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ + yAxisUnit?: string; + + /** Position of the unit symbol relative to the value. Defaults to 'left'. */ + yAxisUnitPosition?: 'left' | 'right'; +}; + +export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps}; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 1979dad7fb6cb..0d5cde51e236d 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -27,6 +27,7 @@ import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUti import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchBarChart from './SearchBarChart'; +import SearchLineChart from './SearchLineChart'; import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; type GroupedItem = @@ -125,8 +126,8 @@ type SearchChartViewProps = { /** The current search query JSON */ queryJSON: SearchQueryJSON; - /** The view type (bar, etc.) */ - view: Exclude; + /** The view type (bar, line, etc.) */ + view: Exclude; /** The groupBy parameter */ groupBy: SearchGroupBy; @@ -144,8 +145,9 @@ type SearchChartViewProps = { /** * Map of chart view types to their corresponding chart components. */ -const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart> = { +const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart | typeof SearchLineChart> = { [CONST.SEARCH.VIEW.BAR]: SearchBarChart, + [CONST.SEARCH.VIEW.LINE]: SearchLineChart, }; /** diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx new file mode 100644 index 0000000000000..efe030eeaeb74 --- /dev/null +++ b/src/components/Search/SearchLineChart.tsx @@ -0,0 +1,91 @@ +import React, {useCallback, useMemo} from 'react'; +import {LineChart} from '@components/Charts'; +import type {LineChartDataPoint} from '@components/Charts'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionWithdrawalIDGroupListItemType, +} from '@components/SelectionListWithSections/types'; +import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; + +type SearchLineChartProps = { + /** Grouped transaction data from search results */ + data: TransactionGroupListItemType[]; + + /** Chart title */ + title: string; + + /** Chart title icon */ + titleIcon: IconAsset; + + /** Function to extract label from grouped item */ + getLabel: (item: GroupedItem) => string; + + /** Function to build filter query from grouped item */ + getFilterQuery: (item: GroupedItem) => string; + + /** Callback when a point is pressed - receives the filter query to apply */ + onBarPress?: (filterQuery: string) => void; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Currency symbol for Y-axis labels */ + yAxisUnit?: string; + + /** Position of currency symbol relative to value */ + yAxisUnitPosition?: 'left' | 'right'; +}; + +function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { + const chartData: LineChartDataPoint[] = useMemo(() => { + return data.map((item) => { + const groupedItem = item as GroupedItem; + const currency = groupedItem.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + + return { + label: getLabel(groupedItem), + total: totalInDisplayUnits, + }; + }); + }, [data, getLabel]); + + const handlePointPress = useCallback( + (dataPoint: LineChartDataPoint, index: number) => { + if (!onBarPress) { + return; + } + + const item = data.at(index); + if (!item) { + return; + } + + const filterQuery = getFilterQuery(item as GroupedItem); + onBarPress(filterQuery); + }, + [data, getFilterQuery, onBarPress], + ); + + return ( + + ); +} + +SearchLineChart.displayName = 'SearchLineChart'; + +export default SearchLineChart; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 59e59a9363449..eaa360727828b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1305,8 +1305,7 @@ function Search({ const shouldShowTableHeader = isLargeScreenWidth && !isChat; const tableHeaderVisible = canSelectMultiple || shouldShowTableHeader; - // Other charts are not implemented yet - const shouldShowChartView = view === CONST.SEARCH.VIEW.BAR && !!validGroupBy; + const shouldShowChartView = (view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE) && !!validGroupBy; if (shouldShowChartView) { return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 7faab6847ef1e..953aaca2848f6 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -115,8 +115,8 @@ type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | In type SearchStatus = SingularSearchStatus | SingularSearchStatus[]; type SearchGroupBy = ValueOf; type SearchView = ValueOf; -// LineChart and PieChart are not implemented so we exclude them here to prevent TypeScript errors in `SearchChartView.tsx`. -type ChartView = Exclude; +// PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`. +type ChartView = Exclude; type TableColumnSize = ValueOf; type SearchDatePreset = ValueOf; type SearchWithdrawalType = ValueOf; diff --git a/src/styles/index.ts b/src/styles/index.ts index 4ca6f677c1118..ccb2b64432820 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5805,6 +5805,14 @@ const staticStyles = (theme: ThemeColors) => barChartChartContainer: { minHeight: 250, }, + lineChartContainer: { + borderRadius: variables.componentBorderRadiusLarge, + paddingTop: variables.qrShareHorizontalPadding, + paddingHorizontal: variables.qrShareHorizontalPadding, + }, + lineChartChartContainer: { + minHeight: 250, + }, discoverSectionImage: { width: '100%', height: undefined, From a8c40b2c028e35f610daefc93bc696a80d9bebd4 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 11:21:56 -0800 Subject: [PATCH 02/14] Add english translation --- src/languages/en.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 62a48239b88bb..33ce71189d769 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6981,6 +6981,8 @@ const translations = { label: 'View', table: 'Table', bar: 'Bar', + line: 'Line', + pie: 'Pie', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'From', From 6340c82aef8ae789c7384f3719ff03269478b3dc Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:30:16 -0800 Subject: [PATCH 03/14] Refactor charts directory and make it more DRY --- .../Charts/BarChart/BarChartContent.tsx | 74 ++++++------ .../Charts/BarChart/index.native.tsx | 2 +- src/components/Charts/BarChart/index.tsx | 2 +- .../Charts/LineChart/LineChartContent.tsx | 62 ++++------ .../Charts/LineChart/index.native.tsx | 2 +- src/components/Charts/LineChart/index.tsx | 2 +- .../Charts/{ => components}/ChartHeader.tsx | 0 .../Charts/{ => components}/ChartTooltip.tsx | 6 +- src/components/Charts/constants.ts | 112 +----------------- src/components/Charts/hooks/index.ts | 2 + .../Charts/hooks/useChartInteractions.ts | 4 +- .../Charts/hooks/useChartLabelFormats.ts | 7 +- .../Charts/hooks/useChartLabelLayout.ts | 28 +++-- .../Charts/hooks/useDynamicYDomain.ts | 12 ++ src/components/Charts/hooks/useTooltipData.ts | 41 +++++++ src/components/Charts/index.ts | 8 +- src/components/Charts/types.ts | 59 ++------- .../Charts/{chartColors.ts => utils.ts} | 5 +- src/components/Search/SearchBarChart.tsx | 7 +- src/components/Search/SearchLineChart.tsx | 6 +- 20 files changed, 166 insertions(+), 275 deletions(-) rename src/components/Charts/{ => components}/ChartHeader.tsx (100%) rename src/components/Charts/{ => components}/ChartTooltip.tsx (90%) create mode 100644 src/components/Charts/hooks/useDynamicYDomain.ts create mode 100644 src/components/Charts/hooks/useTooltipData.ts rename src/components/Charts/{chartColors.ts => utils.ts} (85%) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index c1d60f962b6b3..08cffc68f0c66 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -6,19 +6,12 @@ import Animated, {useSharedValue} from 'react-native-reanimated'; import type {ChartBounds, PointsArray} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import {getChartColor} from '@components/Charts/chartColors'; -import ChartHeader from '@components/Charts/ChartHeader'; -import ChartTooltip from '@components/Charts/ChartTooltip'; +import ChartHeader from '@components/Charts/components/ChartHeader'; +import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import { - BAR_INNER_PADDING, - BAR_ROUNDED_CORNERS, - CHART_COLORS, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, - DEFAULT_SINGLE_BAR_COLOR_INDEX, - DOMAIN_PADDING, - DOMAIN_PADDING_SAFETY_BUFFER, - FRAME_LINE_WIDTH, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, @@ -26,13 +19,35 @@ import { } from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; -import type {BarChartProps} from '@components/Charts/types'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +type BarChartProps = CartesianChartProps & { + /** Callback when a bar is pressed */ + onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; + + /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ + useSingleColor?: boolean; +}; + +/** Inner padding between bars (0.3 = 30% of bar width) */ +const BAR_INNER_PADDING = 0.3; + +/** Domain padding configuration for the bar chart */ +const DOMAIN_PADDING = { + left: 0, + right: 16, + top: 30, + bottom: 10, +}; + +/** Safety buffer multiplier for domain padding calculation */ +const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; + /** * Calculate minimum domainPadding required to prevent bars from overflowing chart edges. * @@ -57,7 +72,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); - const defaultBarColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX); + const defaultBarColor = DEFAULT_CHART_COLOR; // prepare data for display const chartData = useMemo(() => { @@ -67,9 +82,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni })); }, [data]); - // Anchor Y-axis at zero so the baseline is always visible. - // When negative values are present, let victory-native auto-calculate the domain to avoid clipping. - const yAxisDomain = useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); + const yAxisDomain = useDynamicYDomain(data); // Handle bar press callback const handleBarPress = useCallback( @@ -169,29 +182,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni barGeometry, }); - const tooltipData = useMemo(() => { - if (activeDataIndex < 0 || activeDataIndex >= data.length) { - return null; - } - const dataPoint = data.at(activeDataIndex); - if (!dataPoint) { - return null; - } - const formatted = dataPoint.total.toLocaleString(); - let formattedAmount = formatted; - if (yAxisUnit) { - // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") - const separator = yAxisUnit.length > 1 ? ' ' : ''; - formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; - } - const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); - const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; - return { - label: dataPoint.label, - amount: formattedAmount, - percentage: percent < 1 ? '<1%' : `${percent}%`, - }; - }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); const renderBar = useCallback( (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { @@ -207,7 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni color={barColor} barCount={barCount} innerPadding={BAR_INNER_PADDING} - roundedCorners={BAR_ROUNDED_CORNERS} + roundedCorners={{topLeft: 8, topRight: 8, bottomLeft: 8, bottomRight: 8}} /> ); }, @@ -276,7 +267,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni domain: yAxisDomain, }, ]} - frame={{lineWidth: FRAME_LINE_WIDTH}} + frame={{lineWidth: 0}} data={chartData} > {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} @@ -297,3 +288,4 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } export default BarChartContent; +export type {BarChartProps}; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 82396525a5b00..54f47ea8bb1af 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {BarChartProps} from '@components/Charts/types'; +import type {BarChartProps} from './BarChartContent'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index c82a92ecbf23e..0d00fb501193c 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -2,7 +2,7 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {BarChartProps} from '@components/Charts/types'; +import type {BarChartProps} from './BarChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; function BarChart(props: BarChartProps) { diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 710fb24a47a8e..6e7e0e5fa13c5 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -5,36 +5,32 @@ import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import {getChartColor} from '@components/Charts/chartColors'; -import ChartHeader from '@components/Charts/ChartHeader'; -import ChartTooltip from '@components/Charts/ChartTooltip'; +import ChartHeader from '@components/Charts/components/ChartHeader'; +import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import { CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, - DOT_INNER_RADIUS, - DOT_OUTER_RADIUS, - LINE_CHART_FRAME, X_AXIS_LINE_WIDTH, - Y_AXIS_DOMAIN, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT, } from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; -import type {LineChartProps} from '@components/Charts/types'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -/** Symmetric domain padding for line charts */ -const LINE_DOMAIN_PADDING = { - left: 20, - right: 20, - top: 20, - bottom: 20, +/** Inner dot radius for line chart data points */ +const DOT_INNER_RADIUS = 6; + +type LineChartProps = CartesianChartProps & { + /** Callback when a data point is pressed */ + onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; }; function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { @@ -45,6 +41,8 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const [chartWidth, setChartWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); + const yAxisDomain = useDynamicYDomain(data); + const chartData = useMemo(() => { return data.map((point, index) => ({ x: index, @@ -104,28 +102,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn checkIsOver: checkIsOverDot, }); - const tooltipData = useMemo(() => { - if (activeDataIndex < 0 || activeDataIndex >= data.length) { - return null; - } - const dataPoint = data.at(activeDataIndex); - if (!dataPoint) { - return null; - } - const formatted = dataPoint.total.toLocaleString(); - let formattedAmount = formatted; - if (yAxisUnit) { - const separator = yAxisUnit.length > 1 ? ' ' : ''; - formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; - } - const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); - const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; - return { - label: dataPoint.label, - amount: formattedAmount, - percentage: percent < 1 ? '<1%' : `${percent}%`, - }; - }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); const dynamicChartStyle = useMemo( () => ({ @@ -161,7 +138,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={LINE_DOMAIN_PADDING} + domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} xAxis={{ @@ -182,24 +159,24 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn lineWidth: Y_AXIS_LINE_WIDTH, lineColor: theme.border, labelOffset: Y_AXIS_LABEL_OFFSET, - domain: Y_AXIS_DOMAIN, + domain: yAxisDomain, }, ]} - frame={LINE_CHART_FRAME} + frame={{lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}} data={chartData} > {({points}) => ( <> )} @@ -220,3 +197,4 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn } export default LineChartContent; +export type {LineChartProps}; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx index f0e5af8da0122..db7c218db9aba 100644 --- a/src/components/Charts/LineChart/index.native.tsx +++ b/src/components/Charts/LineChart/index.native.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {LineChartProps} from '@components/Charts/types'; +import type {LineChartProps} from './LineChartContent'; import LineChartContent from './LineChartContent'; function LineChart(props: LineChartProps) { diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 2fefabd884cb6..492b278697b71 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -2,7 +2,7 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {LineChartProps} from '@components/Charts/types'; +import type {LineChartProps} from './LineChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; function LineChart(props: LineChartProps) { diff --git a/src/components/Charts/ChartHeader.tsx b/src/components/Charts/components/ChartHeader.tsx similarity index 100% rename from src/components/Charts/ChartHeader.tsx rename to src/components/Charts/components/ChartHeader.tsx diff --git a/src/components/Charts/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx similarity index 90% rename from src/components/Charts/ChartTooltip.tsx rename to src/components/Charts/components/ChartTooltip.tsx index 5c61feef80f70..83e6d4b2f9dbd 100644 --- a/src/components/Charts/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -3,7 +3,11 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from './constants'; +/** The height of the chart tooltip pointer */ +const TOOLTIP_POINTER_HEIGHT = 4; + +/** The width of the chart tooltip pointer */ +const TOOLTIP_POINTER_WIDTH = 12; type ChartTooltipProps = { /** Label text (e.g., "Airfare", "Amazon") */ diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 7c597f66bb150..b8b987b9fe19a 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -1,129 +1,19 @@ -import type {Color} from '@shopify/react-native-skia'; -import type {RoundedCorners} from 'victory-native'; -import colors from '@styles/theme/colors'; - -/** - * Chart color palette from Figma design. - * Colors cycle when there are more data points than colors. - */ -const CHART_COLORS: Color[] = [colors.yellow400, colors.tangerine400, colors.pink400, colors.green400, colors.ice400]; - /** Number of Y-axis ticks (including zero) */ const Y_AXIS_TICK_COUNT = 5; -/** Inner padding between bars (0.3 = 30% of bar width) */ -const BAR_INNER_PADDING = 0.3; - -/** Domain padding configuration for the chart */ -const DOMAIN_PADDING = { - left: 0, - right: 16, - top: 30, - bottom: 10, -}; - /** Distance between Y-axis labels and the chart */ const Y_AXIS_LABEL_OFFSET = 16; -/** Rounded corners radius for bars */ -const BAR_CORNER_RADIUS = 8; - -/** Rounded corners configuration for bars */ -const BAR_ROUNDED_CORNERS: RoundedCorners = { - topLeft: BAR_CORNER_RADIUS, - topRight: BAR_CORNER_RADIUS, - bottomLeft: BAR_CORNER_RADIUS, - bottomRight: BAR_CORNER_RADIUS, -}; - /** Chart padding */ const CHART_PADDING = 5; /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; -/** Default bar color index when useSingleColor is true (ice blue) */ -const DEFAULT_SINGLE_BAR_COLOR_INDEX = 4; - -/** Safety buffer multiplier for domain padding calculation */ -const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; - /** Line width for X-axis (hidden) */ const X_AXIS_LINE_WIDTH = 0; /** Line width for Y-axis grid lines */ const Y_AXIS_LINE_WIDTH = 1; -/** Line width for frame (hidden) */ -const FRAME_LINE_WIDTH = 0; - -/** The height of the chart tooltip pointer */ -const TOOLTIP_POINTER_HEIGHT = 4; - -/** The width of the chart tooltip pointer */ -const TOOLTIP_POINTER_WIDTH = 12; - -/** Gap between bar top and tooltip bottom */ -const TOOLTIP_BAR_GAP = 8; - -/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_45 = -45; - -/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_90 = -90; - -/** Sin of 45 degrees - used to calculate effective width of rotated labels */ -const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 - -/** Minimum padding between labels (in pixels) */ -const LABEL_PADDING = 4; - -/** Maximum ratio of container height that X-axis labels can occupy. - * Victory allocates: fontHeight + yLabelOffset * 2 + rotateOffset. - * With fontHeight ~12px and yLabelOffset = 16, base is ~44px. - * This ratio limits total label area to prevent labels from taking too much space. */ -const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; - -/** Ellipsis character for truncated labels */ -const LABEL_ELLIPSIS = '...'; - -/** Inner dot radius for line chart data points */ -const DOT_INNER_RADIUS = 6; - -/** Outer dot radius for line chart data points (background ring) */ -const DOT_OUTER_RADIUS = 8; - -/** Y-axis domain anchored at zero for line charts */ -const Y_AXIS_DOMAIN: [number] = [0]; - -/** Frame configuration for line charts - only left and bottom borders */ -const LINE_CHART_FRAME = {lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}; - -export { - CHART_COLORS, - Y_AXIS_TICK_COUNT, - BAR_INNER_PADDING, - DOMAIN_PADDING, - Y_AXIS_LABEL_OFFSET, - BAR_ROUNDED_CORNERS, - CHART_PADDING, - CHART_CONTENT_MIN_HEIGHT, - DEFAULT_SINGLE_BAR_COLOR_INDEX, - DOMAIN_PADDING_SAFETY_BUFFER, - X_AXIS_LINE_WIDTH, - Y_AXIS_LINE_WIDTH, - FRAME_LINE_WIDTH, - TOOLTIP_POINTER_HEIGHT, - TOOLTIP_POINTER_WIDTH, - TOOLTIP_BAR_GAP, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - SIN_45_DEGREES, - LABEL_PADDING, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - LABEL_ELLIPSIS, - DOT_INNER_RADIUS, - DOT_OUTER_RADIUS, - Y_AXIS_DOMAIN, - LINE_CHART_FRAME, -}; +export {Y_AXIS_TICK_COUNT, Y_AXIS_LABEL_OFFSET, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH}; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index fc5119b0a729b..ac5272aa5b566 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -4,3 +4,5 @@ export {useChartInteractions} from './useChartInteractions'; export type {HitTestArgs} from './useChartInteractions'; export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; export {default as useChartLabelFormats} from './useChartLabelFormats'; +export {default as useDynamicYDomain} from './useDynamicYDomain'; +export {useTooltipData} from './useTooltipData'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index e7a5422f58808..3cb17d796b71a 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -3,9 +3,11 @@ import {Gesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import {useAnimatedReaction, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; -import {TOOLTIP_BAR_GAP} from '@components/Charts/constants'; import {useChartInteractionState} from './useChartInteractionState'; +/** Gap between bar top and tooltip bottom */ +const TOOLTIP_BAR_GAP = 8; + /** * Arguments passed to the checkIsOver callback for hit-testing */ diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 3f6136c5d262a..31921462d8b8c 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,13 +1,10 @@ import {useCallback} from 'react'; - -type ChartDataPoint = { - label: string; -}; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; type UseChartLabelFormatsProps = { data: ChartDataPoint[]; yAxisUnit?: string; - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; labelSkipInterval: number; labelRotation: number; truncatedLabels: string[]; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 215459488f8f6..108aa027ed4fb 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,14 +1,24 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; -import { - LABEL_ELLIPSIS, - LABEL_PADDING, - SIN_45_DEGREES, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - Y_AXIS_LABEL_OFFSET, -} from '@components/Charts/constants'; +import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; + +/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_45 = -45; + +/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_90 = -90; + +/** Sin of 45 degrees - used to calculate effective width of rotated labels */ +const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 + +/** Minimum padding between labels (in pixels) */ +const LABEL_PADDING = 4; + +/** Maximum ratio of container height that X-axis labels can occupy. */ +const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; + +/** Ellipsis character for truncated labels */ +const LABEL_ELLIPSIS = '...'; type ChartDataPoint = { label: string; diff --git a/src/components/Charts/hooks/useDynamicYDomain.ts b/src/components/Charts/hooks/useDynamicYDomain.ts new file mode 100644 index 0000000000000..2868a317b5404 --- /dev/null +++ b/src/components/Charts/hooks/useDynamicYDomain.ts @@ -0,0 +1,12 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint} from '@components/Charts/types'; + +/** + * Anchor Y-axis at zero so the baseline is always visible. + * When negative values are present, let victory-native auto-calculate the domain to avoid clipping. + */ +function useDynamicYDomain(data: ChartDataPoint[]): [number] | undefined { + return useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); +} + +export default useDynamicYDomain; diff --git a/src/components/Charts/hooks/useTooltipData.ts b/src/components/Charts/hooks/useTooltipData.ts new file mode 100644 index 0000000000000..3a75db14a1068 --- /dev/null +++ b/src/components/Charts/hooks/useTooltipData.ts @@ -0,0 +1,41 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; + +type TooltipData = { + label: string; + amount: string; + percentage: string; +}; + +/** + * Formats tooltip content for the active chart data point. + * Computes the display amount (with optional currency unit) and the percentage relative to all data points. + */ +function useTooltipData(activeDataIndex: number, data: ChartDataPoint[], yAxisUnit?: string, yAxisUnitPosition?: YAxisUnitPosition): TooltipData | null { + return useMemo(() => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + const formatted = dataPoint.total.toLocaleString(); + let formattedAmount = formatted; + if (yAxisUnit) { + // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") + const separator = yAxisUnit.length > 1 ? ' ' : ''; + formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + } + const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; + return { + label: dataPoint.label, + amount: formattedAmount, + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; + }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); +} + +export {useTooltipData}; +export type {TooltipData}; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index 5804e553c8f40..85629c33083bd 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,7 +1,9 @@ import BarChart from './BarChart'; -import ChartHeader from './ChartHeader'; -import ChartTooltip from './ChartTooltip'; +import ChartHeader from './components/ChartHeader'; +import ChartTooltip from './components/ChartTooltip'; import LineChart from './LineChart'; export {BarChart, ChartHeader, ChartTooltip, LineChart}; -export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps} from './types'; +export type {ChartDataPoint, CartesianChartProps} from './types'; +export type {BarChartProps} from './BarChart/BarChartContent'; +export type {LineChartProps} from './LineChart/LineChartContent'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 678a0dd58b982..da769fd2aeac8 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,24 +1,21 @@ import type IconAsset from '@src/types/utils/IconAsset'; -type BarChartDataPoint = { - /** Label displayed under the bar (e.g., "Amazon", "Travel", "Nov 2025") */ +type ChartDataPoint = { + /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ label: string; /** Total amount (pre-formatted, e.g., dollars not cents) */ total: number; - /** Currency code for formatting */ - currency: string; - - /** Query string for navigation when bar is clicked (optional) */ + /** Query string for navigation when data point is clicked (optional) */ onClickQuery?: string; }; -type BarChartProps = { +type CartesianChartProps = { /** Data points to display */ - data: BarChartDataPoint[]; + data: ChartDataPoint[]; - /** Chart title (e.g., "Top Categories", "Spend by Merchant") */ + /** Chart title (e.g., "Top Categories", "Spend over time") */ title?: string; /** Icon displayed next to the title */ @@ -27,51 +24,13 @@ type BarChartProps = { /** Whether data is loading */ isLoading?: boolean; - /** Callback when a bar is pressed */ - onBarPress?: (dataPoint: BarChartDataPoint, index: number) => void; - /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ yAxisUnit?: string; /** Position of the unit symbol relative to the value. Defaults to 'left'. */ - yAxisUnitPosition?: 'left' | 'right'; - - /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ - useSingleColor?: boolean; -}; - -type LineChartDataPoint = { - /** Label displayed under the data point (e.g., "Nov 2025", "Week 3") */ - label: string; - - /** Total amount (pre-formatted, e.g., dollars not cents) */ - total: number; - - /** Query string for navigation when point is clicked (optional) */ - onClickQuery?: string; + yAxisUnitPosition?: YAxisUnitPosition; }; -type LineChartProps = { - /** Data points to display */ - data: LineChartDataPoint[]; - - /** Chart title (e.g., "Spend over time") */ - title?: string; - - /** Icon displayed next to the title */ - titleIcon?: IconAsset; - - /** Whether data is loading */ - isLoading?: boolean; - - /** Callback when a data point is pressed */ - onPointPress?: (dataPoint: LineChartDataPoint, index: number) => void; - - /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ - yAxisUnit?: string; - - /** Position of the unit symbol relative to the value. Defaults to 'left'. */ - yAxisUnitPosition?: 'left' | 'right'; -}; +type YAxisUnitPosition = 'left' | 'right'; -export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps}; +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition}; diff --git a/src/components/Charts/chartColors.ts b/src/components/Charts/utils.ts similarity index 85% rename from src/components/Charts/chartColors.ts rename to src/components/Charts/utils.ts index c34ea720c0409..f14e4d5efbd07 100644 --- a/src/components/Charts/chartColors.ts +++ b/src/components/Charts/utils.ts @@ -36,4 +36,7 @@ function getChartColor(index: number): string { return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; } -export {CHART_PALETTE, getChartColor}; +/** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ +const DEFAULT_CHART_COLOR = getChartColor(4); + +export {getChartColor, DEFAULT_CHART_COLOR}; diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 68d1e2f89e1a3..a8b012167ade2 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; -import type {BarChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint} from '@components/Charts'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -44,7 +44,7 @@ type SearchBarChartProps = { function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { // Transform grouped transaction data to BarChart format - const chartData: BarChartDataPoint[] = useMemo(() => { + const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; const currency = groupedItem.currency ?? 'USD'; @@ -53,13 +53,12 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar return { label: getLabel(groupedItem), total: totalInDisplayUnits, - currency, }; }); }, [data, getLabel]); const handleBarPress = useCallback( - (dataPoint: BarChartDataPoint, index: number) => { + (dataPoint: ChartDataPoint, index: number) => { if (!onBarPress) { return; } diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index efe030eeaeb74..a726dddf4641c 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; -import type {LineChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint} from '@components/Charts'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -43,7 +43,7 @@ type SearchLineChartProps = { }; function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { - const chartData: LineChartDataPoint[] = useMemo(() => { + const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; const currency = groupedItem.currency ?? 'USD'; @@ -57,7 +57,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa }, [data, getLabel]); const handlePointPress = useCallback( - (dataPoint: LineChartDataPoint, index: number) => { + (dataPoint: ChartDataPoint, index: number) => { if (!onBarPress) { return; } From 2c42fde7dce08b7863e1a471eba1015eef9d9320 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:38:14 -0800 Subject: [PATCH 04/14] Add remaining translations --- src/CONST/index.ts | 1 - src/languages/de.ts | 2 +- src/languages/en.ts | 1 - src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 11 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f7398158f3455..4dba9ffdc9bf2 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7183,7 +7183,6 @@ const CONST = { TABLE: 'table', BAR: 'bar', LINE: 'line', - PIE: 'pie', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', diff --git a/src/languages/de.ts b/src/languages/de.ts index 8da341287e34e..755ffefffc84b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7079,7 +7079,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', }, topSpenders: 'Top-Ausgaben', - view: {label: 'Ansehen', table: 'Tabelle', bar: 'Bar'}, + view: {label: 'Ansehen', table: 'Tabelle', bar: 'Bar', line: 'Linie'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Von', [CONST.SEARCH.GROUP_BY.CARD]: 'Karten', diff --git a/src/languages/en.ts b/src/languages/en.ts index 33ce71189d769..54690165917b8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6982,7 +6982,6 @@ const translations = { table: 'Table', bar: 'Bar', line: 'Line', - pie: 'Pie', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'From', diff --git a/src/languages/es.ts b/src/languages/es.ts index 550115b5b9021..fbf00adfaca24 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6639,7 +6639,7 @@ ${amount} para ${merchant} - ${date}`, unapprovedCard: 'Tarjeta no aprobada', reconciliation: 'Conciliación', topSpenders: 'Mayores gastadores', - view: {label: 'Ver', table: 'Tabla', bar: 'Barra'}, + view: {label: 'Ver', table: 'Tabla', bar: 'Barra', line: 'Línea'}, saveSearch: 'Guardar búsqueda', savedSearchesMenuItemTitle: 'Guardadas', topCategories: 'Categorías principales', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 726c88e995a49..ca9980563cc10 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7091,7 +7091,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', }, topSpenders: 'Plus gros dépensiers', - view: {label: 'Afficher', table: 'Tableau', bar: 'Barre'}, + view: {label: 'Afficher', table: 'Tableau', bar: 'Barre', line: 'Ligne'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartes', diff --git a/src/languages/it.ts b/src/languages/it.ts index 33c0428bfdf4e..64063d39b9fc9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7068,7 +7068,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori allMatchingItemsSelected: 'Tutti gli elementi corrispondenti selezionati', }, topSpenders: 'Maggiori spenditori', - view: {label: 'Visualizza', table: 'Tabella', bar: 'Bar'}, + view: {label: 'Visualizza', table: 'Tabella', bar: 'Bar', line: 'Linea'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Da', [CONST.SEARCH.GROUP_BY.CARD]: 'Carte', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 761da2729ec5d..85f5160d36d74 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7006,7 +7006,7 @@ ${reportName} allMatchingItemsSelected: '一致する項目をすべて選択済み', }, topSpenders: 'トップ支出者', - view: {label: '表示', table: 'テーブル', bar: 'バー'}, + view: {label: '表示', table: 'テーブル', bar: 'バー', line: '折れ線'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '差出人', [CONST.SEARCH.GROUP_BY.CARD]: 'カード', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f4f2720e38722..fb4d0a2dcd892 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7051,7 +7051,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', }, topSpenders: 'Grootste uitgaven', - view: {label: 'Bekijken', table: 'Tabel', bar: 'Bar'}, + view: {label: 'Bekijken', table: 'Tabel', bar: 'Bar', line: 'Lijn'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Van', [CONST.SEARCH.GROUP_BY.CARD]: 'Kaarten', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7c790e3888ced..697f0a768c83e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7039,7 +7039,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i allMatchingItemsSelected: 'Wybrano wszystkie pasujące elementy', }, topSpenders: 'Najwięksi wydający', - view: {label: 'Zobacz', table: 'Tabela', bar: 'Pasek'}, + view: {label: 'Zobacz', table: 'Tabela', bar: 'Pasek', line: 'Linia'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Od', [CONST.SEARCH.GROUP_BY.CARD]: 'Karty', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 84ec54156619e..8077b928f3d90 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7040,7 +7040,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', }, topSpenders: 'Maiores gastadores', - view: {label: 'Ver', table: 'Tabela', bar: 'Bar'}, + view: {label: 'Ver', table: 'Tabela', bar: 'Bar', line: 'Linha'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartões', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7630d11c09fb3..70aac591ce31a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6886,7 +6886,7 @@ ${reportName} allMatchingItemsSelected: '已选择所有匹配的项目', }, topSpenders: '最高支出者', - view: {label: '查看', table: '表格', bar: '栏'}, + view: {label: '查看', table: '表格', bar: '栏', line: '折线'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '来自', [CONST.SEARCH.GROUP_BY.CARD]: '卡片', From acec8e616d31a4d36b29df130fb3f8d366c87501 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:49:33 -0800 Subject: [PATCH 05/14] Fix default color --- src/components/Charts/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index f14e4d5efbd07..f56cd568b28c8 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -37,6 +37,6 @@ function getChartColor(index: number): string { } /** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ -const DEFAULT_CHART_COLOR = getChartColor(4); +const DEFAULT_CHART_COLOR = getChartColor(5); export {getChartColor, DEFAULT_CHART_COLOR}; From 03dc033b6bb9607a64042e50f2c543747f6beddf Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:57:25 -0800 Subject: [PATCH 06/14] Move GroupedItem to common types --- src/components/Search/SearchBarChart.tsx | 11 ++------ src/components/Search/SearchChartView.tsx | 28 ++------------------ src/components/Search/SearchLineChart.tsx | 11 ++------ src/components/Search/types.ts | 31 ++++++++++++++++++++++- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index a8b012167ade2..874ee22f6393f 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,17 +1,10 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; import type {ChartDataPoint} from '@components/Charts'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionWithdrawalIDGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; - -type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; +import type {GroupedItem} from './types'; type SearchBarChartProps = { /** Grouped transaction data from search results */ diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 0d5cde51e236d..f57b66cdaaf2c 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -2,19 +2,7 @@ import React, {useCallback, useMemo} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionMerchantGroupListItemType, - TransactionMonthGroupListItemType, - TransactionQuarterGroupListItemType, - TransactionTagGroupListItemType, - TransactionWeekGroupListItemType, - TransactionWithdrawalIDGroupListItemType, - TransactionYearGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -28,19 +16,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchBarChart from './SearchBarChart'; import SearchLineChart from './SearchLineChart'; -import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; - -type GroupedItem = - | TransactionMemberGroupListItemType - | TransactionCardGroupListItemType - | TransactionWithdrawalIDGroupListItemType - | TransactionCategoryGroupListItemType - | TransactionMerchantGroupListItemType - | TransactionTagGroupListItemType - | TransactionMonthGroupListItemType - | TransactionWeekGroupListItemType - | TransactionYearGroupListItemType - | TransactionQuarterGroupListItemType; +import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './types'; type ChartGroupByConfig = { titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index a726dddf4641c..d83607b7cc028 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,17 +1,10 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; import type {ChartDataPoint} from '@components/Charts'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionWithdrawalIDGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; - -type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; +import type {GroupedItem} from './types'; type SearchLineChartProps = { /** Grouped transaction data from search results */ diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 953aaca2848f6..a232c0463ec8a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,6 +1,21 @@ import type {ValueOf} from 'type-fest'; import type {PaymentMethod} from '@components/KYCWall/types'; -import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import type { + ReportActionListItemType, + TaskListItemType, + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; import type {SearchKey} from '@libs/SearchUIUtils'; import type CONST from '@src/CONST'; import type {Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; @@ -307,6 +322,19 @@ type BankAccountMenuItem = { value: PaymentMethod; }; +/** Union type representing all possible grouped transaction item types used in chart views */ +type GroupedItem = + | TransactionMemberGroupListItemType + | TransactionCardGroupListItemType + | TransactionWithdrawalIDGroupListItemType + | TransactionCategoryGroupListItemType + | TransactionMerchantGroupListItemType + | TransactionTagGroupListItemType + | TransactionMonthGroupListItemType + | TransactionWeekGroupListItemType + | TransactionYearGroupListItemType + | TransactionQuarterGroupListItemType; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -353,4 +381,5 @@ export type { SearchTextFilterKeys, BankAccountMenuItem, SearchCustomColumnIds, + GroupedItem, }; From e53afcd53e9cd6d1c544771027e7dbfb9f5e30c5 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:05:36 -0800 Subject: [PATCH 07/14] Fix domain padding in BarChart --- src/components/Charts/BarChart/BarChartContent.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 08cffc68f0c66..e3e6be5e81026 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -37,12 +37,9 @@ type BarChartProps = CartesianChartProps & { /** Inner padding between bars (0.3 = 30% of bar width) */ const BAR_INNER_PADDING = 0.3; -/** Domain padding configuration for the bar chart */ +/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ const DOMAIN_PADDING = { - left: 0, - right: 16, - top: 30, - bottom: 10, + top: 32, }; /** Safety buffer multiplier for domain padding calculation */ @@ -114,10 +111,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const domainPadding = useMemo(() => { if (chartWidth === 0) { - return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + return {...DOMAIN_PADDING, left: 0, right: 0}; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + return {...DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ From d5966d6d8cdf6c2662d5775a2f4813807a40833d Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:09:24 -0800 Subject: [PATCH 08/14] Fix BarChart domain padding --- src/components/Charts/BarChart/BarChartContent.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index e3e6be5e81026..5b52b9913f6bc 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -40,6 +40,9 @@ const BAR_INNER_PADDING = 0.3; /** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ const DOMAIN_PADDING = { top: 32, + bottom: 0, + left: 0, + right: 0, }; /** Safety buffer multiplier for domain padding calculation */ @@ -114,7 +117,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return {...DOMAIN_PADDING, left: 0, right: 0}; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {...DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right}; + return {...DOMAIN_PADDING, right: horizontalPadding + DOMAIN_PADDING.right, left: horizontalPadding}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ From d3f8df9ee279954c8ce9a206122ed44baa6fc1a4 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:12:33 -0800 Subject: [PATCH 09/14] Bring back removed type imports --- src/components/Search/SearchChartView.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index f57b66cdaaf2c..32cb4f9385f98 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -2,7 +2,19 @@ import React, {useCallback, useMemo} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; From c6435780ad0ff665a80063d002d9579b5b015963 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:30:03 -0800 Subject: [PATCH 10/14] Address comments from previous PR --- .../Charts/components/ChartTooltip.tsx | 1 + .../Charts/hooks/useChartInteractionState.ts | 26 +++------------ .../Charts/hooks/useChartInteractions.ts | 32 +++++-------------- .../Charts/hooks/useChartLabelLayout.ts | 4 +-- src/components/Charts/utils.ts | 16 ++++++---- src/components/Search/SearchBarChart.tsx | 16 +++++----- src/components/Search/SearchChartView.tsx | 8 ++--- src/components/Search/SearchLineChart.tsx | 16 +++++----- 8 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index 83e6d4b2f9dbd..be005042afb50 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; + /** The height of the chart tooltip pointer */ const TOOLTIP_POINTER_HEIGHT = 4; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts index 344832485fe1f..a2d15a86e4d6f 100644 --- a/src/components/Charts/hooks/useChartInteractionState.ts +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -22,13 +22,16 @@ type ChartInteractionStateInit = { type ChartInteractionState = { /** Whether interaction (hover/press) is currently active */ isActive: SharedValue; + /** Index of the matched data point (-1 if none) */ matchedIndex: SharedValue; + /** X-axis value and position */ x: { value: SharedValue; position: SharedValue; }; + /** Y-axis values and positions for each y key */ y: Record< keyof Init['y'], @@ -37,8 +40,10 @@ type ChartInteractionState = { position: SharedValue; } >; + /** Y index for stacked bar charts */ yIndex: SharedValue; + /** Raw cursor position */ cursor: { x: SharedValue; @@ -68,27 +73,6 @@ function useIsInteractionActive(state: C /** * Creates shared state for chart interactions (hover, tap, press). * Compatible with Victory Native's handleTouch function exposed via actionsRef. - * - * @param initialValues - Initial x and y values matching your chart data structure - * @returns Object containing the interaction state and a boolean indicating if interaction is active - * - * @example - * ```tsx - * const { state, isActive } = useChartInteractionState({ - * x: '', - * y: { value: 0 } - * }); - * - * // Use with customGestures and actionsRef - * const hoverGesture = Gesture.Hover() - * .onUpdate((e) => { - * state.isActive.set(true); - * actionsRef.current?.handleTouch(state, e.x, e.y); - * }) - * .onEnd(() => { - * state.isActive.set(false); - * }); - * ``` */ function useChartInteractionState( initialValues: Init, diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 3cb17d796b71a..065eadcd3188e 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -14,12 +14,16 @@ const TOOLTIP_BAR_GAP = 8; type HitTestArgs = { /** Current raw X position of the cursor */ cursorX: number; + /** Current raw Y position of the cursor */ cursorY: number; + /** Calculated X position of the matched data point */ targetX: number; + /** Calculated Y position of the matched data point */ targetY: number; + /** The bottom boundary of the chart area */ chartBottom: number; }; @@ -30,11 +34,13 @@ type HitTestArgs = { type UseChartInteractionsProps = { /** Callback triggered when a valid data point is tapped/clicked */ handlePress: (index: number) => void; + /** * Worklet function to determine if the cursor is technically "hovering" * over a specific chart element (e.g., within a bar's width or a point's radius). */ checkIsOver: (args: HitTestArgs) => boolean; + /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ barGeometry?: SharedValue<{barWidth: number; chartBottom: number; yZero: number}>; }; @@ -48,33 +54,11 @@ type CartesianActionsHandle = { }; /** - * Hook to manage complex chart interactions including hover gestures (web), + * Hook to manage chart interactions including hover gestures (web), * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. * - * It synchronizes high-frequency interaction data from the UI thread to React state + * Synchronizes high-frequency interaction data from the UI thread to React state * for metadata display (like tooltips) and navigation. - * - * @param props - Configuration including press handlers and hit-test logic. - * @returns An object containing refs, gestures, and state for the chart component. - * - * @example - * ```tsx - * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ - * handlePress: (index) => console.log("Pressed index:", index), - * checkIsOver: ({ cursorX, targetX, barWidth }) => { - * 'worklet'; - * return Math.abs(cursorX - targetX) < barWidth / 2; - * }, - * barGeometry: myBarSharedValue, - * }); - * - * return ( - * - * - * {isTooltipActive && } - * - * ); - * ``` */ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 108aa027ed4fb..e41bd9ff21294 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -2,10 +2,10 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; -/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ +/** Rotation angle for X-axis labels - 45 degrees */ const X_AXIS_LABEL_ROTATION_45 = -45; -/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ +/** Rotation angle for X-axis labels - 90 degrees */ const X_AXIS_LABEL_ROTATION_90 = -90; /** Sin of 45 degrees - used to calculate effective width of rotated labels */ diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index f56cd568b28c8..3dca6dc372f65 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -2,20 +2,22 @@ import colors from '@styles/theme/colors'; /** * Expensify Chart Color Palette. - * Sequence logic: - * 1. Row Sequence: 400, 600, 300, 500, 700 - * 2. Hue Order: Yellow, Tangerine, Pink, Green, Ice, Blue + * + * Shades are ordered (400, 600, 300, 500, 700) so that sequential colors have + * maximum contrast, making adjacent chart segments easy to distinguish. + * + * Within each shade, hues cycle: Yellow, Tangerine, Pink, Green, Ice, Blue. */ const CHART_PALETTE: string[] = (() => { - const rows = [400, 600, 300, 500, 700] as const; + const shades = [400, 600, 300, 500, 700] as const; const hues = ['yellow', 'tangerine', 'pink', 'green', 'ice', 'blue'] as const; const palette: string[] = []; - // Generate the 30 unique combinations (5 rows × 6 hues) - for (const row of rows) { + // Generate the 30 unique combinations (5 shades × 6 hues) + for (const shade of shades) { for (const hue of hues) { - const colorKey = `${hue}${row}`; + const colorKey = `${hue}${shade}`; if (colors[colorKey]) { palette.push(colors[colorKey]); } diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 874ee22f6393f..684ed04ed8b9c 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; -import type {ChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -22,8 +22,8 @@ type SearchBarChartProps = { /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; - /** Callback when a bar is pressed - receives the filter query to apply */ - onBarPress?: (filterQuery: string) => void; + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; /** Whether data is loading */ isLoading?: boolean; @@ -32,10 +32,10 @@ type SearchBarChartProps = { yAxisUnit?: string; /** Position of currency symbol relative to value */ - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; }; -function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { +function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { // Transform grouped transaction data to BarChart format const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { @@ -52,7 +52,7 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar const handleBarPress = useCallback( (dataPoint: ChartDataPoint, index: number) => { - if (!onBarPress) { + if (!onItemPress) { return; } @@ -62,9 +62,9 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar } const filterQuery = getFilterQuery(item as GroupedItem); - onBarPress(filterQuery); + onItemPress(filterQuery); }, - [data, getFilterQuery, onBarPress], + [data, getFilterQuery, onItemPress], ); return ( diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 32cb4f9385f98..7b0b82b56b8b6 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -152,9 +152,8 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: const title = translate(`search.chartTitles.${groupBy}`); const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; - const handleBarPress = useCallback( + const handleItemPress = useCallback( (filterQuery: string) => { - // Build new query string from current query + filter, then parse it const currentQueryString = buildSearchQueryString(queryJSON); const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); @@ -163,18 +162,15 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: return; } - // Modify the query object directly: remove groupBy and view to show table newQueryJSON.groupBy = undefined; newQueryJSON.view = CONST.SEARCH.VIEW.TABLE; - // Build the final query string and navigate const newQueryString = buildSearchQueryString(newQueryJSON); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString})); }, [queryJSON], ); - // Get currency symbol and position from first data item const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { const firstItem = data.at(0) as GroupedItem | undefined; const currency = firstItem?.currency ?? 'USD'; @@ -200,7 +196,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: titleIcon={titleIcon} getLabel={getLabel} getFilterQuery={getFilterQuery} - onBarPress={handleBarPress} + onItemPress={handleItemPress} isLoading={isLoading} yAxisUnit={yAxisUnit} yAxisUnitPosition={yAxisUnitPosition} diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index d83607b7cc028..5505a2a647d32 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; -import type {ChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -22,8 +22,8 @@ type SearchLineChartProps = { /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; - /** Callback when a point is pressed - receives the filter query to apply */ - onBarPress?: (filterQuery: string) => void; + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; /** Whether data is loading */ isLoading?: boolean; @@ -32,10 +32,10 @@ type SearchLineChartProps = { yAxisUnit?: string; /** Position of currency symbol relative to value */ - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; }; -function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { +function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; @@ -51,7 +51,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa const handlePointPress = useCallback( (dataPoint: ChartDataPoint, index: number) => { - if (!onBarPress) { + if (!onItemPress) { return; } @@ -61,9 +61,9 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa } const filterQuery = getFilterQuery(item as GroupedItem); - onBarPress(filterQuery); + onItemPress(filterQuery); }, - [data, getFilterQuery, onBarPress], + [data, getFilterQuery, onItemPress], ); return ( From 513f1ac24ba4acfc39510e9c3f80e85193111891 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:31:26 -0800 Subject: [PATCH 11/14] Add missing import --- src/components/Charts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index 85629c33083bd..f86738fa140a5 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -4,6 +4,6 @@ import ChartTooltip from './components/ChartTooltip'; import LineChart from './LineChart'; export {BarChart, ChartHeader, ChartTooltip, LineChart}; -export type {ChartDataPoint, CartesianChartProps} from './types'; +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition} from './types'; export type {BarChartProps} from './BarChart/BarChartContent'; export type {LineChartProps} from './LineChart/LineChartContent'; From b7b1a172c66bc4163ac98ac32f9ea2fa825e436c Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:47:27 -0800 Subject: [PATCH 12/14] Format code and add type guard for grouped data --- .../Charts/BarChart/BarChartContent.tsx | 11 ++------ src/components/Charts/BarChart/index.tsx | 2 +- .../Charts/LineChart/LineChartContent.tsx | 26 ++++++------------- src/components/Charts/LineChart/index.tsx | 2 +- src/components/Search/SearchBarChart.tsx | 12 ++++----- src/components/Search/SearchChartView.tsx | 5 ++-- src/components/Search/SearchLineChart.tsx | 12 ++++----- src/components/Search/index.tsx | 5 ++-- src/libs/SearchUIUtils.ts | 11 ++++++++ 9 files changed, 38 insertions(+), 48 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 5b52b9913f6bc..af40d0e8c6d46 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -8,19 +8,12 @@ import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; -import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; -import { - CHART_CONTENT_MIN_HEIGHT, - CHART_PADDING, - X_AXIS_LINE_WIDTH, - Y_AXIS_LABEL_OFFSET, - Y_AXIS_LINE_WIDTH, - Y_AXIS_TICK_COUNT, -} from '@components/Charts/constants'; +import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; +import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 0d00fb501193c..90e4bee47edb4 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -2,8 +2,8 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {BarChartProps} from './BarChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {BarChartProps} from './BarChartContent'; function BarChart(props: BarChartProps) { const styles = useThemeStyles(); diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 6e7e0e5fa13c5..140f704a9f0a6 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -7,19 +7,12 @@ import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; -import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; -import { - CHART_CONTENT_MIN_HEIGHT, - CHART_PADDING, - X_AXIS_LINE_WIDTH, - Y_AXIS_LABEL_OFFSET, - Y_AXIS_LINE_WIDTH, - Y_AXIS_TICK_COUNT, -} from '@components/Charts/constants'; +import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; +import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -86,16 +79,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn truncatedLabels, }); - const checkIsOverDot = useCallback( - (args: HitTestArgs) => { - 'worklet'; + const checkIsOverDot = useCallback((args: HitTestArgs) => { + 'worklet'; - const dx = args.cursorX - args.targetX; - const dy = args.cursorY - args.targetY; - return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; - }, - [], - ); + const dx = args.cursorX - args.targetX; + const dy = args.cursorY - args.targetY; + return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; + }, []); const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ handlePress: handlePointPress, diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 492b278697b71..907d385722f8e 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -2,8 +2,8 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {LineChartProps} from './LineChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {LineChartProps} from './LineChartContent'; function LineChart(props: LineChartProps) { const styles = useThemeStyles(); diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 684ed04ed8b9c..18b4beb3045f9 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,14 +1,13 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; import type {GroupedItem} from './types'; type SearchBarChartProps = { /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Chart title */ title: string; @@ -39,12 +38,11 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onIte // Transform grouped transaction data to BarChart format const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { - const groupedItem = item as GroupedItem; - const currency = groupedItem.currency ?? 'USD'; - const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); return { - label: getLabel(groupedItem), + label: getLabel(item), total: totalInDisplayUnits, }; }); @@ -61,7 +59,7 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onIte return; } - const filterQuery = getFilterQuery(item as GroupedItem); + const filterQuery = getFilterQuery(item); onItemPress(filterQuery); }, [data, getFilterQuery, onItemPress], diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 7b0b82b56b8b6..4f34f3e67a525 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -5,7 +5,6 @@ import Animated from 'react-native-reanimated'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, - TransactionGroupListItemType, TransactionMemberGroupListItemType, TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, @@ -121,7 +120,7 @@ type SearchChartViewProps = { groupBy: SearchGroupBy; /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Whether data is loading */ isLoading?: boolean; @@ -172,7 +171,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: ); const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { - const firstItem = data.at(0) as GroupedItem | undefined; + const firstItem = data.at(0); const currency = firstItem?.currency ?? 'USD'; const {symbol, position} = getCurrencyDisplayInfoForCharts(currency); diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index 5505a2a647d32..b066990219337 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,14 +1,13 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; import type {GroupedItem} from './types'; type SearchLineChartProps = { /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Chart title */ title: string; @@ -38,12 +37,11 @@ type SearchLineChartProps = { function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { - const groupedItem = item as GroupedItem; - const currency = groupedItem.currency ?? 'USD'; - const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); return { - label: getLabel(groupedItem), + label: getLabel(item), total: totalInDisplayUnits, }; }); @@ -60,7 +58,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onIt return; } - const filterQuery = getFilterQuery(item as GroupedItem); + const filterQuery = getFilterQuery(item); onItemPress(filterQuery); }, [data, getFilterQuery, onItemPress], diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index eaa360727828b..2e1d087b315f4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -56,6 +56,7 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, + isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, @@ -1307,14 +1308,14 @@ function Search({ const shouldShowChartView = (view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE) && !!validGroupBy; - if (shouldShowChartView) { + if (shouldShowChartView && isGroupedItemArray(sortedData)) { return ( diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d6b32ac056454..387cfec489822 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -25,6 +25,7 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, + GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; @@ -1036,6 +1037,15 @@ function isTransactionQuarterGroupListItemType(item: ListItem): item is Transact return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER; } +/** + * Type guard that checks if a list of search items contains grouped transaction data. + * When a search has a groupBy parameter, all items share the same shape, so checking the first element is sufficient. + */ +function isGroupedItemArray(data: ListItem[]): data is GroupedItem[] { + const first = data.at(0); + return data.length === 0 || (first !== undefined && isTransactionGroupListItemType(first) && 'groupedBy' in first); +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -4496,6 +4506,7 @@ export { isTransactionWeekGroupListItemType, isTransactionYearGroupListItemType, isTransactionQuarterGroupListItemType, + isGroupedItemArray, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, From 01ab33f5ce088a5600af0c518dc661fe0c848d2f Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 15:11:00 -0800 Subject: [PATCH 13/14] Fix label rendering issues --- .../Charts/BarChart/BarChartContent.tsx | 39 +++++++++---------- .../Charts/LineChart/LineChartContent.tsx | 9 +++-- src/components/Charts/hooks/index.ts | 1 + .../Charts/hooks/useChartBoundsTracking.ts | 27 +++++++++++++ .../Charts/hooks/useChartLabelLayout.ts | 10 ++--- src/components/Search/index.tsx | 2 +- src/libs/SearchUIUtils.ts | 2 +- 7 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 src/components/Charts/hooks/useChartBoundsTracking.ts diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index af40d0e8c6d46..06672891b0722 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,7 +62,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; @@ -97,11 +96,28 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setContainerHeight(height); }, []); + // Store bar geometry for hit-testing (only constants, no arrays) + const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); + + const onBoundsChange = useCallback( + (bounds: ChartBounds, width: number) => { + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * width) / data.length; + barGeometry.set({ + ...barGeometry.get(), + barWidth: calculatedBarWidth, + chartBottom: bounds.bottom, + }); + }, + [data.length, barGeometry], + ); + + const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(onBoundsChange); + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - barAreaWidth, + plotAreaWidth, containerHeight, }); @@ -122,23 +138,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni truncatedLabels, }); - // Store bar geometry for hit-testing (only constants, no arrays) - const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); - - const handleChartBoundsChange = useCallback( - (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; - barGeometry.set({ - ...barGeometry.get(), - barWidth: calculatedBarWidth, - chartBottom: bounds.bottom, - }); - setBarAreaWidth(domainWidth); - }, - [data.length, barGeometry], - ); - const handleScaleChange = useCallback( (_xScale: unknown, yScale: (value: number) => number) => { barGeometry.set({ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 140f704a9f0a6..340bf66ebe101 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -10,7 +10,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,11 +62,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setContainerHeight(height); }, []); + const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(); + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - barAreaWidth: chartWidth, + plotAreaWidth, containerHeight, }); @@ -128,9 +130,10 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} + domainPadding={{left: 20, right: 40, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} + onChartBoundsChange={handleChartBoundsChange} xAxis={{ font, tickCount: data.length, diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index ac5272aa5b566..c1996fad8d443 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,3 +1,4 @@ +export {useChartBoundsTracking} from './useChartBoundsTracking'; export {useChartInteractionState} from './useChartInteractionState'; export {useChartLabelLayout} from './useChartLabelLayout'; export {useChartInteractions} from './useChartInteractions'; diff --git a/src/components/Charts/hooks/useChartBoundsTracking.ts b/src/components/Charts/hooks/useChartBoundsTracking.ts new file mode 100644 index 0000000000000..8b5188929617d --- /dev/null +++ b/src/components/Charts/hooks/useChartBoundsTracking.ts @@ -0,0 +1,27 @@ +import {useCallback, useState} from 'react'; +import type {ChartBounds} from 'victory-native'; + +type OnBoundsChange = (bounds: ChartBounds, plotAreaWidth: number) => void; + +/** + * Reusable hook that tracks the plot area width from CartesianChart's `onChartBoundsChange`. + * + * @param onBoundsChange - Optional callback for chart-specific logic (e.g. BarChart bar geometry). + */ +function useChartBoundsTracking(onBoundsChange?: OnBoundsChange) { + const [plotAreaWidth, setPlotAreaWidth] = useState(0); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + const width = bounds.right - bounds.left; + setPlotAreaWidth(width); + onBoundsChange?.(bounds, width); + }, + [onBoundsChange], + ); + + return {plotAreaWidth, handleChartBoundsChange}; +} + +export {useChartBoundsTracking}; +export type {OnBoundsChange}; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index e41bd9ff21294..18a3a4d1a45e5 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -29,7 +29,7 @@ type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; chartWidth: number; - barAreaWidth: number; + plotAreaWidth: number; containerHeight: number; }; @@ -43,7 +43,7 @@ function measureTextWidth(text: string, font: SkFont): number { return glyphWidths.reduce((sum, w) => sum + w, 0); } -function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHeight}: LabelLayoutConfig) { return useMemo(() => { if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; @@ -133,9 +133,9 @@ function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHei // Calculate skip interval using spec formula: // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) // skipInterval = ceil(barCount / maxVisibleLabels) - // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth + // Use plotAreaWidth (actual plotting area from chartBounds) rather than chartWidth // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = barAreaWidth || chartWidth; + const labelAreaWidth = plotAreaWidth || chartWidth; const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); // When maxVisibleLabels is 0 (area too narrow for even one label) or less than // data.length, compute the interval. data.length is the safe upper bound — show @@ -151,7 +151,7 @@ function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHei } return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; - }, [font, chartWidth, barAreaWidth, containerHeight, data]); + }, [font, chartWidth, plotAreaWidth, containerHeight, data]); } export {useChartLabelLayout}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2e1d087b315f4..8750ddb30642e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,6 +49,7 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, + isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -56,7 +57,6 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, - isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 387cfec489822..446cc1df6bb8a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,6 +11,7 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { + GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -25,7 +26,6 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, - GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; From d584243d9ac2d221581c44ea59b2fbb9c10dc31d Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 15:18:17 -0800 Subject: [PATCH 14/14] Revert "Fix label rendering issues" This reverts commit 01ab33f5ce088a5600af0c518dc661fe0c848d2f. --- .../Charts/BarChart/BarChartContent.tsx | 39 ++++++++++--------- .../Charts/LineChart/LineChartContent.tsx | 9 ++--- src/components/Charts/hooks/index.ts | 1 - .../Charts/hooks/useChartBoundsTracking.ts | 27 ------------- .../Charts/hooks/useChartLabelLayout.ts | 10 ++--- src/components/Search/index.tsx | 2 +- src/libs/SearchUIUtils.ts | 2 +- 7 files changed, 30 insertions(+), 60 deletions(-) delete mode 100644 src/components/Charts/hooks/useChartBoundsTracking.ts diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 06672891b0722..af40d0e8c6d46 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,6 +62,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); + const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; @@ -96,28 +97,11 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setContainerHeight(height); }, []); - // Store bar geometry for hit-testing (only constants, no arrays) - const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); - - const onBoundsChange = useCallback( - (bounds: ChartBounds, width: number) => { - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * width) / data.length; - barGeometry.set({ - ...barGeometry.get(), - barWidth: calculatedBarWidth, - chartBottom: bounds.bottom, - }); - }, - [data.length, barGeometry], - ); - - const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(onBoundsChange); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - plotAreaWidth, + barAreaWidth, containerHeight, }); @@ -138,6 +122,23 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni truncatedLabels, }); + // Store bar geometry for hit-testing (only constants, no arrays) + const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + const domainWidth = bounds.right - bounds.left; + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; + barGeometry.set({ + ...barGeometry.get(), + barWidth: calculatedBarWidth, + chartBottom: bounds.bottom, + }); + setBarAreaWidth(domainWidth); + }, + [data.length, barGeometry], + ); + const handleScaleChange = useCallback( (_xScale: unknown, yScale: (value: number) => number) => { barGeometry.set({ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 340bf66ebe101..140f704a9f0a6 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -10,7 +10,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,13 +62,11 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setContainerHeight(height); }, []); - const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - plotAreaWidth, + barAreaWidth: chartWidth, containerHeight, }); @@ -130,10 +128,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={{left: 20, right: 40, top: 20, bottom: 20}} + domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} - onChartBoundsChange={handleChartBoundsChange} xAxis={{ font, tickCount: data.length, diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index c1996fad8d443..ac5272aa5b566 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,4 +1,3 @@ -export {useChartBoundsTracking} from './useChartBoundsTracking'; export {useChartInteractionState} from './useChartInteractionState'; export {useChartLabelLayout} from './useChartLabelLayout'; export {useChartInteractions} from './useChartInteractions'; diff --git a/src/components/Charts/hooks/useChartBoundsTracking.ts b/src/components/Charts/hooks/useChartBoundsTracking.ts deleted file mode 100644 index 8b5188929617d..0000000000000 --- a/src/components/Charts/hooks/useChartBoundsTracking.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {useCallback, useState} from 'react'; -import type {ChartBounds} from 'victory-native'; - -type OnBoundsChange = (bounds: ChartBounds, plotAreaWidth: number) => void; - -/** - * Reusable hook that tracks the plot area width from CartesianChart's `onChartBoundsChange`. - * - * @param onBoundsChange - Optional callback for chart-specific logic (e.g. BarChart bar geometry). - */ -function useChartBoundsTracking(onBoundsChange?: OnBoundsChange) { - const [plotAreaWidth, setPlotAreaWidth] = useState(0); - - const handleChartBoundsChange = useCallback( - (bounds: ChartBounds) => { - const width = bounds.right - bounds.left; - setPlotAreaWidth(width); - onBoundsChange?.(bounds, width); - }, - [onBoundsChange], - ); - - return {plotAreaWidth, handleChartBoundsChange}; -} - -export {useChartBoundsTracking}; -export type {OnBoundsChange}; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 18a3a4d1a45e5..e41bd9ff21294 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -29,7 +29,7 @@ type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; chartWidth: number; - plotAreaWidth: number; + barAreaWidth: number; containerHeight: number; }; @@ -43,7 +43,7 @@ function measureTextWidth(text: string, font: SkFont): number { return glyphWidths.reduce((sum, w) => sum + w, 0); } -function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { return useMemo(() => { if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; @@ -133,9 +133,9 @@ function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHe // Calculate skip interval using spec formula: // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) // skipInterval = ceil(barCount / maxVisibleLabels) - // Use plotAreaWidth (actual plotting area from chartBounds) rather than chartWidth + // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = plotAreaWidth || chartWidth; + const labelAreaWidth = barAreaWidth || chartWidth; const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); // When maxVisibleLabels is 0 (area too narrow for even one label) or less than // data.length, compute the interval. data.length is the safe upper bound — show @@ -151,7 +151,7 @@ function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHe } return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; - }, [font, chartWidth, plotAreaWidth, containerHeight, data]); + }, [font, chartWidth, barAreaWidth, containerHeight, data]); } export {useChartLabelLayout}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8750ddb30642e..2e1d087b315f4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,7 +49,6 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, - isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -57,6 +56,7 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, + isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 446cc1df6bb8a..387cfec489822 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,7 +11,6 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { - GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -26,6 +25,7 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, + GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem';