diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 55eaf64d919f1..4dba9ffdc9bf2 100755
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7182,6 +7182,7 @@ const CONST = {
VIEW: {
TABLE: 'table',
BAR: 'bar',
+ LINE: 'line',
},
SYNTAX_FILTER_KEYS: {
TYPE: 'type',
diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx
index c1d60f962b6b3..af40d0e8c6d46 100644
--- a/src/components/Charts/BarChart/BarChartContent.tsx
+++ b/src/components/Charts/BarChart/BarChartContent.tsx
@@ -6,33 +6,41 @@ 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 {
- 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,
- Y_AXIS_TICK_COUNT,
-} from '@components/Charts/constants';
+import ChartHeader from '@components/Charts/components/ChartHeader';
+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} 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 {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
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;
+
+/** 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 */
+const DOMAIN_PADDING_SAFETY_BUFFER = 1.1;
+
/**
* Calculate minimum domainPadding required to prevent bars from overflowing chart edges.
*
@@ -57,7 +65,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 +75,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(
@@ -101,10 +107,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, right: horizontalPadding + DOMAIN_PADDING.right, left: horizontalPadding};
}, [chartWidth, data.length]);
const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({
@@ -169,29 +175,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 +191,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 +260,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 +281,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..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 '@components/Charts/types';
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
new file mode 100644
index 0000000000000..140f704a9f0a6
--- /dev/null
+++ b/src/components/Charts/LineChart/LineChartContent.tsx
@@ -0,0 +1,190 @@
+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 ChartHeader from '@components/Charts/components/ChartHeader';
+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 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';
+import variables from '@styles/variables';
+
+/** 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) {
+ 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 yAxisDomain = useDynamicYDomain(data);
+
+ 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 = useTooltipData(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;
+export type {LineChartProps};
diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx
new file mode 100644
index 0000000000000..db7c218db9aba
--- /dev/null
+++ b/src/components/Charts/LineChart/index.native.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import type {LineChartProps} from './LineChartContent';
+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..907d385722f8e
--- /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 useThemeStyles from '@hooks/useThemeStyles';
+import type {LineChartProps} from './LineChartContent';
+
+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/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..be005042afb50 100644
--- a/src/components/Charts/ChartTooltip.tsx
+++ b/src/components/Charts/components/ChartTooltip.tsx
@@ -3,7 +3,12 @@ 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 b0ed40872622d..b8b987b9fe19a 100644
--- a/src/components/Charts/constants.ts
+++ b/src/components/Charts/constants.ts
@@ -1,113 +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 = '...';
-
-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,
-};
+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/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 e7a5422f58808..065eadcd3188e 100644
--- a/src/components/Charts/hooks/useChartInteractions.ts
+++ b/src/components/Charts/hooks/useChartInteractions.ts
@@ -3,21 +3,27 @@ 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
*/
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;
};
@@ -28,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}>;
};
@@ -46,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/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..e41bd9ff21294 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 */
+const X_AXIS_LABEL_ROTATION_45 = -45;
+
+/** 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 */
+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 aabb568439238..f86738fa140a5 100644
--- a/src/components/Charts/index.ts
+++ b/src/components/Charts/index.ts
@@ -1,6 +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};
-export type {BarChartDataPoint, BarChartProps} from './types';
+export {BarChart, ChartHeader, ChartTooltip, LineChart};
+export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition} 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 e2ec4fe540726..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,17 +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;
+ yAxisUnitPosition?: YAxisUnitPosition;
};
-export type {BarChartDataPoint, BarChartProps};
+type YAxisUnitPosition = 'left' | 'right';
+
+export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition};
diff --git a/src/components/Charts/chartColors.ts b/src/components/Charts/utils.ts
similarity index 55%
rename from src/components/Charts/chartColors.ts
rename to src/components/Charts/utils.ts
index c34ea720c0409..3dca6dc372f65 100644
--- a/src/components/Charts/chartColors.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]);
}
@@ -36,4 +38,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(5);
+
+export {getChartColor, DEFAULT_CHART_COLOR};
diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx
index 68d1e2f89e1a3..18b4beb3045f9 100644
--- a/src/components/Search/SearchBarChart.tsx
+++ b/src/components/Search/SearchBarChart.tsx
@@ -1,21 +1,13 @@
import React, {useCallback, useMemo} from 'react';
import {BarChart} from '@components/Charts';
-import type {BarChartDataPoint} from '@components/Charts';
-import type {
- TransactionCardGroupListItemType,
- TransactionCategoryGroupListItemType,
- TransactionGroupListItemType,
- TransactionMemberGroupListItemType,
- TransactionWithdrawalIDGroupListItemType,
-} from '@components/SelectionListWithSections/types';
+import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts';
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 */
- data: TransactionGroupListItemType[];
+ data: GroupedItem[];
/** Chart title */
title: string;
@@ -29,8 +21,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;
@@ -39,28 +31,26 @@ 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: BarChartDataPoint[] = useMemo(() => {
+ 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,
- currency,
};
});
}, [data, getLabel]);
const handleBarPress = useCallback(
- (dataPoint: BarChartDataPoint, index: number) => {
- if (!onBarPress) {
+ (dataPoint: ChartDataPoint, index: number) => {
+ if (!onItemPress) {
return;
}
@@ -69,10 +59,10 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar
return;
}
- const filterQuery = getFilterQuery(item as GroupedItem);
- onBarPress(filterQuery);
+ const filterQuery = getFilterQuery(item);
+ onItemPress(filterQuery);
},
- [data, getFilterQuery, onBarPress],
+ [data, getFilterQuery, onItemPress],
);
return (
diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx
index 1979dad7fb6cb..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,
@@ -27,19 +26,8 @@ import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUti
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import SearchBarChart from './SearchBarChart';
-import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types';
-
-type GroupedItem =
- | TransactionMemberGroupListItemType
- | TransactionCardGroupListItemType
- | TransactionWithdrawalIDGroupListItemType
- | TransactionCategoryGroupListItemType
- | TransactionMerchantGroupListItemType
- | TransactionTagGroupListItemType
- | TransactionMonthGroupListItemType
- | TransactionWeekGroupListItemType
- | TransactionYearGroupListItemType
- | TransactionQuarterGroupListItemType;
+import SearchLineChart from './SearchLineChart';
+import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './types';
type ChartGroupByConfig = {
titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar';
@@ -125,14 +113,14 @@ 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;
/** Grouped transaction data from search results */
- data: TransactionGroupListItemType[];
+ data: GroupedItem[];
/** Whether data is loading */
isLoading?: boolean;
@@ -144,8 +132,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,
};
/**
@@ -162,9 +151,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}`);
@@ -173,20 +161,17 @@ 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 firstItem = data.at(0);
const currency = firstItem?.currency ?? 'USD';
const {symbol, position} = getCurrencyDisplayInfoForCharts(currency);
@@ -210,7 +195,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
new file mode 100644
index 0000000000000..b066990219337
--- /dev/null
+++ b/src/components/Search/SearchLineChart.tsx
@@ -0,0 +1,82 @@
+import React, {useCallback, useMemo} from 'react';
+import {LineChart} from '@components/Charts';
+import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts';
+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: GroupedItem[];
+
+ /** 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 chart item is pressed - receives the filter query to apply */
+ onItemPress?: (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?: YAxisUnitPosition;
+};
+
+function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) {
+ const chartData: ChartDataPoint[] = useMemo(() => {
+ return data.map((item) => {
+ const currency = item.currency ?? 'USD';
+ const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency);
+
+ return {
+ label: getLabel(item),
+ total: totalInDisplayUnits,
+ };
+ });
+ }, [data, getLabel]);
+
+ const handlePointPress = useCallback(
+ (dataPoint: ChartDataPoint, index: number) => {
+ if (!onItemPress) {
+ return;
+ }
+
+ const item = data.at(index);
+ if (!item) {
+ return;
+ }
+
+ const filterQuery = getFilterQuery(item);
+ onItemPress(filterQuery);
+ },
+ [data, getFilterQuery, onItemPress],
+ );
+
+ return (
+
+ );
+}
+
+SearchLineChart.displayName = 'SearchLineChart';
+
+export default SearchLineChart;
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 59e59a9363449..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,
@@ -1305,17 +1306,16 @@ 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) {
+ if (shouldShowChartView && isGroupedItemArray(sortedData)) {
return (
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 7faab6847ef1e..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';
@@ -115,8 +130,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;
@@ -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,
};
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 62a48239b88bb..54690165917b8 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -6981,6 +6981,7 @@ const translations = {
label: 'View',
table: 'Table',
bar: 'Bar',
+ line: 'Line',
},
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]: '卡片',
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,
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,