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,