diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index cd0813c10d09..7272de0a4d90 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import {BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {BAR_CORNER_RADIUS, BAR_GROUP_INNER_GAP, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; @@ -15,7 +15,8 @@ import { useSyncPointPositions, useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getBarLayout, getCartesianPlotBounds, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; +import type {ChartSeries} from '@components/Charts/types'; +import {getBarLayout, getCartesianPlotBounds, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -24,6 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import type {TupleToUnion} from 'type-fest'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; import {View} from 'react-native'; @@ -41,10 +43,10 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [{key: primarySeriesKey}] = series; - const chartData = data.map((point, index) => ({ + const seriesKeys = series.map((seriesItem) => seriesItem.key); + const chartData: Array<{x: number} & Record> = data.map((point, index) => ({ x: index, - y: getPrimaryValue(point, primarySeriesKey), + ...Object.fromEntries(seriesKeys.map((key) => [key, point.values[key]])), })); const yAxisDomain = useDynamicYDomain(data); @@ -73,6 +75,7 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap; const plotBounds = getCartesianPlotBounds(chartWidth, chartPaddingRight); const barLayout = getBarLayout(plotBounds.width, data.length); + const groupedBarWidth = (barLayout.barWidth - BAR_GROUP_INNER_GAP * (series.length - 1)) / series.length; const measurements = useChartLabelMeasurements(labelData, fontManager, variables.iconSizeExtraSmall); @@ -143,24 +146,31 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', })); - const renderBar = (point: PointsArray[number], chartBounds: ChartBounds) => { - const dataIndex = Number(point.xValue); - const dataPoint = data.at(dataIndex); + const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, seriesItem: ChartSeries, seriesIndex: number) => { + const dataPoint = data.at(Number(point.xValue)); + if (!dataPoint || typeof point.y !== 'number') { + return null; + } + + const pointColor = seriesIndex === 0 ? dataPoint.color : undefined; + const color = dataPoint.seriesColors?.[seriesItem.key] ?? pointColor ?? seriesItem.color ?? VictoryTheme.colors.default; + // Moves the bar from the group's center to the middle of its series' slot, counted from the group's left edge, so the series sit side by side + const offset = -barLayout.barWidth / 2 + seriesIndex * (groupedBarWidth + BAR_GROUP_INNER_GAP) + groupedBarWidth / 2; return ( ); }; - const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + const renderOutside = (args: CartesianChartRenderArg, string>) => { if (xAxisLabelHeight === undefined) { return null; } @@ -217,7 +227,7 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l explicitSize={{width: chartWidth, height: chartHeight}} xKey="x" padding={chartPadding} - yKeys={['y']} + yKeys={seriesKeys} domain={{x: barLayout.xDomain}} domainPadding={VERTICAL_BAR_DOMAIN_PADDING} onChartBoundsChange={handleChartBoundsChange} @@ -242,7 +252,8 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l chartBounds={chartBounds} color={theme.border} /> - {points.y.map((point) => renderBar(point, chartBounds))} + {groupedBarWidth > 0 && + series.flatMap((seriesItem, seriesIndex) => (points[seriesItem.key] ?? []).map((point) => renderBar(point, chartBounds, seriesItem, seriesIndex)))} )} diff --git a/src/components/Charts/barChartConstants.ts b/src/components/Charts/barChartConstants.ts index 6ced8b2c85a1..83ad876b136a 100644 --- a/src/components/Charts/barChartConstants.ts +++ b/src/components/Charts/barChartConstants.ts @@ -7,6 +7,9 @@ const BAR_GAP = 16; /** Widest a bar gets (px), so one or two bars don't stretch across the whole plot. */ const BAR_MAX_WIDTH = 320; +/** Gap (px) between the bars of different series at the same point */ +const BAR_GROUP_INNER_GAP = 4; + const BAR_CORNER_RADIUS = 4; /** Share of the gap on each side of a bar that still counts as hovering it, so neighbors never overlap. */ @@ -16,4 +19,4 @@ const BAR_HIT_GAP_RATIO = 0.25; const VERTICAL_BAR_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0}; export default BAR_INNER_PADDING; -export {BAR_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING}; +export {BAR_GAP, BAR_GROUP_INNER_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING}; diff --git a/src/components/Charts/components/ChartTooltipComparison.tsx b/src/components/Charts/components/ChartTooltipComparison.tsx index 3e45a95e5fa5..9560fc50c45d 100644 --- a/src/components/Charts/components/ChartTooltipComparison.tsx +++ b/src/components/Charts/components/ChartTooltipComparison.tsx @@ -5,7 +5,6 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; @@ -24,10 +23,9 @@ type ChartTooltipComparisonProps = { /** Renders each compared series' amount in the chart tooltip, followed by the change between them */ function ChartTooltipComparison({rows, change}: ChartTooltipComparisonProps) { const styles = useThemeStyles(); - const theme = useTheme(); const StyleUtils = useStyleUtils(); const icons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong']); - const changeColor = change?.isIncrease ? theme.danger : theme.successHover; + const changeColor = StyleUtils.getSpendChangeColor(!!change?.isIncrease); return ( <> diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index c4bad3a14c77..4dbfc7308594 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -31,6 +31,9 @@ type ChartDataPoint = { /** Color of the point's bar or slice. Left out, the chart's default color. */ color?: string; + /** Color of each series' bar at the point, keyed by the series' key. Left out, `color` for the first series and each other series' own color. */ + seriesColors?: Partial>; + /** Number of expenses in this point */ count?: number; diff --git a/src/components/Charts/utils/formatTooltipComparison.ts b/src/components/Charts/utils/formatTooltipComparison.ts index 282c14614ae1..c0ab9443d7b4 100644 --- a/src/components/Charts/utils/formatTooltipComparison.ts +++ b/src/components/Charts/utils/formatTooltipComparison.ts @@ -28,8 +28,8 @@ type TooltipChange = { /** The difference, without its sign */ amount: string; - /** The difference relative to the compared amount, absent when that amount is zero */ - percentage?: string; + /** The difference relative to the compared amount */ + percentage: string; isIncrease: boolean; }; @@ -41,6 +41,16 @@ type TooltipComparison = { change?: TooltipChange; }; +/** Formats how much the primary value rose or fell relative to the compared one, as a percentage */ +function formatChangePercentage(primaryValue: number, comparedValue: number, locale: Locale | undefined): string { + if (comparedValue === 0) { + return '—'; + } + + const difference = primaryValue - comparedValue; + return formatPercentOfTotal((Math.abs(difference) / Math.abs(comparedValue)) * 100, difference, locale); +} + /** Formats how much the primary value rose or fell against the compared one. */ function formatChange(primaryValue: number, comparedValue: number, formatAmount: (value: number) => string, locale: Locale | undefined): TooltipChange | undefined { if (primaryValue === comparedValue) { @@ -50,7 +60,7 @@ function formatChange(primaryValue: number, comparedValue: number, formatAmount: const difference = primaryValue - comparedValue; return { amount: formatAmount(Math.abs(difference)), - percentage: comparedValue === 0 ? undefined : formatPercentOfTotal((Math.abs(difference) / Math.abs(comparedValue)) * 100, difference, locale), + percentage: formatChangePercentage(primaryValue, comparedValue, locale), isIncrease: difference > 0, }; } @@ -77,7 +87,7 @@ function formatTooltipComparison( const isPrimary = key === primarySeriesKey; return { key, - color, + color: dataPoint.seriesColors?.[key] ?? color, label, amount: formatAmount(value), percentage: isPrimary && dataPoint.percentOfTotal !== undefined ? formatPercentOfTotal(dataPoint.percentOfTotal, value, locale) : undefined, @@ -88,4 +98,5 @@ function formatTooltipComparison( } export default formatTooltipComparison; +export {formatChangePercentage}; export type {TooltipChange, TooltipRow}; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index ba2ea2fe7f14..f4270edb47c9 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -4,6 +4,7 @@ import type {ChartDataPoint} from '@components/Charts'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import {sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -78,6 +79,7 @@ function SearchChartView({ renderDetails, chartContainerStyle, }: SearchChartViewProps) { + const theme = useTheme(); const {preferredLocale, translate, dateFnsLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); @@ -87,15 +89,17 @@ function SearchChartView({ const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {}; - const isSinglePointBar = shouldShowSinglePointAsBar && data.length === 1; const {series, rows} = buildChartSeries({ data, - comparison: isSinglePointBar ? undefined : comparison, + comparison, + groupBy, view, getLabel, getShortLabel: view === CONST.SEARCH.VIEW.LINE ? (getLineChartLabel ?? getShortLabel) : getShortLabel, getCurrencyDecimals, getInProgressLabel: (item) => getInProgressBucketLabel({groupBy, item, today, dateFnsLocale, dateFilterRange, translate}), + dateFnsLocale, + colorScheme: theme.colorScheme, }); const points = rows.map((row) => row.point); @@ -139,26 +143,27 @@ function SearchChartView({ shouldShowLabels={shouldShowGroupLabels} /> ), - [CONST.SEARCH.VIEW.LINE]: isSinglePointBar ? ( - - ) : ( - - ), + [CONST.SEARCH.VIEW.LINE]: + shouldShowSinglePointAsBar && points.length === 1 ? ( + + ) : ( + + ), [CONST.SEARCH.VIEW.PIE]: ( string | undefined; + + /** Locale the names of compared periods are formatted in */ + dateFnsLocale: LocaleContextProps['dateFnsLocale']; + + /** Decides the shade of a compared bar */ + colorScheme: ColorScheme; }; type SearchChartModel = { @@ -75,8 +105,40 @@ function getSliceColorsByDataIndex(data: ChartDataPoint[]): Array Date, toEnd: (date: Date) => Date) => isSameDay(startDate, toStart(startDate)) && isSameDay(endDate, toEnd(startDate)); + + if (isSameDay(startDate, endDate)) { + return format(startDate, 'MMM d, yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfYear, endOfYear)) { + return format(startDate, 'yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfQuarter, endOfQuarter)) { + return format(startDate, 'QQQ yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfMonth, endOfMonth)) { + return format(startDate, 'LLLL yyyy', {locale: dateFnsLocale}); + } + return DateUtils.getFormattedDateRangeForSearch(start, end, dateFnsLocale); +} + /** This is the single place group totals are turned into plotted values. */ -function buildChartSeries({data, comparison, view, getLabel, getShortLabel, getCurrencyDecimals, getInProgressLabel}: BuildChartSeriesParams): SearchChartModel { +function buildChartSeries({ + data, + comparison, + groupBy, + view, + getLabel, + getShortLabel, + getCurrencyDecimals, + getInProgressLabel, + dateFnsLocale, + colorScheme, +}: BuildChartSeriesParams): SearchChartModel { const getAmount = (item: GroupedItem) => convertToFrontendAmountAsInteger(item.total ?? 0, getCurrencyDecimals(item.currency ?? CONST.CURRENCY.USD)); const comparedByKey = new Map(comparison?.data.map((item) => [item.keyForList, item])); const rows = data.map((item) => { @@ -84,12 +146,20 @@ function buildChartSeries({data, comparison, view, getLabel, getShortLabel, getC const shortLabel = getShortLabel?.(item); const inProgressLabel = getInProgressLabel?.(item); const comparedItem = comparedByKey.get(item.keyForList); + let comparedValue: number | undefined; + if (comparedItem) { + comparedValue = getAmount(comparedItem); + } else if (comparison && !CHART_GROUP_BY_CONFIG[groupBy].getDateRange) { + // No compared match for a time bucket means its counterpart doesn't exist, but for any other group it means the group had no spend. + comparedValue = 0; + } + const point: ChartDataPoint = { label, shortLabel, values: { [CHART_SERIES_KEY.PRIMARY]: getAmount(item), - ...(comparedItem && {[CHART_SERIES_KEY.COMPARISON]: getAmount(comparedItem)}), + ...(comparedValue !== undefined && {[CHART_SERIES_KEY.COMPARISON]: comparedValue}), }, percentOfTotal: item.percentOfTotal, count: item.count, @@ -106,19 +176,32 @@ function buildChartSeries({data, comparison, view, getLabel, getShortLabel, getC const pieColors = view === CONST.SEARCH.VIEW.PIE ? getSliceColorsByDataIndex(rows.map((row) => row.point)) : undefined; return { - series: [{key: CHART_SERIES_KEY.PRIMARY}, ...(comparison ? [{key: CHART_SERIES_KEY.COMPARISON, color: comparison.color, label: comparison.label}] : [])], + series: [ + {key: CHART_SERIES_KEY.PRIMARY, label: comparison?.periods && formatDates(comparison.periods.primary, dateFnsLocale)}, + ...(comparison + ? [ + { + key: CHART_SERIES_KEY.COMPARISON, + color: comparison.color, + label: comparison.label ?? (comparison.periods && formatDates(comparison.periods.comparison, dateFnsLocale)), + }, + ] + : []), + ], rows: rows.map((row, index) => { - let color; if (pieColors) { - color = pieColors.at(index); - } else if (view === CONST.SEARCH.VIEW.BAR) { - color = VictoryTheme.colors.getColor(index); + return {...row, point: {...row.point, color: pieColors.at(index)}}; + } + if (view !== CONST.SEARCH.VIEW.BAR) { + return row; } - return {...row, point: {...row.point, color}}; + const color = VictoryTheme.colors.getColor(index); + const seriesColors = {[CHART_SERIES_KEY.PRIMARY]: color, ...(!!comparison && {[CHART_SERIES_KEY.COMPARISON]: VictoryTheme.colors.getComparisonColor(color, colorScheme)})}; + return {...row, point: {...row.point, color, seriesColors}}; }), }; } export {buildChartSeries, getSliceColorsByDataIndex, CHART_SERIES_KEY}; -export type {ChartComparison, SearchChartModel}; +export type {ChartComparison, ChartComparisonPeriods, ChartPeriod, SearchChartModel}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 5dd7a081fe84..3306accefb56 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1177,7 +1177,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'In Ausgaben anzeigen', emptyState: {title: 'Nichts anzuzeigen', subtitle: 'Versuchen Sie, Ihre Kriterien oben anzupassen'}, noExpensesState: {title: 'Sehen Sie, wohin Ihr Geld fließt', subtitle: 'Sobald Sie Ausgaben haben, finden Sie Ausgabentrends, Top-Händler und mehr.'}, - compare: {label: 'Vergleichen', previousPeriod: 'Vorheriger Zeitraum', average: 'Durchschnitt'}, + compare: { + label: 'Vergleichen', + previousPeriod: 'Vorheriger Zeitraum', + average: 'Durchschnitt', + increase: (percentage: string) => `Um ${percentage} gestiegen`, + decrease: (percentage: string) => `Um ${percentage} gesunken`, + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/el.ts b/src/languages/el.ts index 3a0a2f2c3a30..11ee82ea7fe6 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1224,7 +1224,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Προβολή στο Spend', emptyState: {title: 'Τίποτα προς εμφάνιση', subtitle: 'Δοκιμάστε να προσαρμόσετε τα κριτήριά σας παραπάνω'}, noExpensesState: {title: 'Δείτε πού πηγαίνουν τα χρήματά σας', subtitle: 'Μόλις έχετε δαπάνες, θα βλέπετε τάσεις δαπανών, κορυφαίους εμπόρους και άλλα.'}, - compare: {label: 'Σύγκριση', previousPeriod: 'Προηγούμενη περίοδος', average: 'Μέσος όρος'}, + compare: { + label: 'Σύγκριση', + previousPeriod: 'Προηγούμενη περίοδος', + average: 'Μέσος όρος', + increase: (percentage: string) => `Αύξηση ${percentage}`, + decrease: (percentage: string) => `Μείωση ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Συνδρομή', diff --git a/src/languages/en.ts b/src/languages/en.ts index bb6975f9f669..637a51a9cfeb 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1311,6 +1311,8 @@ const translations = { label: 'Compare', previousPeriod: 'Previous period', average: 'Average', + increase: (percentage: string) => `Up ${percentage}`, + decrease: (percentage: string) => `Down ${percentage}`, }, emptyState: { title: 'Nothing to show', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1255ad655989..b64e85177bbb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1172,7 +1172,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Ver en Gastos', emptyState: {title: 'Nada que mostrar', subtitle: 'Prueba a ajustar tus criterios de arriba'}, noExpensesState: {title: 'Ve adónde va tu dinero', subtitle: 'Una vez que tengas gastos, encontrarás tendencias de gasto, principales comercios y mucho más.'}, - compare: {label: 'Comparar', previousPeriod: 'Periodo anterior', average: 'Promedio'}, + compare: { + label: 'Comparar', + previousPeriod: 'Periodo anterior', + average: 'Promedio', + increase: (percentage: string) => `Sube ${percentage}`, + decrease: (percentage: string) => `Baja ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Suscripcion', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c629e2bd4b0e..d94cf050d1c7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1181,7 +1181,13 @@ const translations: TranslationDeepObject = { title: 'Voyez où va votre argent', subtitle: 'Une fois que vous aurez des dépenses, vous verrez des tendances de dépenses, les principaux marchands et plus encore.', }, - compare: {label: 'Comparer', previousPeriod: 'Période précédente', average: 'Moyenne'}, + compare: { + label: 'Comparer', + previousPeriod: 'Période précédente', + average: 'Moyenne', + increase: (percentage: string) => `En hausse de ${percentage}`, + decrease: (percentage: string) => `En baisse de ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9db5fb5ac92f..a41615898513 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1177,7 +1177,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Visualizza in Spese', emptyState: {title: 'Niente da mostrare', subtitle: 'Prova a modificare i criteri qui sopra'}, noExpensesState: {title: 'Scopri dove va il tuo denaro', subtitle: 'Una volta che avrai delle spese, troverai tendenze di spesa, principali esercenti e altro ancora.'}, - compare: {label: 'Confronta', previousPeriod: 'Periodo precedente', average: 'Media'}, + compare: { + label: 'Confronta', + previousPeriod: 'Periodo precedente', + average: 'Media', + increase: (percentage: string) => `In aumento del ${percentage}`, + decrease: (percentage: string) => `In calo del ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abbonamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d412979fa9bc..585daaea8fb8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1165,7 +1165,13 @@ const translations: TranslationDeepObject = { viewOnSpend: '支出で表示', emptyState: {title: '表示するものはありません', subtitle: '上の条件を調整してみてください'}, noExpensesState: {title: 'お金の使い道を確認する', subtitle: '経費が登録されると、支出の傾向や上位の加盟店など、さまざまな情報を確認できるようになります。'}, - compare: {label: '比較', previousPeriod: '前の期間', average: '平均'}, + compare: { + label: '比較', + previousPeriod: '前の期間', + average: '平均', + increase: (percentage: string) => `${percentage} 増加`, + decrease: (percentage: string) => `${percentage} 減少`, + }, }, allSettingsScreen: { subscription: 'サブスクリプション', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 60718020161a..875bbd74fe3b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1176,7 +1176,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Bekijken in Uitgaven', emptyState: {title: 'Niets om weer te geven', subtitle: 'Probeer je criteria hierboven aan te passen'}, noExpensesState: {title: 'Zie waar je geld naartoe gaat', subtitle: 'Zodra je uitgaven hebt, zie je bestedingspatronen, topverkopers en meer.'}, - compare: {label: 'Vergelijken', previousPeriod: 'Vorige periode', average: 'Gemiddeld'}, + compare: { + label: 'Vergelijken', + previousPeriod: 'Vorige periode', + average: 'Gemiddeld', + increase: (percentage: string) => `Gestegen met ${percentage}`, + decrease: (percentage: string) => `Gedaald met ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9792f3bfe001..5bc658058097 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1198,7 +1198,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Zobacz w Wydatkach', emptyState: {title: 'Nic do wyświetlenia', subtitle: 'Spróbuj zmienić kryteria powyżej'}, noExpensesState: {title: 'Zobacz, na co idą twoje pieniądze', subtitle: 'Gdy będziesz mieć wydatki, zobaczysz trendy w wydawaniu, najważniejszych sprzedawców i więcej.'}, - compare: {label: 'Porównaj', previousPeriod: 'Poprzedni okres', average: 'Średnia'}, + compare: { + label: 'Porównaj', + previousPeriod: 'Poprzedni okres', + average: 'Średnia', + increase: (percentage: string) => `Wzrost o ${percentage}`, + decrease: (percentage: string) => `Spadek o ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Subskrypcja', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ca2cb4c94ba2..358dbf443dc6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1176,7 +1176,13 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Ver em Gastos', emptyState: {title: 'Nada para mostrar', subtitle: 'Tente ajustar seus critérios acima'}, noExpensesState: {title: 'Veja para onde vai o seu dinheiro', subtitle: 'Quando você tiver despesas, vai encontrar tendências de gastos, principais estabelecimentos e muito mais.'}, - compare: {label: 'Comparar', previousPeriod: 'Período anterior', average: 'Média'}, + compare: { + label: 'Comparar', + previousPeriod: 'Período anterior', + average: 'Média', + increase: (percentage: string) => `Alta de ${percentage}`, + decrease: (percentage: string) => `Queda de ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Assinatura', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 44a092b96b81..a5eff1492d45 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1130,7 +1130,13 @@ const translations: TranslationDeepObject = { viewOnSpend: '在支出中查看', emptyState: {title: '没有可显示的内容', subtitle: '请尝试调整上面的条件'}, noExpensesState: {title: '查看你的资金流向', subtitle: '添加报销后,您就能查看消费趋势、主要商家等更多信息。'}, - compare: {label: '比较', previousPeriod: '上一期', average: '平均'}, + compare: { + label: '比较', + previousPeriod: '上一期', + average: '平均', + increase: (percentage: string) => `上升 ${percentage}`, + decrease: (percentage: string) => `下降 ${percentage}`, + }, }, allSettingsScreen: { subscription: '订阅', diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index fe515d980c51..17328cadf28c 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -143,6 +143,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte chart={headlineChart.chart} queryJSON={headlineChart.queryJSON} snapshot={headlineChart.snapshot} + previousPeriodQueryJSON={headlineChart.previousPeriodQueryJSON} + previousPeriodSnapshot={headlineChart.previousPeriodSnapshot} averageQueryJSON={headlineChart.averageQueryJSON} averageSnapshot={headlineChart.averageSnapshot} filters={filters} @@ -156,12 +158,14 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte key={columnIndex} style={[styles.flex1, styles.insightsChartColumn(shouldUseNarrowLayout)]} > - {columnCharts.map(({chart, queryJSON, snapshot, averageQueryJSON, averageSnapshot}) => ( + {columnCharts.map(({chart, queryJSON, snapshot, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}) => ( eligibleCharts.includes(chart))] : [headlineSpec, ...eligibleCharts].map((chart): InsightsChartQuery => ({chart, queryJSON: undefined})); const snapshots = useMultipleSnapshots( - visibleChartQueries.flatMap(({queryJSON, averageQueryJSON}) => [queryJSON, averageQueryJSON].flatMap((chartQueryJSON) => (chartQueryJSON ? [String(chartQueryJSON.hash)] : []))), + visibleChartQueries.flatMap(({queryJSON, previousPeriodQueryJSON, averageQueryJSON}) => + [queryJSON, previousPeriodQueryJSON, averageQueryJSON].flatMap((chartQueryJSON) => (chartQueryJSON ? [String(chartQueryJSON.hash)] : [])), + ), ); const charts: InsightsDashboardChart[] = visibleChartQueries.map((chartQuery) => ({ ...chartQuery, snapshot: chartQuery.queryJSON ? snapshots[chartQuery.queryJSON.hash] : undefined, + previousPeriodSnapshot: chartQuery.previousPeriodQueryJSON ? snapshots[chartQuery.previousPeriodQueryJSON.hash] : undefined, averageSnapshot: chartQuery.averageQueryJSON ? snapshots[chartQuery.averageQueryJSON.hash] : undefined, })); const [headlineChart, ...supportingCharts] = charts; diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 0b75eba886e9..5617ca64612e 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -41,10 +41,16 @@ type InsightsChartWidgetProps = { /** The snapshot stored under the chart's own query */ snapshot: OnyxEntry; - /** The chart's query for the typical amounts */ + /** The chart's query for the previous period */ + previousPeriodQueryJSON: Readonly | undefined; + + /** The snapshot stored under the chart's query for the previous period */ + previousPeriodSnapshot: OnyxEntry; + + /** The chart's query for the average amounts */ averageQueryJSON: Readonly | undefined; - /** The snapshot stored under the chart's query for the typical amounts */ + /** The snapshot stored under the chart's query for the average amounts */ averageSnapshot: OnyxEntry; /** Page-level filters every chart on the dashboard is narrowed by */ @@ -57,16 +63,28 @@ type InsightsChartWidgetProps = { onGroupByChange?: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsChartWidget({chart, queryJSON, snapshot, averageQueryJSON, averageSnapshot, filters, onRetry, onGroupByChange}: InsightsChartWidgetProps) { +function InsightsChartWidget({ + chart, + queryJSON, + snapshot, + previousPeriodQueryJSON, + previousPeriodSnapshot, + averageQueryJSON, + averageSnapshot, + filters, + onRetry, + onGroupByChange, +}: InsightsChartWidgetProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); const icons = useMemoizedLazyExpensifyIcons(['Expand']); - const {data, state, comparison} = useInsightsChartData({chart, queryJSON, snapshot, averageQueryJSON, averageSnapshot}, filters); + const {data, state, comparison} = useInsightsChartData({chart, queryJSON, snapshot, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}, filters); const groupBy = chart.groupBy ?? getQueryGroupBy(filters); const isLoading = state === INSIGHTS_CHART_STATE.LOADING; - const shouldShowTable = chart.view === CONST.SEARCH.VIEW.BAR || chart.view === CONST.SEARCH.VIEW.PIE; + const view = comparison && chart.view === CONST.SEARCH.VIEW.PIE ? CONST.SEARCH.VIEW.BAR : chart.view; + const shouldShowTable = view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.PIE; const groupByOptions = getInsightsGroupByOptions(filters.date); @@ -115,7 +133,7 @@ function InsightsChartWidget({chart, queryJSON, snapshot, averageQueryJSON, aver - {rows.map(({item, point}) => ( - - - {isMemberGroup(item) ? ( - - - - ) : ( - - )} - {point.label} + {rows.map(({item, point}) => { + const primaryValue = point.values[CHART_SERIES_KEY.PRIMARY]; + const comparedValue = point.values[CHART_SERIES_KEY.COMPARISON]; + const hasChange = primaryValue !== undefined && comparedValue !== undefined && primaryValue !== comparedValue; + const changePercentage = hasChange ? formatChangePercentage(primaryValue, comparedValue, preferredLocale) : undefined; + const isIncrease = hasChange && primaryValue > comparedValue; + const isNewSpend = comparedValue === 0; + const changeColor = StyleUtils.getSpendChangeColor(isIncrease); + + return ( + + + {isMemberGroup(item) ? ( + + + + ) : ( + + )} + {point.label} + + + {convertToDisplayString(item.total ?? 0, item.currency)} + {!!changePercentage && isNewSpend && ( + + {changePercentage} + + )} + {!!changePercentage && !isNewSpend && ( + + + {changePercentage} + + )} + - {convertToDisplayString(item.total ?? 0, item.currency)} - - ))} + ); + })} ); } diff --git a/src/pages/Insights/insightsCompare.ts b/src/pages/Insights/insightsCompare.ts new file mode 100644 index 000000000000..754ea8032853 --- /dev/null +++ b/src/pages/Insights/insightsCompare.ts @@ -0,0 +1,45 @@ +import type {ChartComparisonPeriods, ChartPeriod} from '@components/Search/buildChartSeries'; + +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import {differenceInCalendarDays, differenceInCalendarMonths, format, parseISO, startOfYear, subDays, subMonths, subYears} from 'date-fns'; + +import type {InsightsFilters} from './insightsFilters'; + +/** Formats a period's first and last day */ +function formatPeriod(start: Date, end: Date): ChartPeriod { + return { + start: format(start, CONST.DATE.FNS_FORMAT_STRING), + end: format(end, CONST.DATE.FNS_FORMAT_STRING), + }; +} + +/** Resolves the period the Date filter covers and the one before it, which name the compared series. The backend picks the compared rows itself. */ +function resolveComparisonPeriods(dateFilter: InsightsFilters['date']): ChartComparisonPeriods | undefined { + let primary; + if ('preset' in dateFilter) { + primary = getDateRangeForPreset(dateFilter.preset); + } else { + primary = 'on' in dateFilter ? {start: dateFilter.on, end: dateFilter.on} : {start: dateFilter.from, end: dateFilter.to}; + } + if (!primary.start || !primary.end) { + return undefined; + } + const start = parseISO(primary.start); + const end = parseISO(primary.end); + + // The year so far compares against the same days of the year before. + if ('preset' in dateFilter && dateFilter.preset === CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE) { + const sameDayLastYear = subYears(end, 1); + return {primary, comparison: formatPeriod(startOfYear(sameDayLastYear), sameDayLastYear)}; + } + + // The other presets cover whole months and compare month for month, while a custom range compares against the same number of days just before. + const previousStart = 'preset' in dateFilter ? subMonths(start, differenceInCalendarMonths(end, start) + 1) : subDays(start, differenceInCalendarDays(end, start) + 1); + + return {primary, comparison: formatPeriod(previousStart, subDays(start, 1))}; +} + +export default resolveComparisonPeriods; diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index 51c22890d163..b8e1c940d318 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -12,8 +12,8 @@ import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; import {getQueryGroupBy} from './insightsGroupByUtils'; -/** How many periods before the selected date range the Average mode spans (Typical on the ranking charts) */ -const COMPARE_TYPICAL_PERIOD_COUNT = 1; +/** How many periods before the selected date range the Average mode spans */ +const COMPARE_AVERAGE_PERIOD_COUNT = 1; /** Builds the date filter in the shape a search query is built from. */ function buildDateFormValues(date: InsightsFilters['date']): Partial { @@ -51,6 +51,7 @@ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters type InsightsChartQuery = { chart: InsightsChartSpec; queryJSON: Readonly | undefined; + previousPeriodQueryJSON?: Readonly; averageQueryJSON?: Readonly; }; @@ -62,7 +63,7 @@ type InsightsGraphHashes = { type InsightsGraphHashEntry = [InsightsGraphKey, InsightsGraphHashes]; /** Returns the chart's graph slot paired with the hashes its current period, previous period and average are stored under. */ -function buildSnapshotHashEntry({chart, queryJSON, averageQueryJSON}: InsightsChartQuery, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsGraphHashEntry | undefined { +function buildSnapshotHashEntry({chart, queryJSON, previousPeriodQueryJSON, averageQueryJSON}: InsightsChartQuery, shouldIncludeComparisons: boolean): InsightsGraphHashEntry | undefined { const snapshotHash = queryJSON?.hash; if (!snapshotHash) { return undefined; @@ -71,7 +72,7 @@ function buildSnapshotHashEntry({chart, queryJSON, averageQueryJSON}: InsightsCh return [chart.graphKey, {snapshotHash}]; } - const previousPeriodSnapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD))?.hash; + const previousPeriodSnapshotHash = previousPeriodQueryJSON?.hash; const averageSnapshotHash = averageQueryJSON?.hash; if (!previousPeriodSnapshotHash || !averageSnapshotHash) { return undefined; @@ -107,12 +108,15 @@ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: Insight const buildChartQuery = (chart: InsightsChartSpec): InsightsChartQuery => ({ chart, queryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters)), - ...(shouldIncludeComparisons && {averageQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE))}), + ...(shouldIncludeComparisons && { + previousPeriodQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD)), + averageQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE)), + }), }); const headlineChart = buildChartQuery(spec.headlineChart); const supportingCharts = spec.supportingCharts.map(buildChartQuery); const graphEntries = [headlineChart, ...supportingCharts] - .map((chartQuery) => buildSnapshotHashEntry(chartQuery, filters, shouldIncludeComparisons)) + .map((chartQuery) => buildSnapshotHashEntry(chartQuery, shouldIncludeComparisons)) .filter((entry): entry is InsightsGraphHashEntry => !!entry); const insightsHashes: InsightsDashboard['graphs'] = Object.fromEntries(graphEntries); @@ -124,7 +128,7 @@ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: Insight inputQuery, searchKey: spec.searchKey, insightsHashes, - numberOfPeriods: shouldIncludeComparisons ? COMPARE_TYPICAL_PERIOD_COUNT : undefined, + numberOfPeriods: shouldIncludeComparisons ? COMPARE_AVERAGE_PERIOD_COUNT : undefined, }), hash: queryJSON.hash, snapshotHashes: graphEntries.flatMap(([, hashes]) => Object.values(hashes).filter((hash): hash is number => hash !== undefined)), diff --git a/src/pages/Insights/resolveDashboardState.ts b/src/pages/Insights/resolveDashboardState.ts index b38a4cd2bbca..791f79efeaae 100644 --- a/src/pages/Insights/resolveDashboardState.ts +++ b/src/pages/Insights/resolveDashboardState.ts @@ -24,6 +24,7 @@ type InsightsDashboardState = ValueOf; type InsightsDashboardChart = InsightsChartQuery & { snapshot: OnyxEntry; + previousPeriodSnapshot?: OnyxEntry; averageSnapshot?: OnyxEntry; }; diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts index 3de3ff9a9dab..c49efdc00c7d 100644 --- a/src/pages/Insights/useInsightsChartData.ts +++ b/src/pages/Insights/useInsightsChartData.ts @@ -1,7 +1,8 @@ import VictoryTheme from '@components/Charts/VictoryTheme'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {ChartComparison} from '@components/Search/buildChartSeries'; import useGroupedItems from '@components/Search/hooks/useGroupedItems'; -import type {GroupedItem} from '@components/Search/types'; +import type {GroupedItem, SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -11,12 +12,16 @@ import useTheme from '@hooks/useTheme'; import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsightsChartData'; import CONST from '@src/CONST'; +import type {SearchResults} from '@src/types/onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {InsightsFilters} from './insightsFilters'; import type {InsightsDashboardChart} from './resolveDashboardState'; +import resolveComparisonPeriods from './insightsCompare'; + type InsightsChartState = ValueOf; type InsightsChartData = { @@ -30,13 +35,32 @@ type InsightsChartData = { comparison: ChartComparison | undefined; }; +/** What a chart is compared against: the query and snapshot of the compared rows, and what names the compared series */ +type ComparedChart = Pick & { + queryJSON: Readonly | undefined; + snapshot: OnyxEntry; +}; + const BLOCKING_STATES = new Set([INSIGHTS_CHART_STATE.LOADING, INSIGHTS_CHART_STATE.ERROR, INSIGHTS_CHART_STATE.OFFLINE]); +/** Returns what the chart is compared against for the page's Compare mode, or nothing when it doesn't compare */ +function getComparedChart(chart: InsightsDashboardChart, filters: InsightsFilters, translate: LocaleContextProps['translate']): ComparedChart | undefined { + const isLineChart = chart.chart.view === CONST.SEARCH.VIEW.LINE; + switch (filters.compare) { + case CONST.SEARCH.COMPARE.PREVIOUS_PERIOD: { + const periods = isLineChart ? undefined : resolveComparisonPeriods(filters.date); + return periods && {queryJSON: chart.previousPeriodQueryJSON, snapshot: chart.previousPeriodSnapshot, periods}; + } + case CONST.SEARCH.COMPARE.AVERAGE: + return {queryJSON: chart.averageQueryJSON, snapshot: chart.averageSnapshot, label: translate('insightsPage.compare.average')}; + default: + return undefined; + } +} + /** Resolves a chart's rows, and the rows it is compared against when the page compares. */ -function useInsightsChartData( - {chart, queryJSON, snapshot, averageQueryJSON, averageSnapshot}: Pick, - filters: InsightsFilters, -): InsightsChartData { +function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: InsightsFilters): InsightsChartData { + const {queryJSON, snapshot} = dashboardChart; const theme = useTheme(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -45,26 +69,24 @@ function useInsightsChartData( const sortedData = useGroupedItems(snapshot, queryJSON); const current = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); - const isComparing = isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE) && filters.compare === CONST.SEARCH.COMPARE.AVERAGE && chart.view === CONST.SEARCH.VIEW.LINE; - const averageSortedData = useGroupedItems(isComparing ? averageSnapshot : undefined, queryJSON); + const comparedChart = isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE) ? getComparedChart(dashboardChart, filters, translate) : undefined; + // Keeps every compared group, so each plotted group finds its own and not only the top ones. + const comparedSortedData = useGroupedItems(comparedChart?.snapshot, queryJSON && {...queryJSON, limit: undefined}); - if (!isComparing || current.state !== INSIGHTS_CHART_STATE.READY) { + if (!comparedChart || current.state !== INSIGHTS_CHART_STATE.READY) { return {...current, comparison: undefined}; } - const average = resolveInsightsChartData({snapshot: averageSnapshot, queryJSON: averageQueryJSON, sortedData: averageSortedData, isOffline}); + const compared = resolveInsightsChartData({snapshot: comparedChart.snapshot, queryJSON: comparedChart.queryJSON, sortedData: comparedSortedData, isOffline}); // A compared chart waits for both sets of rows. - if (BLOCKING_STATES.has(average.state)) { - return {data: current.data, state: average.state, comparison: undefined}; + if (BLOCKING_STATES.has(compared.state)) { + return {data: current.data, state: compared.state, comparison: undefined}; } + const color = VictoryTheme.colors.getComparisonColor(VictoryTheme.colors.default, theme.colorScheme); return { ...current, - comparison: { - data: average.data, - label: translate('insightsPage.compare.average'), - color: VictoryTheme.colors.getComparisonColor(VictoryTheme.colors.default, theme.colorScheme), - }, + comparison: {data: compared.data, color, label: comparedChart.label, periods: comparedChart.periods}, }; } diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 7a82905ed332..5644f3cc6363 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1890,6 +1890,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ */ getMentionTextColor: (isOurMention: boolean): string => (isOurMention ? theme.ourMentionText : theme.mentionText), + /** Returns the color of a change in spend, red when it went up and green when it went down */ + getSpendChangeColor: (isIncrease: boolean): string => (isIncrease ? theme.danger : theme.successHover), + /** * Generate the wrapper styles for the mini ReportActionContextMenu. */ diff --git a/tests/unit/buildChartSeriesTest.ts b/tests/unit/buildChartSeriesTest.ts index c7090b21d862..73b87873db07 100644 --- a/tests/unit/buildChartSeriesTest.ts +++ b/tests/unit/buildChartSeriesTest.ts @@ -54,7 +54,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000, 12), merchantGroup('Target', 190000, 41)]; // When the series is built for a bar chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then every value is scaled down to the currency unit, because the chart axis reads in dollars, not cents, // and the rows stay in the order the search returned so the chart and the inline table line up row for row @@ -67,7 +75,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a bar chart - const {series, rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {series, rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then a single primary series comes back and every point holds its amount under that series' key, // because the charts read each point's amount by the key of the series they draw @@ -80,7 +96,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then each row still references its own group object, because pressing a row has to open that group's // transactions and a copied object would lose the identity the rest of Search matches on @@ -94,6 +118,9 @@ describe('buildChartSeries', () => { // When the series is built with a group-by that offers a shortened label const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, view: CONST.SEARCH.VIEW.BAR, getLabel, @@ -111,7 +138,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then no row carries a color, because a line chart draws one continuous stroke and the inline table // must not show color swatches that nothing on the canvas corresponds to @@ -123,7 +158,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 190000), merchantGroup('Target', 480000)]; // When the series is built for a bar chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then colors follow the array order rather than the totals, so each bar keeps its color when the totals change expect(rows.map((row) => row.point.color)).toEqual([VictoryTheme.colors.getColor(0), VictoryTheme.colors.getColor(1)]); @@ -134,7 +177,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Small', 10000), merchantGroup('Large', 900000), merchantGroup('Medium', 50000)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the palette follows the slices by size, so `Large` gets the first color regardless of sitting second in the array expect(rows.at(1)?.point.color).toBe(VictoryTheme.colors.getColor(0)); @@ -147,7 +198,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Refund', -900000), merchantGroup('Target', 190000)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the refund is ranked first, because the pie draws slices sized by absolute value and ranking on the // signed total would push the biggest slice to the end of the palette @@ -160,7 +219,15 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000, 12, 71.6), merchantGroup('Target', 190000, 41, 28.4)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the point carries the backend share untouched, because the chart tooltip and the inline table both // read it from here and recomputing it in either place is what let them disagree @@ -172,7 +239,15 @@ describe('buildChartSeries', () => { const data = [quarterGroup(1, 75000), quarterGroup(2, 25000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then the point carries no share, because the only share we quote is the one the search measured against // its own total, and a share derived from the groups on hand would disagree with it as soon as the @@ -185,7 +260,15 @@ describe('buildChartSeries', () => { const data: GroupedItem[] = []; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then there is nothing to plot, because the empty state is the view's job and the chart must not be handed // placeholder rows to draw @@ -199,6 +282,9 @@ describe('buildChartSeries', () => { // When the series is built for a line chart compared against the average const {series, rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, comparison: {data: averages, label: 'Average', color: themeColors.green200}, view: CONST.SEARCH.VIEW.LINE, @@ -217,6 +303,9 @@ describe('buildChartSeries', () => { // When the series is built for a line chart compared against those rows const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, comparison: {data: [quarterGroup(1, 45000)], label: 'Average', color: themeColors.green200}, view: CONST.SEARCH.VIEW.LINE, @@ -228,6 +317,74 @@ describe('buildChartSeries', () => { expect(rows.map((row) => row.point.values[CHART_SERIES_KEY.COMPARISON])).toEqual([450, undefined]); }); + it('compares a ranked group the compared rows leave out against nothing spent', () => { + // Given this month's top merchants, one of which had no spend last month, so last month's rows leave it out + const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; + const previousMonth = [merchantGroup('Person', 240000)]; + + // When the series is built for a bar chart compared against last month + const {rows} = buildChartSeries({ + data, + comparison: {data: previousMonth, color: themeColors.green200}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then the new merchant compares against $0, since a ranking only lists groups that spent, rather than drawing no bar + expect(rows.map((row) => row.point.values[CHART_SERIES_KEY.COMPARISON])).toEqual([2400, 0]); + }); + + it("shades each compared bar with its own bar's color", () => { + // Given this month's top two merchants compared against the average amounts + const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; + + // When the series is built for a bar chart + const {rows} = buildChartSeries({ + data, + comparison: {data: [merchantGroup('Person', 240000), merchantGroup('Target', 90000)], label: 'Average', color: themeColors.green200}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then each pair shares its rank's hue, the compared bar lighter, rather than every compared bar taking the series' green + expect(rows.map((row) => row.point.seriesColors)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: themeColors.yellow400, [CHART_SERIES_KEY.COMPARISON]: themeColors.yellow200}, + {[CHART_SERIES_KEY.PRIMARY]: themeColors.tangerine400, [CHART_SERIES_KEY.COMPARISON]: themeColors.tangerine200}, + ]); + }); + + it('names each series by the period it covers when comparing periods', () => { + // Given this month's merchants compared against last month + const data = [merchantGroup('Person', 480000)]; + + // When the series is built with the two periods + const {series} = buildChartSeries({ + data, + comparison: { + data: [merchantGroup('Person', 240000)], + color: themeColors.green200, + periods: {primary: {start: '2026-10-01', end: '2026-10-31'}, comparison: {start: '2026-09-01', end: '2026-09-30'}}, + }, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then the tooltip can tell the two bars apart by their months, since neither period has a name of its own + expect(series.map((seriesItem) => seriesItem.label)).toEqual(['October 2026', 'September 2026']); + }); + it('relabels the group still collecting expenses, keeping its axis label', () => { // Given two quarter groups, the last of which is still in progress const data = [quarterGroup(3, 100000), quarterGroup(4, 20000)]; @@ -235,6 +392,9 @@ describe('buildChartSeries', () => { // When the series is built with a label for the in-progress group const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, view: CONST.SEARCH.VIEW.LINE, getLabel: quarterLabel, diff --git a/tests/unit/components/Charts/formatTooltipComparison.test.ts b/tests/unit/components/Charts/formatTooltipComparison.test.ts index 8f91fde98696..4360d824d534 100644 --- a/tests/unit/components/Charts/formatTooltipComparison.test.ts +++ b/tests/unit/components/Charts/formatTooltipComparison.test.ts @@ -28,6 +28,22 @@ describe('formatTooltipComparison', () => { ]); }); + it("colors a bar's rows like its own bars rather than like the series", () => { + // Given a blue bar and its lighter average bar, where the compared series keeps the line's color + const point: ChartDataPoint = { + label: 'Amazon', + values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 100}, + seriesColors: {[CHART_SERIES_KEY.PRIMARY]: colors.blue400, [CHART_SERIES_KEY.COMPARISON]: colors.blue200}, + }; + const series: NonEmptyTuple = [{key: CHART_SERIES_KEY.PRIMARY}, {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: 'Average'}]; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, series, formatAmount, 'en'); + + // Then the dots match the bars drawn: the bar's own blue, and a lighter blue for the average amount + expect(comparison?.rows.map((row) => row.color)).toEqual([colors.blue400, colors.blue200]); + }); + it('leaves the tooltip as it is when a single series is plotted', () => { // Given a point of a chart that compares nothing const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300}}; @@ -71,4 +87,15 @@ describe('formatTooltipComparison', () => { // Then the change is the unsigned difference, marked as a decrease expect(comparison?.change).toEqual({amount: '$50', percentage: '50%', isIncrease: false}); }); + + it('shows a dash for the percentage of spend that is new against nothing', () => { + // Given April spend for a group with no spend in the compared period + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 0}}; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then the change has no meaningful share of nothing, so a dash stands in for its percentage + expect(comparison?.change).toEqual({amount: '$300', percentage: '—', isIncrease: true}); + }); }); diff --git a/tests/unit/insightsCompareTest.ts b/tests/unit/insightsCompareTest.ts new file mode 100644 index 000000000000..9f4d30ef1e0f --- /dev/null +++ b/tests/unit/insightsCompareTest.ts @@ -0,0 +1,54 @@ +import resolveComparisonPeriods from '@pages/Insights/insightsCompare'; + +import CONST from '@src/CONST'; + +describe('resolveComparisonPeriods', () => { + beforeEach(() => { + // Given a fixed today, so the resolved periods are the same on every run + jest.useFakeTimers().setSystemTime(new Date('2026-09-22T12:00:00')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('compares the year so far against the same stretch of the year before', () => { + // When the page reports on the year to date + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE}); + + // Then each period runs from January 1st to the same day of its own year + expect(periods).toEqual({primary: {start: '2026-01-01', end: '2026-09-22'}, comparison: {start: '2025-01-01', end: '2025-09-22'}}); + }); + + it('compares a month against the month before it', () => { + // When the page reports on last month + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}); + + // Then both periods are whole months + expect(periods).toEqual({primary: {start: '2026-08-01', end: '2026-08-31'}, comparison: {start: '2026-07-01', end: '2026-07-31'}}); + }); + + it('compares the last twelve months against the twelve before them', () => { + // When the page reports on the last 12 months + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}); + + // Then the periods sit back to back, each a year long and bounded by whole months + expect(periods).toEqual({primary: {start: '2025-10-01', end: '2026-09-30'}, comparison: {start: '2024-10-01', end: '2025-09-30'}}); + }); + + it('compares a custom range against the range of the same length before it', () => { + // When the page reports on ten days in March + const periods = resolveComparisonPeriods({from: '2026-03-11', to: '2026-03-20'}); + + // Then the compared period is the ten days ending the day before it starts + expect(periods).toEqual({primary: {start: '2026-03-11', end: '2026-03-20'}, comparison: {start: '2026-03-01', end: '2026-03-10'}}); + }); + + it('compares a custom range of whole months against the same number of days before it', () => { + // When the page reports on October as a custom range + const periods = resolveComparisonPeriods({from: '2026-10-01', to: '2026-10-31'}); + + // Then the compared period is the 31 days before it, which starts on August 31st rather than September 1st + expect(periods?.comparison).toEqual({start: '2026-08-31', end: '2026-09-30'}); + }); +});