From 3fbb94a29485ead91d789438340c6f6b730cc09a Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 10 Nov 2026 16:42:59 +0100 Subject: [PATCH 01/10] Stop plotting future periods and future-dated expenses --- src/components/Search/SearchChartView.tsx | 2 - src/components/Search/buildChartSeries.ts | 14 +---- src/languages/en.ts | 1 + src/pages/Insights/InsightsDashboard.tsx | 23 ++++++- .../Insights/charts/InsightsChartWidget.tsx | 5 ++ .../charts/InsightsFutureExpensesNote.tsx | 60 +++++++++++++++++++ .../Insights/details/InsightsDetailsPage.tsx | 3 +- src/pages/Insights/getInsightsDateRange.ts | 16 +++++ src/pages/Insights/insightsCompare.ts | 11 +--- src/pages/Insights/insightsGroupByUtils.ts | 29 +++++++-- src/pages/Insights/insightsQueries.ts | 45 ++++++++++++-- .../InsightsSection/useHomeInsightConfigs.ts | 2 +- src/types/onyx/InsightsDashboard.ts | 20 ++++++- src/types/onyx/index.ts | 3 +- tests/unit/buildChartSeriesTest.ts | 24 +++++--- tests/unit/getInsightsDetailsPeriodTest.ts | 4 +- tests/unit/insightsGroupByUtilsTest.ts | 16 ++--- tests/unit/insightsQueriesTest.ts | 14 ++--- 18 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 src/pages/Insights/charts/InsightsFutureExpensesNote.tsx create mode 100644 src/pages/Insights/getInsightsDateRange.ts diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 6569320c9661..34f723d9b06b 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -135,7 +135,6 @@ function SearchChartView({ const formatTotal = isCountMetric ? convertToDisplayString : undefined; const emptyLaterKeys = new Set(data.filter((item) => isEmptyPeriodAfterToday(item, groupBy, today, dateFilterRange.start)).map((item) => item.keyForList)); - const comparedKeysWithExpenses = new Set(comparison && !isChartAverage(comparison) ? comparison.data.filter((item) => !!item.count).map((item) => item.keyForList) : []); const {data: chartData, comparison: chartComparison} = shouldShowRunningTotal ? applyRunningTotal({data, comparison, groupBy}) : {data, comparison}; const {series, rows} = buildChartSeries({ @@ -154,7 +153,6 @@ function SearchChartView({ return !!range && isPeriodAfterToday(range.start, today, dateFilterRange.start); }, isEmptyLaterPeriod: (item) => emptyLaterKeys.has(item.keyForList), - hasComparedExpenses: (item) => comparedKeysWithExpenses.has(item.keyForList), translate, dateFnsLocale, colorScheme: theme.colorScheme, diff --git a/src/components/Search/buildChartSeries.ts b/src/components/Search/buildChartSeries.ts index 9c8a75ba0643..863fe22af6e0 100644 --- a/src/components/Search/buildChartSeries.ts +++ b/src/components/Search/buildChartSeries.ts @@ -98,9 +98,6 @@ type BuildChartSeriesParams = { /** Returns whether a group's period starts after today with no expenses of its own, which a running total's count no longer shows */ isEmptyLaterPeriod?: (item: GroupedItem) => boolean; - /** Returns whether a compared group has expenses of its own, which a running total's count no longer shows. Reads the group's count by default. */ - hasComparedExpenses?: (item: GroupedItem) => boolean; - /** Formats labels shared by paired time buckets, and the name of an average */ translate: LocaleContextProps['translate']; @@ -149,7 +146,6 @@ function buildChartSeries({ getInProgressLabel, isLaterPeriod, isEmptyLaterPeriod, - hasComparedExpenses = (item) => !!item.count, translate, dateFnsLocale, colorScheme, @@ -195,7 +191,6 @@ function buildChartSeries({ value: compared && getValue(compared), percentOfTotal: compared?.percentOfTotal, total: compared && getTotal(compared), - hasExpenses: !!comparedItem && hasComparedExpenses(comparedItem), bucketLabels: getComparedBucketLabels(bucketRange, counterpartRange, periods, groupBy, translate, dateFnsLocale), counterpartStart: counterpartRange?.start, sortDate: bucketRange.start, @@ -241,7 +236,6 @@ function buildChartSeries({ return { row: {point, item}, isEmptyLaterPeriod: !!isEmptyLaterPeriod?.(item), - hasComparedExpenses: !!compared?.hasExpenses, counterpartStart: compared?.counterpartStart, sortDate: compared?.sortDate ?? '', }; @@ -260,7 +254,7 @@ function buildChartSeries({ isInProgress: false, }; const row: SearchChartDataRow = {point}; - return {row, isEmptyLaterPeriod: false, hasComparedExpenses: hasComparedExpenses(comparedItem), sortDate: getPositionInPrimaryPeriod(range, comparisonPeriods, timeGroupBy)}; + return {row, isEmptyLaterPeriod: false, sortDate: getPositionInPrimaryPeriod(range, comparisonPeriods, timeGroupBy)}; }; const pairedStarts = new Set(pairedRows.map((pairedRow) => pairedRow.counterpartStart)); @@ -280,14 +274,12 @@ function buildChartSeries({ return first.sortDate < second.sortDate ? -1 : 1; }); - // Ends the current line at today, or at the last future expense, instead of drawing $0 through periods that haven't come yet. + // Ends the current line at today instead of drawing $0 through periods that haven't come yet. const lastCurrentIndex = sortedRows.findLastIndex((sortedRow) => !!sortedRow.row.item && !sortedRow.isEmptyLaterPeriod); const plottedRows = sortedRows.map(({row, isEmptyLaterPeriod: isEmpty}, index) => index > lastCurrentIndex && isEmpty ? {...row, point: {...row.point, values: {...row.point.values, [CHART_SERIES_KEY.PRIMARY]: undefined}}} : row, ); - // The chart goes on through the compared period's last expense - const lastPlottedIndex = sortedRows.findLastIndex((sortedRow, index) => index <= lastCurrentIndex || sortedRow.hasComparedExpenses); - const rows = plottedRows.slice(0, lastPlottedIndex + 1); + const rows = comparedRows ? plottedRows : plottedRows.slice(0, lastCurrentIndex + 1); const pieColors = view === CONST.SEARCH.VIEW.PIE ? getSliceColorsByDataIndex(rows.map((row) => row.point)) : undefined; const primarySeries: ChartSeries = {key: CHART_SERIES_KEY.PRIMARY, label: periods && formatDates(periods.primary, dateFnsLocale)}; diff --git a/src/languages/en.ts b/src/languages/en.ts index aee6f5b227fb..bf658d787d1c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1372,6 +1372,7 @@ const translations = { violationsBySubmitter: 'Violations by submitter', violationsByApprover: 'Violations by approver', runningTotal: 'Running total', + futureExpensesNotShown: (amount: string) => `Not shown: ${amount} in future expenses.`, compare: { label: 'Compare', previousPeriod: 'Previous period', diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 70198d7b10f9..39d7e7404eec 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -30,7 +30,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {AIInsight, InsightsDashboard as InsightsDashboardRecord, InsightsDashboardID, InsightsScope} from '@src/types/onyx'; +import type {AIInsight, InsightsDashboard as InsightsDashboardRecord, InsightsDashboardID, InsightsFutureExpenses, InsightsScope} from '@src/types/onyx'; import viewRef from '@src/types/utils/viewRef'; import type {OnyxCollection} from 'react-native-onyx'; @@ -47,6 +47,7 @@ import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDash import InsightsAICardRow from './ai/InsightsAICardRow'; import InsightsChartWidget from './charts/InsightsChartWidget'; +import InsightsFutureExpensesNote from './charts/InsightsFutureExpensesNote'; import InsightsPageControls from './controls/InsightsPageControls'; import INSIGHTS_DASHBOARD_SPECS, {getPoliciesInScope, getUpgradeablePolicyIDsInScope, getVisibleCharts, shouldUpsellControlPlan} from './dashboardSpecs'; import {getQueryGroupBy} from './insightsGroupByUtils'; @@ -67,6 +68,12 @@ type InsightsDashboardContentProps = { isLoadingAIInsights: boolean; headlineChart: InsightsDashboardChart; + /** Last day the charts count expenses through, as the backend resolved today */ + asOfDate: string | undefined; + + /** Expenses in the date range dated after `asOfDate`, which every chart leaves out */ + futureExpenses: InsightsFutureExpenses | undefined; + /** Charts in the grid below the headline, left out when no workspace in scope is eligible for them */ supportingCharts: InsightsDashboardChart[]; @@ -96,6 +103,8 @@ function InsightsDashboardContent({ insightsScope, isLoadingAIInsights, headlineChart, + asOfDate, + futureExpenses, supportingCharts, filters, upgradeablePolicyIDs, @@ -252,7 +261,15 @@ function InsightsDashboardContent({ insightsScope={insightsScope} onRetry={onRetry} onGroupByChange={onGroupByChange} - /> + > + {!!asOfDate && !!futureExpenses?.count && ( + + )} + {columns.map((columnCharts, columnIndex) => ( ) => void; + + /** Shown below the plot once the chart has loaded, including when it has nothing to plot */ + children?: React.ReactNode; }; function InsightsChartWidget({ @@ -94,6 +97,7 @@ function InsightsChartWidget({ insightsScope, onRetry, onGroupByChange, + children, }: InsightsChartWidgetProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -240,6 +244,7 @@ function InsightsChartWidget({ )} + {(state === INSIGHTS_CHART_STATE.READY || state === INSIGHTS_CHART_STATE.EMPTY) && children} ); } diff --git a/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx b/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx new file mode 100644 index 000000000000..68cfded0e7be --- /dev/null +++ b/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx @@ -0,0 +1,60 @@ +/** Note on Spend over time saying how much spend dated after today the dashboard leaves out, with a link to those expenses on the Spend page. */ +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLayoutSpacing from '@hooks/useLayoutSpacing'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; + +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {buildFutureExpensesQueryString} from '@pages/Insights/insightsQueries'; + +import ROUTES from '@src/ROUTES'; +import type {InsightsFutureExpenses} from '@src/types/onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +type InsightsFutureExpensesNoteProps = { + /** Expenses in the date range dated after `asOfDate`, which the charts leave out */ + futureExpenses: InsightsFutureExpenses; + + /** Last day the charts count expenses through */ + asOfDate: string; + + /** Page-level filters, whose date range and workspaces the Spend page is opened with */ + filters: InsightsFilters; +}; + +function InsightsFutureExpensesNote({futureExpenses, asOfDate, filters}: InsightsFutureExpensesNoteProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); + const {convertToDisplayString} = useCurrencyListActions(); + + const openFutureExpenses = () => { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildFutureExpensesQueryString(filters, asOfDate)})); + }; + + return ( + + + {translate('insightsPage.futureExpensesNotShown', convertToDisplayString(futureExpenses.total, futureExpenses.currency))}{' '} + + {translate('common.view')} + + + + ); +} + +export default InsightsFutureExpensesNote; diff --git a/src/pages/Insights/details/InsightsDetailsPage.tsx b/src/pages/Insights/details/InsightsDetailsPage.tsx index 43d26deb22f4..335daa662d05 100644 --- a/src/pages/Insights/details/InsightsDetailsPage.tsx +++ b/src/pages/Insights/details/InsightsDetailsPage.tsx @@ -61,7 +61,8 @@ function InsightsDetailsPage({route}: InsightsDetailsPageProps) { translate, }) : undefined; - const drillDownQuery = chartQueryJSON && item ? buildChartDrillDownQuery(chartQueryJSON, item.filterQuery) : undefined; + const queryJSONThroughToday = chartSpec && isResolved ? buildSearchQueryJSON(applyInsightsFilters(chartSpec, filters, undefined, true)) : undefined; + const drillDownQuery = queryJSONThroughToday && item ? buildChartDrillDownQuery(queryJSONThroughToday, item.filterQuery) : undefined; if (!isBetaEnabled(CONST.BETAS.INSIGHTS_AI) || !chartSpec || (isResolved && !item)) { return ( diff --git a/src/pages/Insights/getInsightsDateRange.ts b/src/pages/Insights/getInsightsDateRange.ts new file mode 100644 index 000000000000..eec19ec7a195 --- /dev/null +++ b/src/pages/Insights/getInsightsDateRange.ts @@ -0,0 +1,16 @@ +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import type {InsightsFilters} from './insightsFilters'; + +/** Returns the first and last day the Date filter covers, as yyyy-MM-dd strings. Presets like "never" have no fixed range, so both come back empty. */ +function getInsightsDateRange(date: InsightsFilters['date']): {start: string; end: string} { + if ('preset' in date) { + return getDateRangeForPreset(date.preset); + } + if ('on' in date) { + return {start: date.on, end: date.on}; + } + return {start: date.from, end: date.to}; +} + +export default getInsightsDateRange; diff --git a/src/pages/Insights/insightsCompare.ts b/src/pages/Insights/insightsCompare.ts index 0bd1cd843475..e84f106be922 100644 --- a/src/pages/Insights/insightsCompare.ts +++ b/src/pages/Insights/insightsCompare.ts @@ -1,13 +1,13 @@ import type {ChartComparisonPeriods, ChartPeriod} from '@components/Search/comparisonBuckets'; -import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; - import CONST from '@src/CONST'; import {differenceInCalendarDays, differenceInCalendarMonths, format, parseISO, subDays, subMonths} from 'date-fns'; import type {InsightsFilters} from './insightsFilters'; +import getInsightsDateRange from './getInsightsDateRange'; + /** Presets that span a fixed number of days rather than whole months */ const DAY_LENGTH_PRESETS = new Set([ CONST.SEARCH.DATE_PRESETS.THIS_WEEK, @@ -26,12 +26,7 @@ function formatPeriod(start: Date, end: Date): ChartPeriod { /** Resolves the period the Date filter covers and the one before it, which decide how buckets pair up and what each series is named. 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}; - } + const primary = getInsightsDateRange(dateFilter); if (!primary.start || !primary.end) { return undefined; } diff --git a/src/pages/Insights/insightsGroupByUtils.ts b/src/pages/Insights/insightsGroupByUtils.ts index 940ebb2c1c1d..ef31749f5d84 100644 --- a/src/pages/Insights/insightsGroupByUtils.ts +++ b/src/pages/Insights/insightsGroupByUtils.ts @@ -1,4 +1,4 @@ -import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; +import type {SearchDatePreset} from '@components/Search/types'; import CONST from '@src/CONST'; @@ -8,6 +8,7 @@ import {addDays, differenceInCalendarDays, differenceInMonths, parseISO} from 'd import type {InsightsFilters} from './insightsFilters'; +import getInsightsDateRange from './getInsightsDateRange'; import {INSIGHTS_GROUP_BY_OPTIONS, INSIGHTS_RUNNING_TOTAL_GROUP_BY} from './insightsFilters'; type InsightsGroupBy = InsightsFilters['groupBy']; @@ -23,13 +24,26 @@ const MIN_MONTHS_BY_GROUP_BY = { [CONST.SEARCH.GROUP_BY.YEAR]: 24, } as const; +const CURRENT_PERIOD_GROUP_BY_OPTIONS: Partial> = { + [CONST.SEARCH.DATE_PRESETS.THIS_WEEK]: [CONST.SEARCH.GROUP_BY.DAY], + [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: [CONST.SEARCH.GROUP_BY.DAY], + [CONST.SEARCH.DATE_PRESETS.THIS_QUARTER]: [CONST.SEARCH.GROUP_BY.WEEK], + [CONST.SEARCH.DATE_PRESETS.THIS_YEAR]: [CONST.SEARCH.GROUP_BY.WEEK, CONST.SEARCH.GROUP_BY.MONTH], + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: [CONST.SEARCH.GROUP_BY.WEEK, CONST.SEARCH.GROUP_BY.MONTH], +}; + /** Returns the Group by options that fit the date range, in the dropdown's order */ function getInsightsGroupByOptions(date: InsightsFilters['date']): InsightsTimeGroupBy[] { if ('on' in date) { return [CONST.SEARCH.GROUP_BY.DAY]; } - const boundaries = 'from' in date ? {start: date.from, end: date.to} : getDateRangeForPreset(date.preset); + const currentPeriodOptions = 'preset' in date ? CURRENT_PERIOD_GROUP_BY_OPTIONS[date.preset] : undefined; + if (currentPeriodOptions) { + return currentPeriodOptions; + } + + const boundaries = getInsightsDateRange(date); // Presets like "never" have no fixed range, so there's no length to fit a grouping to if (!boundaries.start || !boundaries.end) { @@ -75,9 +89,16 @@ function isRunningTotalAvailable(date: InsightsFilters['date']): boolean { return getInsightsGroupByOptions(date).length > 0; } -/** Returns the grouping a running total is plotted at: by day for ranges that offer Day (62 days or less), by month otherwise. */ +/** Returns the grouping a running total is plotted at: by day for ranges that offer Day (62 days or less), by month otherwise, or by the finest offered grouping when Month isn't offered either, like Week for This quarter. */ function getRunningTotalGroupBy(date: InsightsFilters['date']): InsightsTimeGroupBy { - return getInsightsGroupByOptions(date).includes(CONST.SEARCH.GROUP_BY.DAY) ? CONST.SEARCH.GROUP_BY.DAY : CONST.SEARCH.GROUP_BY.MONTH; + const options = getInsightsGroupByOptions(date); + if (options.includes(CONST.SEARCH.GROUP_BY.DAY)) { + return CONST.SEARCH.GROUP_BY.DAY; + } + if (options.includes(CONST.SEARCH.GROUP_BY.MONTH)) { + return CONST.SEARCH.GROUP_BY.MONTH; + } + return options.at(0) ?? CONST.SEARCH.GROUP_BY.MONTH; } /** Returns the real time bucket the charts are queried with, fitted to the date range. Running total queries a real day/month bucket, so it never reaches the backend as the Running total value. */ diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index 2a7147d97769..964391df7556 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -7,10 +7,13 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {InsightsDashboard, InsightsDashboardID, InsightsGraphKey} from '@src/types/onyx'; +import {addDays, format, parseISO} from 'date-fns'; + import type {InsightsChartSpec, InsightsDateFilterKey} from './dashboardSpecs'; import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS, {VIOLATION_HAS_VALUES} from './dashboardSpecs'; +import getInsightsDateRange from './getInsightsDateRange'; import {getQueryGroupBy} from './insightsGroupByUtils'; /** How many periods before the selected date range the Average mode spans */ @@ -38,6 +41,21 @@ function buildDateFormValues(date: InsightsFilters['date'], dateFilterKey: Insig return {[range]: getRangeQueryValue(date.from, date.to)}; } +/** Returns the day after a date, which a strict `date<` filter takes to keep the date itself in */ +function getDayAfter(date: Date): string { + return format(addDays(date, 1), CONST.DATE.FNS_FORMAT_STRING); +} + +/** Builds the filter that leaves out expenses dated after today, or nothing when the date range already ends by today. */ +function buildStopAtTodayFormValues(date: InsightsFilters['date']): Partial { + const now = new Date(); + const {end} = getInsightsDateRange(date); + if (!end || end <= format(now, CONST.DATE.FNS_FORMAT_STRING)) { + return {}; + } + return {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(now)}; +} + /** Builds the page's filters in the shape a search query is built from. Leaves the workspaces out when none are selected, which reports on all of them. */ function buildFilterFormValues(filters: InsightsFilters, shouldIncludeDate = true, dateFilterKey?: InsightsDateFilterKey): Partial { return { @@ -81,14 +99,33 @@ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { return buildQueryStringFromFilterFormValues({...buildFilterFormValues(filters), groupBy: filters.groupBy as SearchGroupBy, compare: filters.compare}); } -/** Builds a chart's query with the page's filters applied. */ -function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode): SearchQueryString { +/** Builds a chart's query with the page's filters applied. `shouldStopAtToday` leaves out expenses dated after today, for charts on the expense date whose query goes to Search instead of GetInsights. */ +function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode, shouldStopAtToday = false): SearchQueryString { + const isExpenseDateChart = !chart.dateFilterKey || chart.dateFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE; return buildQueryStringFromFilterFormValues( - {...buildFilterFormValues(filters, true, chart.dateFilterKey), has: chart.has, groupBy: chart.groupBy ?? getQueryGroupBy(filters), view: chart.view, compare}, + { + ...buildFilterFormValues(filters, true, chart.dateFilterKey), + ...(shouldStopAtToday && isExpenseDateChart && buildStopAtTodayFormValues(filters.date)), + has: chart.has, + groupBy: chart.groupBy ?? getQueryGroupBy(filters), + view: chart.view, + compare, + }, {sortBy: chart.sortBy, sortOrder: chart.sortOrder, limit: chart.limit}, ); } +/** Builds the Spend page query for the expenses the dashboard leaves out: those dated after `asOfDate` through the end of the date range, in the dashboard's workspaces. */ +function buildFutureExpensesQueryString(filters: InsightsFilters, asOfDate: string): SearchQueryString { + const {end} = getInsightsDateRange(filters.date); + return buildQueryStringFromFilterFormValues({ + ...buildFilterFormValues(filters, false), + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + [FILTER_KEYS.DATE_AFTER]: asOfDate, + ...(!!end && {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(parseISO(end))}), + }); +} + type InsightsChartQuery = { chart: InsightsChartSpec; queryJSON: Readonly | undefined; @@ -178,6 +215,6 @@ function buildTopExpensesQueryJSON(query: SearchQueryString): Readonly>; + /** Last day the graphs count expenses through, as a yyyy-MM-dd string */ + asOfDate?: string; + + /** Expenses in the date range dated after `asOfDate` */ + futureExpenses?: InsightsFutureExpenses; + /** Whether the account has any expenses at all, regardless of the query, so an empty account can be told apart from filters that matched nothing */ hasResults?: boolean; @@ -60,5 +78,5 @@ type InsightsDashboard = { responseJsonCode?: number; }; -export type {AIInsight, InsightsDashboardID, InsightsGraphKey, InsightsSearchKey}; +export type {AIInsight, InsightsDashboardID, InsightsFutureExpenses, InsightsGraphKey, InsightsSearchKey}; export default InsightsDashboard; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 05d588770929..4c4dcad0df2b 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -127,7 +127,7 @@ import type ImportedSpreadsheet from './ImportedSpreadsheet'; import type ImportedSpreadsheetMemberData from './ImportedSpreadsheetMemberData'; import type InitiatingBankAccountUnlock from './InitiatingBankAccountUnlock'; import type InsightsDashboard from './InsightsDashboard'; -import type {AIInsight, InsightsDashboardID, InsightsGraphKey, InsightsSearchKey} from './InsightsDashboard'; +import type {AIInsight, InsightsDashboardID, InsightsFutureExpenses, InsightsGraphKey, InsightsSearchKey} from './InsightsDashboard'; import type IntroSelected from './IntroSelected'; import type InvitedEmailsToAccountIDs from './InvitedEmailsToAccountIDs'; import type JoinablePolicies from './JoinablePolicies'; @@ -326,6 +326,7 @@ export type { AIInsight, InsightsDashboard, InsightsDashboardID, + InsightsFutureExpenses, InsightsGraphKey, InsightsSearchKey, IntroSelected, diff --git a/tests/unit/buildChartSeriesTest.ts b/tests/unit/buildChartSeriesTest.ts index df7b5b18692e..85bb062edfca 100644 --- a/tests/unit/buildChartSeriesTest.ts +++ b/tests/unit/buildChartSeriesTest.ts @@ -610,7 +610,7 @@ describe('buildChartSeries', () => { expect(model.rows.map((row) => row.point.values)).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 3000, [CHART_SERIES_KEY.COMPARISON]: 300}, {[CHART_SERIES_KEY.COMPARISON]: 400}]); }); - it('ends the compared line at the last period the compared period has expenses in', () => { + it('keeps the compared line through the whole date range when the compared period has no expenses at its end', () => { // Given this year in its second quarter with nothing dated later, against last year, which had expenses only until Q3 const rows = [quarterGroup(2, 200000), {...quarterGroup(3, 0), count: 0}, {...quarterGroup(4, 0), count: 0}]; const previousRows = [quarterGroup(2, 20000, 2025), quarterGroup(3, 30000, 2025), {...quarterGroup(4, 0, 2025), count: 0}]; @@ -629,16 +629,19 @@ describe('buildChartSeries', () => { isEmptyLaterPeriod: (item) => item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER && item.quarter > 2 && !item.count, }); - // Then the chart ends at Q3, last year's last quarter with expenses, rather than drawing its empty Q4 - expect(model.rows.map((row) => row.point.values)).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, {[CHART_SERIES_KEY.COMPARISON]: 300}]); + // Then last year's line runs through Q4 so the date range stays the same as it fills in, while this year's line stops at Q2 + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, + {[CHART_SERIES_KEY.COMPARISON]: 300}, + {[CHART_SERIES_KEY.COMPARISON]: 0}, + ]); }); - it('ends the compared running total at the last period the compared period has expenses in', () => { + it('keeps the compared running total through the whole date range when the compared period has no expenses at its end', () => { // Given the same two years plotted as running totals, where last year's empty Q4 carries its earlier total and count forward const rows = [quarterGroup(2, 200000), {...quarterGroup(3, 0), count: 0}, {...quarterGroup(4, 0), count: 0}]; const previousRows = [quarterGroup(2, 20000, 2025), quarterGroup(3, 30000, 2025), {...quarterGroup(4, 0, 2025), count: 0}]; const emptyLaterKeys = new Set(rows.filter((item) => !item.count).map((item) => item.keyForList)); - const comparedKeysWithExpenses = new Set(previousRows.filter((item) => !!item.count).map((item) => item.keyForList)); const runningTotal = applyRunningTotal({ data: rows, comparison: {data: previousRows, color: themeColors.green200, periods: {primary: {start: '2026-01-01', end: '2026-12-31'}, comparison: {start: '2025-01-01', end: '2025-12-31'}}}, @@ -657,14 +660,17 @@ describe('buildChartSeries', () => { dateFnsLocale: undefined, colorScheme: CONST.COLOR_SCHEME.LIGHT, isEmptyLaterPeriod: (item) => emptyLaterKeys.has(item.keyForList), - hasComparedExpenses: (item) => comparedKeysWithExpenses.has(item.keyForList), }); - // Then the compared line still ends at Q3, rather than staying flat through a quarter with no expenses - expect(model.rows.map((row) => row.point.values)).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, {[CHART_SERIES_KEY.COMPARISON]: 500}]); + // Then last year's running total stays flat through its empty Q4, while this year's line stops at Q2 + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, + {[CHART_SERIES_KEY.COMPARISON]: 500}, + {[CHART_SERIES_KEY.COMPARISON]: 500}, + ]); }); - it('keeps the compared days the current period has no day for when the compared period has expenses on them', () => { + it('keeps the compared days the current period has no day for', () => { // Given February so far plotted by day against January, with February's later days empty and January's 29th to 31st unpaired const {rows} = buildChartSeries({ data: [dayGroup('2026-02-15'), {...dayGroup('2026-02-16'), total: 0, count: 0}, {...dayGroup('2026-02-28'), total: 0, count: 0}], diff --git a/tests/unit/getInsightsDetailsPeriodTest.ts b/tests/unit/getInsightsDetailsPeriodTest.ts index 6d96e1cea7eb..3adfd582c50c 100644 --- a/tests/unit/getInsightsDetailsPeriodTest.ts +++ b/tests/unit/getInsightsDetailsPeriodTest.ts @@ -65,8 +65,8 @@ describe('getInsightsDetailsPeriod', () => { }); it('uses the drawn Group by on Spend over time, even for a preset with a period of its own', () => { - // Given This month drawn by week on Spend over time - const filters: InsightsFilters = {...BASE_FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}, groupBy: CONST.SEARCH.GROUP_BY.WEEK}; + // Given Last month drawn by week on Spend over time + const filters: InsightsFilters = {...BASE_FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, groupBy: CONST.SEARCH.GROUP_BY.WEEK}; // When a point on Spend over time opens the Details panel const period = getInsightsDetailsPeriod(filters, CONST.INSIGHTS.GRAPH.SPEND_OVER_TIME); diff --git a/tests/unit/insightsGroupByUtilsTest.ts b/tests/unit/insightsGroupByUtilsTest.ts index c2b22cea2b04..e14555cfe283 100644 --- a/tests/unit/insightsGroupByUtilsTest.ts +++ b/tests/unit/insightsGroupByUtilsTest.ts @@ -19,9 +19,9 @@ describe('insightsGroupByUtils', () => { describe('getInsightsGroupByOptions', () => { it.each<[string, InsightsFilters['date'], Array]>([ - ['this month', {preset: THIS_MONTH}, [DAY, WEEK]], + ['this month', {preset: THIS_MONTH}, [DAY]], ['last month', {preset: LAST_MONTH}, [DAY, WEEK]], - ['this year', {preset: THIS_YEAR}, [WEEK, MONTH, QUARTER]], + ['this year', {preset: THIS_YEAR}, [WEEK, MONTH]], ['last 12 months', {preset: LAST_12_MONTHS}, [WEEK, MONTH, QUARTER]], ['a range under two weeks', {from: '2026-03-01', to: '2026-03-10'}, [DAY]], ['a range of 13 days', {from: '2026-03-01', to: '2026-03-13'}, [DAY]], @@ -48,8 +48,8 @@ describe('insightsGroupByUtils', () => { // When the Group by options are listed for year to date const options = getInsightsGroupByOptions({preset: YEAR_TO_DATE}); - // Then they fit the whole year, not just the few weeks so far - expect(options).toEqual([WEEK, MONTH, QUARTER]); + // Then they fit the whole year, not just the few weeks so far, leaving out Quarter like this year does + expect(options).toEqual([WEEK, MONTH]); }); }); @@ -65,10 +65,10 @@ describe('insightsGroupByUtils', () => { }); it('switches to the nearest offered option when the range rules the selection out', () => { - // Given a quarterly chart moved to this month, which only offers Day and Week + // Given a quarterly chart moved to last month, which only offers Day and Week // When the Group by is fitted to the range - const groupBy = getFittingGroupBy(QUARTER, getInsightsGroupByOptions({preset: THIS_MONTH})); + const groupBy = getFittingGroupBy(QUARTER, getInsightsGroupByOptions({preset: LAST_MONTH})); // Then Week is picked, the closest grouping to Quarter that the month offers expect(groupBy).toBe(WEEK); @@ -97,8 +97,8 @@ describe('insightsGroupByUtils', () => { describe('getQueryGroupBy', () => { it('queries the nearest offered option when the date range rules the selection out', () => { - // Given a quarterly dashboard moved to this month, which only offers Day and Week - const filters: InsightsFilters = {date: {preset: THIS_MONTH}, policyIDs: [], groupBy: QUARTER, groupCurrency: 'USD'}; + // Given a quarterly dashboard moved to last month, which only offers Day and Week + const filters: InsightsFilters = {date: {preset: LAST_MONTH}, policyIDs: [], groupBy: QUARTER, groupCurrency: 'USD'}; // When the Group by to query with is picked const groupBy = getQueryGroupBy(filters); diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 1c82a736fe46..7c1c4d9a4a76 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -229,12 +229,12 @@ describe('insightsQueries', () => { // Given the spend dashboard grouped by month const monthly = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, FILTERS, SPEND_SPEC.supportingCharts); - // When the same dashboard is grouped by quarter instead - const quarterly = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, {...FILTERS, groupBy: CONST.SEARCH.GROUP_BY.QUARTER}, SPEND_SPEC.supportingCharts); + // When the same dashboard is grouped by week instead + const weekly = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, {...FILTERS, groupBy: CONST.SEARCH.GROUP_BY.WEEK}, SPEND_SPEC.supportingCharts); // Then each request carries the hash of the query it asks for, so the two responses are stored apart expect(monthly?.hash).toBe(parsePayload(monthly)?.hash); - expect(quarterly?.hash).not.toBe(monthly?.hash); + expect(weekly?.hash).not.toBe(monthly?.hash); }); it('asks for nothing the page filters did not set', () => { @@ -351,11 +351,11 @@ describe('insightsQueries', () => { const {headlineChart} = SPEND_SPEC; expect(headlineChart.groupBy).toBeUndefined(); - // When the page filters group by quarter - const chartQuery = applyInsightsFilters(headlineChart, {...FILTERS, groupBy: CONST.SEARCH.GROUP_BY.QUARTER}); + // When the page filters group by week + const chartQuery = applyInsightsFilters(headlineChart, {...FILTERS, groupBy: CONST.SEARCH.GROUP_BY.WEEK}); - // Then the chart is grouped by quarter - expect(buildSearchQueryJSON(chartQuery)?.groupBy).toBe(CONST.SEARCH.GROUP_BY.QUARTER); + // Then the chart is grouped by week + expect(buildSearchQueryJSON(chartQuery)?.groupBy).toBe(CONST.SEARCH.GROUP_BY.WEEK); }); it('groups the headline chart by day while supporting charts keep their own group-by', () => { From 520d8d2df15409e7dc82aa87e92bca7972ff54cd Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 10 Nov 2026 17:29:02 +0100 Subject: [PATCH 02/10] Stop plotting violations before tracking started --- src/CONST/index.ts | 2 ++ src/components/Search/SearchChartView.tsx | 5 +++ src/components/Search/buildChartSeries.ts | 7 ++++- src/languages/en.ts | 1 + .../Insights/charts/InsightsChartNote.tsx | 31 +++++++++++++++++++ .../Insights/charts/InsightsChartWidget.tsx | 10 +++++- .../charts/InsightsFutureExpensesNote.tsx | 27 ++++++---------- .../charts/InsightsTrackedFromNote.tsx | 26 ++++++++++++++++ src/pages/Insights/dashboardSpecs.ts | 4 +++ src/pages/Insights/insightsTrackedFrom.ts | 29 +++++++++++++++++ src/pages/Insights/useInsightsChartData.ts | 16 ++++++++-- 11 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 src/pages/Insights/charts/InsightsChartNote.tsx create mode 100644 src/pages/Insights/charts/InsightsTrackedFromNote.tsx create mode 100644 src/pages/Insights/insightsTrackedFrom.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fc62fe9d0d95..1b1c002509be 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10449,6 +10449,8 @@ const CONST = { VIOLATIONS_BY_SUBMITTER: 'violationsBySubmitter', VIOLATIONS_BY_APPROVER: 'violationsByApprover', }, + // The backend started recording violations on this day, so it has no violation data for earlier dates + VIOLATIONS_TRACKED_FROM: '2026-06-01', }, SECTION_LIST_ITEM_TYPE: { diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 34f723d9b06b..bdb2cf41fd00 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -87,6 +87,9 @@ type SearchChartViewProps = { /** Style of the view around the chart, which the details below it don't share */ chartContainerStyle?: StyleProp; + + /** First day the data was recorded, as a yyyy-MM-dd string, so compared buckets before it plot nothing instead of 0 */ + trackedFrom?: string; }; /** @@ -110,6 +113,7 @@ function SearchChartView({ shouldShowRunningTotal = false, renderDetails, chartContainerStyle, + trackedFrom, }: SearchChartViewProps) { const theme = useTheme(); const {preferredLocale, translate, dateFnsLocale, numberFormat} = useLocalize(); @@ -153,6 +157,7 @@ function SearchChartView({ return !!range && isPeriodAfterToday(range.start, today, dateFilterRange.start); }, isEmptyLaterPeriod: (item) => emptyLaterKeys.has(item.keyForList), + trackedFrom, translate, dateFnsLocale, colorScheme: theme.colorScheme, diff --git a/src/components/Search/buildChartSeries.ts b/src/components/Search/buildChartSeries.ts index 863fe22af6e0..f0e29463afce 100644 --- a/src/components/Search/buildChartSeries.ts +++ b/src/components/Search/buildChartSeries.ts @@ -98,6 +98,9 @@ type BuildChartSeriesParams = { /** Returns whether a group's period starts after today with no expenses of its own, which a running total's count no longer shows */ isEmptyLaterPeriod?: (item: GroupedItem) => boolean; + /** First day the data was recorded, as a yyyy-MM-dd string. A compared bucket that ends before it has no data, so it plots nothing instead of 0. */ + trackedFrom?: string; + /** Formats labels shared by paired time buckets, and the name of an average */ translate: LocaleContextProps['translate']; @@ -146,6 +149,7 @@ function buildChartSeries({ getInProgressLabel, isLaterPeriod, isEmptyLaterPeriod, + trackedFrom, translate, dateFnsLocale, colorScheme, @@ -186,7 +190,8 @@ function buildChartSeries({ if (periods && isTimeGroupBy(groupBy) && bucketRange) { const counterpartRange = getCounterpartBucketRange(bucketRange, periods, groupBy); const comparedItem = counterpartRange && comparedByKey.get(counterpartRange.start); - const compared = comparedItem ?? (counterpartRange && buildEmptyGroup(item)); + const isCounterpartTracked = !!counterpartRange && (!trackedFrom || counterpartRange.end >= trackedFrom); + const compared = comparedItem ?? (isCounterpartTracked ? buildEmptyGroup(item) : undefined); return { value: compared && getValue(compared), percentOfTotal: compared?.percentOfTotal, diff --git a/src/languages/en.ts b/src/languages/en.ts index bf658d787d1c..5382ba7a4347 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1373,6 +1373,7 @@ const translations = { violationsByApprover: 'Violations by approver', runningTotal: 'Running total', futureExpensesNotShown: (amount: string) => `Not shown: ${amount} in future expenses.`, + violationsTrackedFrom: (date: string) => `Violations are tracked from ${date}.`, compare: { label: 'Compare', previousPeriod: 'Previous period', diff --git a/src/pages/Insights/charts/InsightsChartNote.tsx b/src/pages/Insights/charts/InsightsChartNote.tsx new file mode 100644 index 000000000000..d842d2472b8f --- /dev/null +++ b/src/pages/Insights/charts/InsightsChartNote.tsx @@ -0,0 +1,31 @@ +/** Small secondary-text note shown under an Insights chart, like what the chart leaves out or where its data starts. */ +import Text from '@components/Text'; + +import useLayoutSpacing from '@hooks/useLayoutSpacing'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +type InsightsChartNoteProps = { + /** The note's text, which can include inline links */ + children: React.ReactNode; +}; + +function InsightsChartNote({children}: InsightsChartNoteProps) { + const styles = useThemeStyles(); + const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); + + return ( + + + {children} + + + ); +} + +export default InsightsChartNote; diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 78edc66335a4..745c9dcd523d 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -30,6 +30,7 @@ import {getInsightsDetailsItemParams} from '@pages/Insights/details/insightsDeta import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {INSIGHTS_RUNNING_TOTAL_GROUP_BY} from '@pages/Insights/insightsFilters'; import {getInsightsGroupByOptions, getQueryGroupBy, isRunningTotalAvailable} from '@pages/Insights/insightsGroupByUtils'; +import {doesRangeStartBeforeTracking} from '@pages/Insights/insightsTrackedFrom'; import useInsightsChartData from '@pages/Insights/useInsightsChartData'; import CONST from '@src/CONST'; @@ -44,6 +45,7 @@ import {View} from 'react-native'; import Animated, {FadeIn} from 'react-native-reanimated'; import InsightsDataTable from './InsightsDataTable'; +import InsightsTrackedFromNote from './InsightsTrackedFromNote'; type InsightsChartWidgetProps = { dashboardID: InsightsDashboardID; @@ -224,6 +226,7 @@ function InsightsChartWidget({ comparison={comparison} shouldShowRunningTotal={shouldShowRunningTotal} chartContainerStyle={cardPaddingHorizontal} + trackedFrom={chart.trackedFrom} shouldShowSinglePointAsBar shouldShowGroupLabels={false} onGroupPress={openDetails} @@ -244,7 +247,12 @@ function InsightsChartWidget({ )} - {(state === INSIGHTS_CHART_STATE.READY || state === INSIGHTS_CHART_STATE.EMPTY) && children} + {(state === INSIGHTS_CHART_STATE.READY || state === INSIGHTS_CHART_STATE.EMPTY) && ( + <> + {!!chart.trackedFrom && doesRangeStartBeforeTracking(filters, chart.trackedFrom) && } + {children} + + )} ); } diff --git a/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx b/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx index 68cfded0e7be..75be6ed6b1e6 100644 --- a/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx +++ b/src/pages/Insights/charts/InsightsFutureExpensesNote.tsx @@ -1,11 +1,8 @@ /** Note on Spend over time saying how much spend dated after today the dashboard leaves out, with a link to those expenses on the Spend page. */ -import Text from '@components/Text'; import TextLink from '@components/TextLink'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -16,7 +13,8 @@ import ROUTES from '@src/ROUTES'; import type {InsightsFutureExpenses} from '@src/types/onyx'; import React from 'react'; -import {View} from 'react-native'; + +import InsightsChartNote from './InsightsChartNote'; type InsightsFutureExpensesNoteProps = { /** Expenses in the date range dated after `asOfDate`, which the charts leave out */ @@ -30,9 +28,7 @@ type InsightsFutureExpensesNoteProps = { }; function InsightsFutureExpensesNote({futureExpenses, asOfDate, filters}: InsightsFutureExpensesNoteProps) { - const styles = useThemeStyles(); const {translate} = useLocalize(); - const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); const {convertToDisplayString} = useCurrencyListActions(); const openFutureExpenses = () => { @@ -40,20 +36,15 @@ function InsightsFutureExpensesNote({futureExpenses, asOfDate, filters}: Insight }; return ( - - + {translate('insightsPage.futureExpensesNotShown', convertToDisplayString(futureExpenses.total, futureExpenses.currency))}{' '} + - {translate('insightsPage.futureExpensesNotShown', convertToDisplayString(futureExpenses.total, futureExpenses.currency))}{' '} - - {translate('common.view')} - - - + {translate('common.view')} + + ); } diff --git a/src/pages/Insights/charts/InsightsTrackedFromNote.tsx b/src/pages/Insights/charts/InsightsTrackedFromNote.tsx new file mode 100644 index 000000000000..9079c6c6947c --- /dev/null +++ b/src/pages/Insights/charts/InsightsTrackedFromNote.tsx @@ -0,0 +1,26 @@ +/** Note on Violations over time saying the day violations started being recorded, since the chart leaves out the days before it. */ +import useLocalize from '@hooks/useLocalize'; + +import CONST from '@src/CONST'; + +import {format, parseISO} from 'date-fns'; +import React from 'react'; + +import InsightsChartNote from './InsightsChartNote'; + +type InsightsTrackedFromNoteProps = { + /** First day the chart has data for, as a yyyy-MM-dd string */ + trackedFrom: string; +}; + +function InsightsTrackedFromNote({trackedFrom}: InsightsTrackedFromNoteProps) { + const {translate, dateFnsLocale} = useLocalize(); + + return ( + + {translate('insightsPage.violationsTrackedFrom', format(parseISO(trackedFrom), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale}))} + + ); +} + +export default InsightsTrackedFromNote; diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 376a9607332e..561884029cf7 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -42,6 +42,9 @@ type InsightsChartSpec = { /** The chart is shown when any workspace in scope passes this. A chart that declares none is always shown. */ isPolicyEligible?: (policy: Policy, login: string | undefined) => boolean; + + /** First day the backend has data for the chart, as a yyyy-MM-dd string. Earlier time buckets aren't drawn, and a note says where the data starts. */ + trackedFrom?: string; }; /** Violations kept at submission or approval */ @@ -123,6 +126,7 @@ const INSIGHTS_DASHBOARD_SPECS: Record= trackedFrom; +} + +/** Whether the dates the chart covers, including the compared period, start before the first tracked day. A range with no start, like "never", reaches back past it. */ +function doesRangeStartBeforeTracking(filters: InsightsFilters, trackedFrom: string): boolean { + const {start} = getInsightsDateRange(filters.date); + if (!start || start < trackedFrom) { + return true; + } + if (filters.compare !== CONST.SEARCH.COMPARE.PREVIOUS_PERIOD) { + return false; + } + const comparisonStart = resolveComparisonPeriods(filters.date)?.comparison.start; + return !!comparisonStart && comparisonStart < trackedFrom; +} + +export {doesRangeStartBeforeTracking, isBucketTracked}; diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts index 8b2746f6bd93..1920a54af595 100644 --- a/src/pages/Insights/useInsightsChartData.ts +++ b/src/pages/Insights/useInsightsChartData.ts @@ -1,5 +1,6 @@ import VictoryTheme from '@components/Charts/VictoryTheme'; import type {ChartComparison} from '@components/Search/buildChartSeries'; +import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; import useGroupedItems from '@components/Search/hooks/useGroupedItems'; import type {GroupedItem} from '@components/Search/types'; @@ -16,6 +17,7 @@ import type {InsightsFilters} from './insightsFilters'; import type {InsightsDashboardChart} from './resolveDashboardState'; import resolveComparisonPeriods from './insightsCompare'; +import {isBucketTracked} from './insightsTrackedFrom'; import normalizeInsightsSnapshot from './normalizeInsightsSnapshot'; type InsightsChartState = ValueOf; @@ -39,8 +41,15 @@ function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: I const theme = useTheme(); const {isOffline} = useNetwork(); + const {trackedFrom} = chart; + const getDateRange = queryJSON?.groupBy ? CHART_GROUP_BY_CONFIG[queryJSON.groupBy].getDateRange : undefined; + const keepTrackedBuckets = (items: GroupedItem[]) => (trackedFrom ? items.filter((item) => isBucketTracked(getDateRange?.(item), trackedFrom)) : items); + const sortedData = useGroupedItems(normalizeInsightsSnapshot(snapshot, queryJSON?.groupBy), queryJSON); - const current = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); + const resolvedCurrent = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); + const trackedData = keepTrackedBuckets(resolvedCurrent.data); + const current: InsightsChartData = + resolvedCurrent.state === INSIGHTS_CHART_STATE.READY && trackedData.length === 0 ? {data: [], state: INSIGHTS_CHART_STATE.EMPTY} : {...resolvedCurrent, data: trackedData}; const compare = filters.compare; const isPreviousPeriod = compare === CONST.SEARCH.COMPARE.PREVIOUS_PERIOD; @@ -66,7 +75,8 @@ function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: I } case CONST.SEARCH.COMPARE.PREVIOUS_PERIOD: { const periods = resolveComparisonPeriods(filters.date); - if (!periods) { + // A compared period that ends before the chart's data starts has nothing to compare against + if (!periods || (!!trackedFrom && periods.comparison.end < trackedFrom)) { return current; } @@ -77,7 +87,7 @@ function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: I } const color = VictoryTheme.colors.getComparisonColor(VictoryTheme.colors.default, theme.colorScheme); - return {...current, comparison: {data: compared.data, color, periods}}; + return {...current, comparison: {data: keepTrackedBuckets(compared.data), color, periods}}; } default: return current; From 66bc2261f98dfd828909b0cf44800190ba944c45 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 17:45:10 +0100 Subject: [PATCH 03/10] Add more thorough unit tests --- tests/unit/buildChartSeriesTest.ts | 75 ++++++++++++++- tests/unit/getInsightsDateRangeTest.ts | 55 +++++++++++ tests/unit/insightsGroupByUtilsTest.ts | 48 +++++++++- tests/unit/insightsQueriesTest.ts | 94 ++++++++++++++++++- tests/unit/insightsTrackedFromTest.ts | 124 +++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 tests/unit/getInsightsDateRangeTest.ts create mode 100644 tests/unit/insightsTrackedFromTest.ts diff --git a/tests/unit/buildChartSeriesTest.ts b/tests/unit/buildChartSeriesTest.ts index cfccc347b30b..420721bc9d5e 100644 --- a/tests/unit/buildChartSeriesTest.ts +++ b/tests/unit/buildChartSeriesTest.ts @@ -920,8 +920,9 @@ describe('buildChartSeries with a compared period', () => { const PERIODS = {primary: {start: '2026-01-01', end: '2026-12-31'}, comparison: {start: '2025-01-01', end: '2025-12-31'}}; /** Builds the model of a chart plotting one period against the one before it. */ - function buildComparison(rows: GroupedItem[], previousRows: GroupedItem[], groupBy: SearchGroupBy) { + function buildComparison(rows: GroupedItem[], previousRows: GroupedItem[], groupBy: SearchGroupBy, trackedFrom?: string) { return buildChartSeries({ + trackedFrom, data: rows, comparison: {data: previousRows, color: themeColors.green200, periods: PERIODS}, view: CONST.SEARCH.VIEW.BAR, @@ -949,6 +950,78 @@ describe('buildChartSeries with a compared period', () => { {[CHART_SERIES_KEY.PRIMARY]: 3000, [CHART_SERIES_KEY.COMPARISON]: 300}, ]); }); + + it('plots nothing for a compared bucket that ends before the data was tracked', () => { + // Given this year against last year, where last year's data only starts on June 1 and has no row for Q1 + const rows = [quarterGroup(1, 100000), quarterGroup(2, 200000)]; + const previousRows = [quarterGroup(2, 20000, 2025)]; + + // When the two periods are plotted against each other + const model = buildComparison(rows, previousRows, CONST.SEARCH.GROUP_BY.QUARTER, '2025-06-01'); + + // Then last year's Q1 is left blank rather than drawn as 0, since nothing was recorded then + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 1000, [CHART_SERIES_KEY.COMPARISON]: undefined}, + {[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, + ]); + }); + + it('plots 0 for a compared bucket that runs into the first tracked day without a row', () => { + // Given this year against last year, where last year's data starts on June 1 and has no row for Q2, which ends after it + const rows = [quarterGroup(2, 200000), quarterGroup(3, 300000)]; + const previousRows = [quarterGroup(3, 30000, 2025)]; + + // When the two periods are plotted against each other + const model = buildComparison(rows, previousRows, CONST.SEARCH.GROUP_BY.QUARTER, '2025-06-01'); + + // Then last year's Q2 is drawn as 0, since its tracked days had no expenses + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 0}, + {[CHART_SERIES_KEY.PRIMARY]: 3000, [CHART_SERIES_KEY.COMPARISON]: 300}, + ]); + }); + + it('plots 0 for a compared bucket without a row when the data has no first tracked day', () => { + // Given this year against last year, where last year has no row for Q1 and every day was tracked + const rows = [quarterGroup(1, 100000), quarterGroup(2, 200000)]; + const previousRows = [quarterGroup(2, 20000, 2025)]; + + // When the two periods are plotted against each other + const model = buildComparison(rows, previousRows, CONST.SEARCH.GROUP_BY.QUARTER); + + // Then last year's Q1 is drawn as 0, since it had no expenses + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 1000, [CHART_SERIES_KEY.COMPARISON]: 0}, + {[CHART_SERIES_KEY.PRIMARY]: 2000, [CHART_SERIES_KEY.COMPARISON]: 200}, + ]); + }); +}); + +describe('buildChartSeries without a compared period', () => { + beforeAll(() => { + IntlStore.load(CONST.LOCALES.EN); + }); + + it('ends the chart at the last period so far, dropping the empty periods still to come', () => { + // Given this year in its second quarter, with Q3 and Q4 still to come and no expenses dated in them + const rows = [quarterGroup(1, 100000), quarterGroup(2, 200000), {...quarterGroup(3, 0), count: 0}, {...quarterGroup(4, 0), count: 0}]; + + // When the series is built with nothing to compare against + const model = buildChartSeries({ + data: rows, + view: CONST.SEARCH.VIEW.LINE, + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + getLabel: CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.QUARTER].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + isEmptyLaterPeriod: (item) => item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER && item.quarter > 2 && !item.count, + }); + + // Then the chart stops at Q2 instead of drawing $0 through the quarters that haven't come yet + expect(model.rows.map((row) => row.point.values)).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 1000}, {[CHART_SERIES_KEY.PRIMARY]: 2000}]); + }); }); describe('buildChartSeries labels for compared time buckets', () => { diff --git a/tests/unit/getInsightsDateRangeTest.ts b/tests/unit/getInsightsDateRangeTest.ts new file mode 100644 index 000000000000..9324f5d5c56a --- /dev/null +++ b/tests/unit/getInsightsDateRangeTest.ts @@ -0,0 +1,55 @@ +import getInsightsDateRange from '@pages/Insights/getInsightsDateRange'; + +import CONST from '@src/CONST'; + +describe('getInsightsDateRange', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 9, 8)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('covers only the picked day for a single date', () => { + // Given a Date filter set to one day + + // When the range it covers is read + const range = getInsightsDateRange({on: '2026-03-04'}); + + // Then the range starts and ends on that day + expect(range).toEqual({start: '2026-03-04', end: '2026-03-04'}); + }); + + it('keeps the bounds of a custom range', () => { + // Given a Date filter set to a custom range + + // When the range it covers is read + const range = getInsightsDateRange({from: '2026-03-01', to: '2026-05-02'}); + + // Then the range keeps the picked bounds as they are + expect(range).toEqual({start: '2026-03-01', end: '2026-05-02'}); + }); + + it('resolves a preset against today', () => { + // Given it's October 8 and the Date filter is set to This month + + // When the range it covers is read + const range = getInsightsDateRange({preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}); + + // Then the range spans the whole of October, including the days still to come + expect(range).toEqual({start: '2026-10-01', end: '2026-10-31'}); + }); + + it('returns no bounds for a preset without a fixed range', () => { + // Given a Date filter set to a preset that doesn't cover fixed dates + + // When the range it covers is read + const range = getInsightsDateRange({preset: CONST.SEARCH.DATE_PRESETS.NEVER}); + + // Then neither bound is set, so callers can tell there's no range to fit to + expect(range.start).toBeFalsy(); + expect(range.end).toBeFalsy(); + }); +}); diff --git a/tests/unit/insightsGroupByUtilsTest.ts b/tests/unit/insightsGroupByUtilsTest.ts index e14555cfe283..2e3151ecd06b 100644 --- a/tests/unit/insightsGroupByUtilsTest.ts +++ b/tests/unit/insightsGroupByUtilsTest.ts @@ -5,7 +5,7 @@ import {getFittingGroupBy, getInsightsGroupByOptions, getQueryGroupBy} from '@pa import CONST from '@src/CONST'; const {DAY, WEEK, MONTH, QUARTER, YEAR} = CONST.SEARCH.GROUP_BY; -const {THIS_MONTH, LAST_MONTH, THIS_YEAR, YEAR_TO_DATE, LAST_12_MONTHS} = CONST.SEARCH.DATE_PRESETS; +const {THIS_WEEK, THIS_MONTH, LAST_MONTH, THIS_QUARTER, THIS_YEAR, YEAR_TO_DATE, LAST_12_MONTHS} = CONST.SEARCH.DATE_PRESETS; describe('insightsGroupByUtils', () => { beforeEach(() => { @@ -19,7 +19,9 @@ describe('insightsGroupByUtils', () => { describe('getInsightsGroupByOptions', () => { it.each<[string, InsightsFilters['date'], Array]>([ + ['this week', {preset: THIS_WEEK}, [DAY]], ['this month', {preset: THIS_MONTH}, [DAY]], + ['this quarter', {preset: THIS_QUARTER}, [WEEK]], ['last month', {preset: LAST_MONTH}, [DAY, WEEK]], ['this year', {preset: THIS_YEAR}, [WEEK, MONTH]], ['last 12 months', {preset: LAST_12_MONTHS}, [WEEK, MONTH, QUARTER]], @@ -129,6 +131,50 @@ describe('insightsGroupByUtils', () => { expect(groupBy).toBe(DAY); }); + it('queries running total by week when the range offers neither daily nor monthly points', () => { + // Given a running total dashboard over this quarter, which only offers Week + const filters: InsightsFilters = {date: {preset: THIS_QUARTER}, policyIDs: [], groupBy: INSIGHTS_RUNNING_TOTAL_GROUP_BY, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then it accumulates from weekly buckets, the only grouping the quarter offers + expect(groupBy).toBe(WEEK); + }); + + it('queries running total by day for this month', () => { + // Given a running total dashboard over this month, which only offers Day + const filters: InsightsFilters = {date: {preset: THIS_MONTH}, policyIDs: [], groupBy: INSIGHTS_RUNNING_TOTAL_GROUP_BY, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then it accumulates from daily buckets + expect(groupBy).toBe(DAY); + }); + + it('queries running total by month for this year', () => { + // Given a running total dashboard over this year, which offers Week and Month + const filters: InsightsFilters = {date: {preset: THIS_YEAR}, policyIDs: [], groupBy: INSIGHTS_RUNNING_TOTAL_GROUP_BY, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then it accumulates from monthly buckets rather than weekly ones + expect(groupBy).toBe(MONTH); + }); + + it('queries a quarterly dashboard over this year by month, since this year no longer offers Quarter', () => { + // Given a quarterly dashboard moved to this year + const filters: InsightsFilters = {date: {preset: THIS_YEAR}, policyIDs: [], groupBy: QUARTER, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then Month is queried, the closest grouping to Quarter that this year offers + expect(groupBy).toBe(MONTH); + }); + it('queries running total by month when the range is too long for daily points', () => { // Given a running total dashboard over the last 12 months, which does not offer Day const filters: InsightsFilters = {date: {preset: LAST_12_MONTHS}, policyIDs: [], groupBy: INSIGHTS_RUNNING_TOTAL_GROUP_BY, groupCurrency: 'USD'}; diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 4c7eeee8743e..55e1d0902bcb 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -6,7 +6,7 @@ import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import type {InsightsQuery} from '@pages/Insights/insightsQueries'; -import buildInsightsJsonQuery, {applyInsightsFilters, buildInsightsQueryString, buildTopExpensesQueryJSON} from '@pages/Insights/insightsQueries'; +import buildInsightsJsonQuery, {applyInsightsFilters, buildFutureExpensesQueryString, buildInsightsQueryString, buildTopExpensesQueryJSON} from '@pages/Insights/insightsQueries'; import CONST from '@src/CONST'; import type SearchResults from '@src/types/onyx/SearchResults'; @@ -485,6 +485,98 @@ describe('insightsQueries', () => { }); }); + describe('applyInsightsFilters stopping at today', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 9, 8)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('leaves out expenses dated after today when the date range runs past it', () => { + // Given it's October 8 and the page covers this year, which runs to December 31 + const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_YEAR}}; + + // When the headline chart's query is built to stop at today + const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, true); + + // Then it keeps the date range and only takes expenses dated before tomorrow + expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}:${CONST.SEARCH.DATE_PRESETS.THIS_YEAR}`); + expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<2026-10-09`); + }); + + it('adds nothing when the date range already ends by today', () => { + // Given the page covers last month, which ended before today + const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}}; + + // When the headline chart's query is built to stop at today + const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, true); + + // Then the query is the same as one that doesn't stop at today + expect(chartQuery).toBe(applyInsightsFilters(SPEND_SPEC.headlineChart, filters)); + }); + + it('keeps expenses dated after today unless asked to stop at today', () => { + // Given the page covers this year, which runs past today + const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_YEAR}}; + + // When the headline chart's query is built without stopping at today + const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters); + + // Then no upper bound is added to the date range + expect(chartQuery).not.toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<`); + }); + + it('adds nothing to charts that filter on a date other than the expense date', () => { + // Given the chart of violations kept at submission, which filters on the submission date, over this year + const chart = getComplianceChart(CONST.INSIGHTS.GRAPH.VIOLATIONS_BY_SUBMITTER); + expect(chart.dateFilterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED); + const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_YEAR}}; + + // When its query is built to stop at today + const chartQuery = applyInsightsFilters(chart, filters, undefined, true); + + // Then the query is the same as one that doesn't stop at today, since expense dates don't bound that chart + expect(chartQuery).toBe(applyInsightsFilters(chart, filters)); + }); + }); + + describe('buildFutureExpensesQueryString', () => { + it('lists the expenses dated after the as-of day through the end of the date range', () => { + // Given a dashboard over October in two workspaces, counted through October 8 + const filters: InsightsFilters = {...FILTERS, date: {from: '2026-10-01', to: '2026-10-31'}, policyIDs: ['policyA', 'policyB']}; + + // When the Spend page query for the left-out expenses is built + const query = buildFutureExpensesQueryString(filters, '2026-10-08'); + + // Then it lists expenses dated after October 8 up to and including October 31, in the same workspaces + const queryJSON = buildSearchQueryJSON(query); + expect(queryJSON?.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>2026-10-08`); + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<2026-11-01`); + expect(query).toContain('policyA'); + expect(query).toContain('policyB'); + + // And the page's own date range isn't added on top + expect(query).not.toContain('2026-10-01'); + expect(query).not.toContain('2026-10-31'); + }); + + it('has no upper bound when the date range has no end', () => { + // Given a dashboard on a preset with no fixed range + const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.NEVER}}; + + // When the Spend page query for the left-out expenses is built + const query = buildFutureExpensesQueryString(filters, '2026-10-08'); + + // Then it lists every expense dated after the as-of day + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>2026-10-08`); + expect(query).not.toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<`); + }); + }); + describe('buildTopExpensesQueryJSON', () => { it('lists the 10 largest expenses within the clicked point', () => { // Given the Spend table query a point on a ranking chart opens diff --git a/tests/unit/insightsTrackedFromTest.ts b/tests/unit/insightsTrackedFromTest.ts new file mode 100644 index 000000000000..c70f791d8dad --- /dev/null +++ b/tests/unit/insightsTrackedFromTest.ts @@ -0,0 +1,124 @@ +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {doesRangeStartBeforeTracking, isBucketTracked} from '@pages/Insights/insightsTrackedFrom'; + +import CONST from '@src/CONST'; + +const TRACKED_FROM = '2026-06-01'; + +const BASE_FILTERS: InsightsFilters = { + date: {from: '2026-07-01', to: '2026-07-31'}, + policyIDs: [], + groupBy: CONST.SEARCH.GROUP_BY.DAY, + groupCurrency: 'USD', +}; + +describe('insightsTrackedFrom', () => { + describe('isBucketTracked', () => { + it('keeps a bucket with no date range', () => { + // Given a bucket whose grouping has no dates, like a merchant + + // When it's checked against the first tracked day + const isTracked = isBucketTracked(undefined, TRACKED_FROM); + + // Then it's kept, since there's no date to rule it out + expect(isTracked).toBe(true); + }); + + it('drops a bucket that ends before the first tracked day', () => { + // Given May, which ends the day before tracking started + + // When it's checked against the first tracked day + const isTracked = isBucketTracked({start: '2026-05-01', end: '2026-05-31'}, TRACKED_FROM); + + // Then it's dropped, since the backend has no data for it + expect(isTracked).toBe(false); + }); + + it('keeps a bucket that runs into the first tracked day', () => { + // Given the week of May 31, which ends after tracking started + + // When it's checked against the first tracked day + const isTracked = isBucketTracked({start: '2026-05-31', end: '2026-06-06'}, TRACKED_FROM); + + // Then it's kept with the data from the first tracked day on + expect(isTracked).toBe(true); + }); + + it('keeps a bucket that ends on the first tracked day', () => { + // Given a bucket whose last day is the first tracked day + + // When it's checked against the first tracked day + const isTracked = isBucketTracked({start: '2026-05-26', end: TRACKED_FROM}, TRACKED_FROM); + + // Then it's kept, since that day has data + expect(isTracked).toBe(true); + }); + }); + + describe('doesRangeStartBeforeTracking', () => { + it('reaches back past the first tracked day when the range has no start', () => { + // Given a Date filter preset that has no fixed start + const filters: InsightsFilters = {...BASE_FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.NEVER}}; + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(filters, TRACKED_FROM); + + // Then it counts as starting before, since it covers every earlier date + expect(startsBefore).toBe(true); + }); + + it('starts before tracking when the range starts before the first tracked day', () => { + // Given a range from May through July + const filters: InsightsFilters = {...BASE_FILTERS, date: {from: '2026-05-01', to: '2026-07-31'}}; + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(filters, TRACKED_FROM); + + // Then it starts before tracking + expect(startsBefore).toBe(true); + }); + + it('does not start before tracking when the range starts after it and nothing is compared', () => { + // Given July with no compared period + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(BASE_FILTERS, TRACKED_FROM); + + // Then it doesn't start before tracking + expect(startsBefore).toBe(false); + }); + + it('starts before tracking when the compared previous period starts before the first tracked day', () => { + // Given June compared with May, which comes before tracking started + const filters: InsightsFilters = {...BASE_FILTERS, date: {from: '2026-06-01', to: '2026-06-30'}, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}; + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(filters, TRACKED_FROM); + + // Then it starts before tracking, since the compared line covers untracked days + expect(startsBefore).toBe(true); + }); + + it('does not start before tracking when the compared previous period starts after the first tracked day', () => { + // Given August compared with July, both after tracking started + const filters: InsightsFilters = {...BASE_FILTERS, date: {from: '2026-08-01', to: '2026-08-31'}, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}; + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(filters, TRACKED_FROM); + + // Then it doesn't start before tracking + expect(startsBefore).toBe(false); + }); + + it('ignores the compared periods of the average', () => { + // Given June compared with the average, whose earlier periods aren't checked here + const filters: InsightsFilters = {...BASE_FILTERS, date: {from: '2026-06-01', to: '2026-06-30'}, compare: CONST.SEARCH.COMPARE.AVERAGE}; + + // When the range is checked against the first tracked day + const startsBefore = doesRangeStartBeforeTracking(filters, TRACKED_FROM); + + // Then only the range itself counts, which starts on the first tracked day + expect(startsBefore).toBe(false); + }); + }); +}); From 230e15196b6cc7f7df596f8334ebe712a6e49ac7 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 17:50:05 +0100 Subject: [PATCH 04/10] Apply parrot translations --- src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ 10 files changed, 20 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 4d87f588d2a6..1769af0f8e4e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1244,6 +1244,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Compliance', runningTotal: 'Laufende Summe', + futureExpensesNotShown: (amount: string) => `Nicht angezeigt: ${amount} in zukünftigen Ausgaben.`, + violationsTrackedFrom: (date: string) => `Verstöße werden ab dem ${date} erfasst.`, upsellState: { title: 'Upgrade auf Control für mehr Einblicke', subtitle: 'Verfolgen Sie Trends bei der Einhaltung der Spesenrichtlinie und markieren Sie Wiederholungstäter.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0661f725d38f..899625f0f020 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1290,6 +1290,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Συμμόρφωση', runningTotal: 'Τρέχον σύνολο', + futureExpensesNotShown: (amount: string) => `Δεν εμφανίζονται: ${amount} σε μελλοντικές δαπάνες.`, + violationsTrackedFrom: (date: string) => `Οι παραβάσεις παρακολουθούνται από ${date}.`, upsellState: { title: 'Αναβαθμίστε σε Control για περισσότερες πληροφορίες', subtitle: 'Παρακολουθήστε τις τάσεις συμμόρφωσης με την πολιτική εξόδων και επισημάνετε τους επανειλημμένους παραβάτες.', diff --git a/src/languages/es.ts b/src/languages/es.ts index e87713ab1a44..396d8e8596e3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1238,6 +1238,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Cumplimiento', runningTotal: 'Total acumulado', + futureExpensesNotShown: (amount: string) => `No se muestran: ${amount} en gastos futuros.`, + violationsTrackedFrom: (date: string) => `Las infracciones se registran desde el ${date}.`, upsellState: { title: 'Mejora al plan Controlar para obtener más información', subtitle: 'Supervisa las tendencias de cumplimiento de la política de gastos y señala a los infractores reincidentes.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5dee8461a8f5..053e2b88f951 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1251,6 +1251,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Conformité', runningTotal: 'Total cumulé', + futureExpensesNotShown: (amount: string) => `Non affiché : ${amount} en dépenses futures.`, + violationsTrackedFrom: (date: string) => `Les violations sont suivies à partir du ${date}.`, upsellState: { title: "Passez à Control pour obtenir plus d'informations", subtitle: 'Suivez les tendances de conformité aux politiques de dépenses et signalez les récidivistes.', diff --git a/src/languages/it.ts b/src/languages/it.ts index d85422735d54..ef3eb8757cf2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1247,6 +1247,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Conformità', runningTotal: 'Totale progressivo', + futureExpensesNotShown: (amount: string) => `Non mostrato: ${amount} in spese future.`, + violationsTrackedFrom: (date: string) => `Le violazioni vengono tracciate a partire dal ${date}.`, upsellState: { title: 'Passa a Control per maggiori informazioni', subtitle: 'Tieni traccia delle tendenze di conformità alla policy spese e segnala i recidivi.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 332fdd02e3c5..f5ed15bdab2d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1232,6 +1232,8 @@ const translations: TranslationDeepObject = { }, compliance: 'コンプライアンス', runningTotal: '累計合計', + futureExpensesNotShown: (amount: string) => `非表示:将来の経費に${amount}あります。`, + violationsTrackedFrom: (date: string) => `違反は${date}から追跡されます。`, upsellState: { title: 'より多くのインサイトを得るには Control にアップグレードしてください', subtitle: '経費ポリシー順守の傾向を追跡し、違反を繰り返すユーザーにフラグを付けます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0a3e67f87f71..b7e3888d954f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1243,6 +1243,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Naleving', runningTotal: 'Doorlopend totaal', + futureExpensesNotShown: (amount: string) => `Niet weergegeven: ${amount} aan toekomstige uitgaven.`, + violationsTrackedFrom: (date: string) => `Overtredingen worden bijgehouden vanaf ${date}.`, upsellState: { title: 'Upgrade naar Control voor meer inzichten', subtitle: 'Volg trends in naleving van onkostenbeleid en markeer herhaalde overtreders.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fe7e7fafeff3..8df0db990c3f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1270,6 +1270,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Zgodność', runningTotal: 'Suma narastająco', + futureExpensesNotShown: (amount: string) => `Nie pokazano: ${amount} w przyszłych wydatkach.`, + violationsTrackedFrom: (date: string) => `Naruszenia są śledzone od ${date}.`, upsellState: { title: 'Ulepsz do Control, aby zyskać więcej wglądu', subtitle: 'Śledź trendy przestrzegania zasad dotyczących wydatków i oznaczaj osoby wielokrotnie je naruszające.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 08502f786758..90601a999056 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1246,6 +1246,8 @@ const translations: TranslationDeepObject = { }, compliance: 'Conformidade', runningTotal: 'Total acumulado', + futureExpensesNotShown: (amount: string) => `Não mostrado: ${amount} em despesas futuras.`, + violationsTrackedFrom: (date: string) => `Violações são registradas a partir de ${date}.`, upsellState: { title: 'Faça upgrade para o plano Control para ter mais insights', subtitle: 'Acompanhe tendências de conformidade com a política de despesas e sinalize reincidentes.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 32f5828eb3a2..4f8b9eb7001b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1197,6 +1197,8 @@ const translations: TranslationDeepObject = { }, compliance: '合规', runningTotal: '累计总额', + futureExpensesNotShown: (amount: string) => `未显示:未来报销金额 ${amount}。`, + violationsTrackedFrom: (date: string) => `从 ${date} 起开始记录违规情况。`, upsellState: {title: '升级到 Control,获取更多洞察', subtitle: '跟踪报销政策合规趋势,并标记屡次违规的用户。', button: '升级至 Control'}, explain: '解释', explainChartPrompt: (chartTitle: string) => `解释「${chartTitle}」中的数据。`, From 1a9e0ac3755bfeb9febd1ec79a207a9f35bd3b9e Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 18:20:59 +0100 Subject: [PATCH 05/10] Build the future expenses link with a date range filter --- src/pages/Insights/insightsQueries.ts | 3 +-- tests/unit/insightsQueriesTest.ts | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index ec0a8b9bde05..f798616863f9 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -129,8 +129,7 @@ function buildFutureExpensesQueryString(filters: InsightsFilters, asOfDate: stri return buildQueryStringFromFilterFormValues({ ...buildFilterFormValues(filters, false), type: CONST.SEARCH.DATA_TYPES.EXPENSE, - [FILTER_KEYS.DATE_AFTER]: asOfDate, - ...(!!end && {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(parseISO(end))}), + [FILTER_KEYS.DATE_RANGE]: getRangeQueryValue(getDayAfter(parseISO(asOfDate)), end || undefined), }); } diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 55e1d0902bcb..7ff459995340 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -554,14 +554,13 @@ describe('insightsQueries', () => { // Then it lists expenses dated after October 8 up to and including October 31, in the same workspaces const queryJSON = buildSearchQueryJSON(query); expect(queryJSON?.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); - expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>2026-10-08`); - expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<2026-11-01`); + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>=2026-10-09`); + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<=2026-10-31`); expect(query).toContain('policyA'); expect(query).toContain('policyB'); // And the page's own date range isn't added on top expect(query).not.toContain('2026-10-01'); - expect(query).not.toContain('2026-10-31'); }); it('has no upper bound when the date range has no end', () => { @@ -572,7 +571,7 @@ describe('insightsQueries', () => { const query = buildFutureExpensesQueryString(filters, '2026-10-08'); // Then it lists every expense dated after the as-of day - expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>2026-10-08`); + expect(query).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>=2026-10-09`); expect(query).not.toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<`); }); }); From 95455bb6ad66d4f1235dddac79e41a99052200cb Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 18:47:54 +0100 Subject: [PATCH 06/10] Use asOfDate as today across Insights charts and Details, also revert Home widget change --- src/ROUTES.ts | 4 +++- src/components/Search/SearchChartView.tsx | 5 ++++- src/libs/Navigation/types.ts | 3 +++ src/pages/Insights/InsightsDashboard.tsx | 2 ++ src/pages/Insights/charts/InsightsChartWidget.tsx | 13 ++++++++++--- .../Insights/details/InsightsDetailsPage.tsx | 5 +++-- src/pages/Insights/getInsightsLastDay.ts | 10 ++++++++++ src/pages/Insights/insightsQueries.ts | 15 +++++++-------- .../home/InsightsSection/useHomeInsightConfigs.ts | 2 +- tests/unit/insightsQueriesTest.ts | 6 +++--- 10 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 src/pages/Insights/getInsightsLastDay.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cea8d54c9930..1e04b52d465a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2418,12 +2418,14 @@ const ROUTES = { graphKey, itemKey, periodEnd, + asOfDate, }: { dashboardID: ValueOf; graphKey: ValueOf; itemKey: string; periodEnd?: string; - }) => `insights/${dashboardID}/details/${graphKey}?item=${encodeURIComponent(itemKey)}${periodEnd ? `&end=${periodEnd}` : ''}` as const, + asOfDate?: string; + }) => `insights/${dashboardID}/details/${graphKey}?item=${encodeURIComponent(itemKey)}${periodEnd ? `&end=${periodEnd}` : ''}${asOfDate ? `&asOf=${asOfDate}` : ''}` as const, }, EXPENSE_REPORT_RHP: { diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 8e261ca1be66..5efd0de49e79 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -93,6 +93,9 @@ type SearchChartViewProps = { /** First day the data was recorded, as a yyyy-MM-dd string, so compared buckets before it plot nothing instead of 0 */ trackedFrom?: string; + + /** Day the data was counted through, as a yyyy-MM-dd string, which decides where the line ends and which period is in progress. Defaults to the device's today. */ + today?: string; }; /** @@ -118,6 +121,7 @@ function SearchChartView({ renderDetails, chartContainerStyle, trackedFrom, + today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING), }: SearchChartViewProps) { const theme = useTheme(); const {preferredLocale, translate, dateFnsLocale, numberFormat} = useLocalize(); @@ -127,7 +131,6 @@ function SearchChartView({ const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; - const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {}; const isCountMetric = metric === CONST.SEARCH.CHART_METRIC.COUNT; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index cea2f5a8ea55..34e09a523316 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1618,6 +1618,9 @@ type InsightsDetailsParamList = { /** Last day of the clicked period, trimmed to the date range like the chart's label */ end?: string; + + /** Last day the dashboard counted expenses through, so Top expenses stops on the same day as the chart. Missing when the panel is opened from a link. */ + asOf?: string; }; }; diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index dd494f0c1915..1b58b8e79565 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -267,6 +267,7 @@ function InsightsDashboardContent({ supportsDrillIn={supportsDrillIn} onRetry={onRetry} onGroupByChange={onGroupByChange} + asOfDate={asOfDate} > {!!asOfDate && !!futureExpenses?.count && ( ))} diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 2caf8b44ff8f..93419448a1f4 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -28,6 +28,7 @@ import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import {getInsightsDetailsItemParams} from '@pages/Insights/details/insightsDetailsItem'; +import getInsightsLastDay from '@pages/Insights/getInsightsLastDay'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {INSIGHTS_RUNNING_TOTAL_GROUP_BY} from '@pages/Insights/insightsFilters'; import {getInsightsGroupByOptions, getQueryGroupBy, isRunningTotalAvailable} from '@pages/Insights/insightsGroupByUtils'; @@ -40,7 +41,6 @@ import type {InsightsDashboardID, InsightsScope, SearchResults} from '@src/types import type {OnyxEntry} from 'react-native-onyx'; -import {format} from 'date-fns'; import React from 'react'; import {View} from 'react-native'; import Animated, {FadeIn} from 'react-native-reanimated'; @@ -89,6 +89,9 @@ type InsightsChartWidgetProps = { /** Shows a group-by control in the chart's header when set. Stores the time bucket or Running total the control picks. */ onGroupByChange?: (update: Partial) => void; + /** Last day GetInsights counted expenses through, as a yyyy-MM-dd string, which the chart and its Details panel treat as today so they stop where the data stops */ + asOfDate?: string; + /** Shown below the plot once the chart has loaded, including when it has nothing to plot */ children?: React.ReactNode; }; @@ -108,6 +111,7 @@ function InsightsChartWidget({ supportsDrillIn = true, onRetry, onGroupByChange, + asOfDate, children, }: InsightsChartWidgetProps) { const styles = useThemeStyles(); @@ -128,6 +132,7 @@ function InsightsChartWidget({ const groupByOptions = getInsightsGroupByOptions(filters.date); const canRunningTotal = isRunningTotalAvailable(filters.date); const shouldShowRunningTotal = filters.groupBy === INSIGHTS_RUNNING_TOTAL_GROUP_BY && canRunningTotal && view === CONST.SEARCH.VIEW.LINE; + const today = getInsightsLastDay(asOfDate); const downloadCSV = () => { if (!queryJSON || state !== INSIGHTS_CHART_STATE.READY) { @@ -141,7 +146,7 @@ function InsightsChartWidget({ currency: filters.groupCurrency, getCurrencyDecimals, translate, - today: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + today, shouldShowRunningTotal, countDimension, }); @@ -156,7 +161,8 @@ function InsightsChartWidget({ if (!itemParams) { return; } - Navigation.navigate(ROUTES.INSIGHTS_DETAILS.getRoute({dashboardID, graphKey: chart.graphKey, ...itemParams})); + const detailsAsOfDate = asOfDate !== getInsightsLastDay(undefined) ? asOfDate : undefined; + Navigation.navigate(ROUTES.INSIGHTS_DETAILS.getRoute({dashboardID, graphKey: chart.graphKey, ...itemParams, asOfDate: detailsAsOfDate})); } : undefined; @@ -244,6 +250,7 @@ function InsightsChartWidget({ shouldShowRunningTotal={shouldShowRunningTotal} chartContainerStyle={cardPaddingHorizontal} trackedFrom={chart.trackedFrom} + today={today} shouldShowSinglePointAsBar shouldShowGroupLabels={isTimeGrouped} onGroupPress={openDetails} diff --git a/src/pages/Insights/details/InsightsDetailsPage.tsx b/src/pages/Insights/details/InsightsDetailsPage.tsx index 335daa662d05..318239879e20 100644 --- a/src/pages/Insights/details/InsightsDetailsPage.tsx +++ b/src/pages/Insights/details/InsightsDetailsPage.tsx @@ -16,6 +16,7 @@ import type {InsightsDetailsParamList} from '@libs/Navigation/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; +import getInsightsLastDay from '@pages/Insights/getInsightsLastDay'; import {getQueryGroupBy} from '@pages/Insights/insightsGroupByUtils'; import {applyInsightsFilters} from '@pages/Insights/insightsQueries'; import useInsightsFilters from '@pages/Insights/useInsightsFilters'; @@ -37,7 +38,7 @@ function InsightsDetailsPage({route}: InsightsDetailsPageProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); const {isBetaEnabled} = usePermissions(); - const {graphKey, item: itemKey, end: periodEnd} = route.params; + const {graphKey, item: itemKey, end: periodEnd, asOf} = route.params; const {filters, isResolved} = useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND); const memberAccountID = graphKey === CONST.INSIGHTS.GRAPH.TOP_SPENDERS ? Number(itemKey) : undefined; const [storedMember] = usePersonalDetail(memberAccountID); @@ -61,7 +62,7 @@ function InsightsDetailsPage({route}: InsightsDetailsPageProps) { translate, }) : undefined; - const queryJSONThroughToday = chartSpec && isResolved ? buildSearchQueryJSON(applyInsightsFilters(chartSpec, filters, undefined, true)) : undefined; + const queryJSONThroughToday = chartSpec && isResolved ? buildSearchQueryJSON(applyInsightsFilters(chartSpec, filters, undefined, getInsightsLastDay(asOf))) : undefined; const drillDownQuery = queryJSONThroughToday && item ? buildChartDrillDownQuery(queryJSONThroughToday, item.filterQuery) : undefined; if (!isBetaEnabled(CONST.BETAS.INSIGHTS_AI) || !chartSpec || (isResolved && !item)) { diff --git a/src/pages/Insights/getInsightsLastDay.ts b/src/pages/Insights/getInsightsLastDay.ts new file mode 100644 index 000000000000..fd65f0b8d6c9 --- /dev/null +++ b/src/pages/Insights/getInsightsLastDay.ts @@ -0,0 +1,10 @@ +import CONST from '@src/CONST'; + +import {format} from 'date-fns'; + +/** Returns the last day the dashboard counts expenses through, as a yyyy-MM-dd string. */ +function getInsightsLastDay(asOfDate: string | undefined): string { + return asOfDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); +} + +export default getInsightsLastDay; diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index f798616863f9..8970feca2770 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -46,14 +46,13 @@ function getDayAfter(date: Date): string { return format(addDays(date, 1), CONST.DATE.FNS_FORMAT_STRING); } -/** Builds the filter that leaves out expenses dated after today, or nothing when the date range already ends by today. */ -function buildStopAtTodayFormValues(date: InsightsFilters['date']): Partial { - const now = new Date(); +/** Builds the filter that leaves out expenses dated after `lastDay`, or nothing when the date range already ends by then. */ +function buildStopAtDayFormValues(date: InsightsFilters['date'], lastDay: string): Partial { const {end} = getInsightsDateRange(date); - if (!end || end <= format(now, CONST.DATE.FNS_FORMAT_STRING)) { + if (!end || end <= lastDay) { return {}; } - return {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(now)}; + return {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(parseISO(lastDay))}; } /** Builds the page's filters in the shape a search query is built from. Leaves the workspaces out when none are selected, which reports on all of them. */ @@ -105,13 +104,13 @@ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { return buildQueryStringFromFilterFormValues({...buildFilterFormValues(filters), groupBy: filters.groupBy as SearchGroupBy, compare: filters.compare}); } -/** Builds a chart's query with the page's filters applied. `shouldStopAtToday` leaves out expenses dated after today, for charts on the expense date whose query goes to Search instead of GetInsights. */ -function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode, shouldStopAtToday = false): SearchQueryString { +/** Builds a chart's query with the page's filters applied. `lastDay` (yyyy-MM-dd) leaves out expenses dated after it, for charts on the expense date whose query goes to Search instead of GetInsights. */ +function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode, lastDay?: string): SearchQueryString { const isExpenseDateChart = !chart.dateFilterKey || chart.dateFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE; const query = buildQueryStringFromFilterFormValues( { ...buildFilterFormValues(filters, true, chart.dateFilterKey), - ...(shouldStopAtToday && isExpenseDateChart && buildStopAtTodayFormValues(filters.date)), + ...(!!lastDay && isExpenseDateChart && buildStopAtDayFormValues(filters.date, lastDay)), has: chart.has, groupBy: chart.groupBy ?? getQueryGroupBy(filters), view: chart.view, diff --git a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts index ca4607dd3615..cbb78f6b9615 100644 --- a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts +++ b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts @@ -37,7 +37,7 @@ function isHomeInsightChart(chart: InsightsChartSpec): chart is HomeInsightChart /** Builds a Home insight that queries the same chart as the Insights page. */ function buildInsightConfigFromChart(chart: HomeInsightChartSpec, filters: InsightsFilters): SearchTypeMenuItem { - const searchQuery = applyInsightsFilters(chart, filters, undefined, true); + const searchQuery = applyInsightsFilters(chart, filters); const searchQueryJSON = buildSearchQueryJSON(searchQuery); return { diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 7ff459995340..18902666f54d 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -500,7 +500,7 @@ describe('insightsQueries', () => { const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_YEAR}}; // When the headline chart's query is built to stop at today - const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, true); + const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, '2026-10-08'); // Then it keeps the date range and only takes expenses dated before tomorrow expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}:${CONST.SEARCH.DATE_PRESETS.THIS_YEAR}`); @@ -512,7 +512,7 @@ describe('insightsQueries', () => { const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}}; // When the headline chart's query is built to stop at today - const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, true); + const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, '2026-10-08'); // Then the query is the same as one that doesn't stop at today expect(chartQuery).toBe(applyInsightsFilters(SPEND_SPEC.headlineChart, filters)); @@ -536,7 +536,7 @@ describe('insightsQueries', () => { const filters: InsightsFilters = {...FILTERS, date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_YEAR}}; // When its query is built to stop at today - const chartQuery = applyInsightsFilters(chart, filters, undefined, true); + const chartQuery = applyInsightsFilters(chart, filters, undefined, '2026-10-08'); // Then the query is the same as one that doesn't stop at today, since expense dates don't bound that chart expect(chartQuery).toBe(applyInsightsFilters(chart, filters)); From 35252a06f4eef47cb679cf14a13ba23b23400fff Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 19:27:32 +0100 Subject: [PATCH 07/10] Fix failing test and react compiler check --- src/pages/Insights/useInsightsChartData.ts | 18 +++++++++--------- tests/ui/InsightsControlsTest.tsx | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts index 1920a54af595..dfd9097c30c3 100644 --- a/src/pages/Insights/useInsightsChartData.ts +++ b/src/pages/Insights/useInsightsChartData.ts @@ -41,16 +41,7 @@ function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: I const theme = useTheme(); const {isOffline} = useNetwork(); - const {trackedFrom} = chart; - const getDateRange = queryJSON?.groupBy ? CHART_GROUP_BY_CONFIG[queryJSON.groupBy].getDateRange : undefined; - const keepTrackedBuckets = (items: GroupedItem[]) => (trackedFrom ? items.filter((item) => isBucketTracked(getDateRange?.(item), trackedFrom)) : items); - const sortedData = useGroupedItems(normalizeInsightsSnapshot(snapshot, queryJSON?.groupBy), queryJSON); - const resolvedCurrent = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); - const trackedData = keepTrackedBuckets(resolvedCurrent.data); - const current: InsightsChartData = - resolvedCurrent.state === INSIGHTS_CHART_STATE.READY && trackedData.length === 0 ? {data: [], state: INSIGHTS_CHART_STATE.EMPTY} : {...resolvedCurrent, data: trackedData}; - const compare = filters.compare; const isPreviousPeriod = compare === CONST.SEARCH.COMPARE.PREVIOUS_PERIOD; // Keeps every compared group, so each plotted group finds its own and not only the top ones. @@ -59,6 +50,15 @@ function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: I queryJSON && {...queryJSON, limit: undefined}, ); + const {trackedFrom} = chart; + const getDateRange = queryJSON?.groupBy ? CHART_GROUP_BY_CONFIG[queryJSON.groupBy].getDateRange : undefined; + const keepTrackedBuckets = (items: GroupedItem[]) => (trackedFrom ? items.filter((item) => isBucketTracked(getDateRange?.(item), trackedFrom)) : items); + + const resolvedCurrent = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); + const trackedData = keepTrackedBuckets(resolvedCurrent.data); + const current: InsightsChartData = + resolvedCurrent.state === INSIGHTS_CHART_STATE.READY && trackedData.length === 0 ? {data: [], state: INSIGHTS_CHART_STATE.EMPTY} : {...resolvedCurrent, data: trackedData}; + if (current.state !== INSIGHTS_CHART_STATE.READY) { return current; } diff --git a/tests/ui/InsightsControlsTest.tsx b/tests/ui/InsightsControlsTest.tsx index 1d41770f484e..64324f81b087 100644 --- a/tests/ui/InsightsControlsTest.tsx +++ b/tests/ui/InsightsControlsTest.tsx @@ -239,8 +239,8 @@ describe('Insights controls', () => { }); it('only offers the groupings that fit the date range', async () => { - // Given the headline chart grouped by week over this month, which is too short for Month, Quarter and Year - const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}; + // Given the headline chart grouped by week over last month, which is too short for Month, Quarter and Year + const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}; renderWithProviders( Date: Thu, 12 Nov 2026 20:14:27 +0100 Subject: [PATCH 08/10] Limit View on Spend to the days the chart plots --- .../Insights/charts/InsightsChartWidget.tsx | 13 +++++++++++-- src/pages/Insights/insightsQueries.ts | 16 +++++++++------- tests/unit/insightsQueriesTest.ts | 4 ++-- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 93419448a1f4..e88c1b5fadee 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -21,7 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import localFileDownload from '@libs/localFileDownload'; import Navigation from '@libs/Navigation/Navigation'; import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; -import {getDateFilterRange} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, getDateFilterRange} from '@libs/SearchQueryUtils'; import InsightsExplainButton from '@pages/Insights/ai/InsightsExplainButton'; import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; @@ -32,6 +32,7 @@ import getInsightsLastDay from '@pages/Insights/getInsightsLastDay'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {INSIGHTS_RUNNING_TOTAL_GROUP_BY} from '@pages/Insights/insightsFilters'; import {getInsightsGroupByOptions, getQueryGroupBy, isRunningTotalAvailable} from '@pages/Insights/insightsGroupByUtils'; +import {applyInsightsFilters} from '@pages/Insights/insightsQueries'; import {doesRangeStartBeforeTracking} from '@pages/Insights/insightsTrackedFrom'; import useInsightsChartData from '@pages/Insights/useInsightsChartData'; @@ -183,6 +184,14 @@ function InsightsChartWidget({ /> ) : null; + const openOnSpend = () => { + const queryJSONForSearch = buildSearchQueryJSON(applyInsightsFilters(chart, filters, undefined, today)) ?? queryJSON; + if (!queryJSONForSearch) { + return; + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSONForSearch)})); + }; + const headerMenu = !!queryJSON && (state === INSIGHTS_CHART_STATE.READY || (isLoading && supportsDrillIn)) ? ( Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSON)})), + onSelected: openOnSpend, shouldCallAfterModalHide: true, }, ] diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index 8970feca2770..e5fa4f4b47f8 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -46,13 +46,15 @@ function getDayAfter(date: Date): string { return format(addDays(date, 1), CONST.DATE.FNS_FORMAT_STRING); } -/** Builds the filter that leaves out expenses dated after `lastDay`, or nothing when the date range already ends by then. */ -function buildStopAtDayFormValues(date: InsightsFilters['date'], lastDay: string): Partial { - const {end} = getInsightsDateRange(date); - if (!end || end <= lastDay) { +/** Builds the filter that narrows the date range to the days the chart has data for: from `trackedFrom` when the range starts earlier, through `lastDay` when it ends later. */ +function buildDataDaysFormValues(date: InsightsFilters['date'], lastDay: string, trackedFrom: string | undefined): Partial { + const {start, end} = getInsightsDateRange(date); + const from = trackedFrom && (!start || start < trackedFrom) ? trackedFrom : undefined; + const to = end && end > lastDay ? lastDay : undefined; + if (!from && !to) { return {}; } - return {[FILTER_KEYS.DATE_BEFORE]: getDayAfter(parseISO(lastDay))}; + return {[FILTER_KEYS.DATE_RANGE]: getRangeQueryValue(from, to)}; } /** Builds the page's filters in the shape a search query is built from. Leaves the workspaces out when none are selected, which reports on all of them. */ @@ -104,13 +106,13 @@ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { return buildQueryStringFromFilterFormValues({...buildFilterFormValues(filters), groupBy: filters.groupBy as SearchGroupBy, compare: filters.compare}); } -/** Builds a chart's query with the page's filters applied. `lastDay` (yyyy-MM-dd) leaves out expenses dated after it, for charts on the expense date whose query goes to Search instead of GetInsights. */ +/** Builds a chart's query with the page's filters applied. `lastDay` (yyyy-MM-dd) is for queries that go to Search instead of GetInsights: it leaves out expenses dated after it and before the chart's `trackedFrom`, so Search lists what the chart plots. Applies to charts on the expense date only. */ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode, lastDay?: string): SearchQueryString { const isExpenseDateChart = !chart.dateFilterKey || chart.dateFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE; const query = buildQueryStringFromFilterFormValues( { ...buildFilterFormValues(filters, true, chart.dateFilterKey), - ...(!!lastDay && isExpenseDateChart && buildStopAtDayFormValues(filters.date, lastDay)), + ...(!!lastDay && isExpenseDateChart && buildDataDaysFormValues(filters.date, lastDay, chart.trackedFrom)), has: chart.has, groupBy: chart.groupBy ?? getQueryGroupBy(filters), view: chart.view, diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 18902666f54d..3ae7cc2a5b62 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -502,9 +502,9 @@ describe('insightsQueries', () => { // When the headline chart's query is built to stop at today const chartQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters, undefined, '2026-10-08'); - // Then it keeps the date range and only takes expenses dated before tomorrow + // Then it keeps the date range and only takes expenses dated up to and including today expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}:${CONST.SEARCH.DATE_PRESETS.THIS_YEAR}`); - expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<2026-10-09`); + expect(chartQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<=2026-10-08`); }); it('adds nothing when the date range already ends by today', () => { From 21af2a5b6260be0edd7bd85ba4e6675277b55c70 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 21:03:12 +0100 Subject: [PATCH 09/10] Group and sort the future expenses View like View on Spend --- src/pages/Insights/insightsQueries.ts | 17 +++++++++++------ tests/unit/insightsQueriesTest.ts | 6 ++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index e5fa4f4b47f8..643591798b2a 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -124,14 +124,19 @@ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters return chart.workflowStage ? `${query} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.WORKFLOW_STAGE}:${chart.workflowStage}` : query; } -/** Builds the Spend page query for the expenses the dashboard leaves out: those dated after `asOfDate` through the end of the date range, in the dashboard's workspaces. */ +/** Builds the Spend page query for the expenses the dashboard leaves out: those dated after `asOfDate` through the end of the date range, in the dashboard's workspaces, grouped by the time bucket Spend over time plots. */ function buildFutureExpensesQueryString(filters: InsightsFilters, asOfDate: string): SearchQueryString { const {end} = getInsightsDateRange(filters.date); - return buildQueryStringFromFilterFormValues({ - ...buildFilterFormValues(filters, false), - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - [FILTER_KEYS.DATE_RANGE]: getRangeQueryValue(getDayAfter(parseISO(asOfDate)), end || undefined), - }); + return buildQueryStringFromFilterFormValues( + { + ...buildFilterFormValues(filters, false), + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + [FILTER_KEYS.DATE_RANGE]: getRangeQueryValue(getDayAfter(parseISO(asOfDate)), end || undefined), + groupBy: getQueryGroupBy(filters), + view: CONST.SEARCH.VIEW.TABLE, + }, + {sortOrder: CONST.SEARCH.SORT_ORDER.ASC}, + ); } type InsightsChartQuery = { diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 3ae7cc2a5b62..5908254a304f 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -561,6 +561,12 @@ describe('insightsQueries', () => { // And the page's own date range isn't added on top expect(query).not.toContain('2026-10-01'); + + // And the expenses are listed as a table grouped by week, the bucket Spend over time plots when the saved Month doesn't fit October + expect(queryJSON?.groupBy).toBe(CONST.SEARCH.GROUP_BY.WEEK); + expect(queryJSON?.view).toBe(CONST.SEARCH.VIEW.TABLE); + expect(queryJSON?.sortBy).toBe(CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK); + expect(queryJSON?.sortOrder).toBe(CONST.SEARCH.SORT_ORDER.ASC); }); it('has no upper bound when the date range has no end', () => { From ed4148065c90995891f3871e3406189f5263ecc0 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 12 Nov 2026 21:10:22 +0100 Subject: [PATCH 10/10] Stop the open period's drill-down at today --- src/components/Search/SearchChartView.tsx | 9 +++++++-- src/pages/Insights/charts/InsightsChartWidget.tsx | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 5efd0de49e79..81f3bfa9fe37 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -70,6 +70,9 @@ type SearchChartViewProps = { /** Whether pressing a bar, point or slice opens Search filtered to it */ shouldDrillDownOnPress?: boolean; + /** Query a pressed group is narrowed from instead of `queryJSON`, for charts whose data stops short of their date range, so Search lists only what the point plots */ + drillDownQueryJSON?: Readonly; + /** Called with the group whose bar, point, slice or touch-opened tooltip is pressed. Takes precedence over `shouldDrillDownOnPress`. */ onGroupPress?: (item: GroupedItem) => void; @@ -113,6 +116,7 @@ function SearchChartView({ shouldShowGroupLabels = true, shouldUseResponsiveLabels = false, shouldDrillDownOnPress = true, + drillDownQueryJSON, onGroupPress, tooltipFooter, shouldShowSinglePointAsBar = false, @@ -185,11 +189,12 @@ function SearchChartView({ return; } - if (!queryJSON) { + const pressedQueryJSON = drillDownQueryJSON ?? queryJSON; + if (!pressedQueryJSON) { return; } - const query = buildChartDrillDownQuery(queryJSON, getFilterQuery(item)); + const query = buildChartDrillDownQuery(pressedQueryJSON, getFilterQuery(item)); if (!query) { return; diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index e88c1b5fadee..25aea770a980 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -184,8 +184,8 @@ function InsightsChartWidget({ /> ) : null; + const queryJSONForSearch = buildSearchQueryJSON(applyInsightsFilters(chart, filters, undefined, today)) ?? queryJSON; const openOnSpend = () => { - const queryJSONForSearch = buildSearchQueryJSON(applyInsightsFilters(chart, filters, undefined, today)) ?? queryJSON; if (!queryJSONForSearch) { return; } @@ -255,6 +255,7 @@ function InsightsChartWidget({ countDimension={countDimension} isLoading={isLoading} shouldDrillDownOnPress={supportsDrillIn} + drillDownQueryJSON={queryJSONForSearch} comparison={comparison} shouldShowRunningTotal={shouldShowRunningTotal} chartContainerStyle={cardPaddingHorizontal}