diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7f5c6828d09c8..a1660180a7dd1 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -905,8 +905,10 @@ function Search({ } if (isTransactionMonthGroupListItemType(item)) { + // Extract the existing date filter to check for year-to-date or other date limits + const existingDateFilter = queryJSON.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + const {start: monthStart, end: monthEnd} = adjustTimeRangeToDateFilters(DateUtils.getMonthDateRange(item.year, item.month), existingDateFilter); const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); - const {start: monthStart, end: monthEnd} = DateUtils.getMonthDateRange(item.year, item.month); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, filters: [ @@ -954,8 +956,10 @@ function Search({ if (yearGroupItem.year === undefined) { return; } + // Extract the existing date filter to check for year-to-date or other date limits + const existingDateFilter = queryJSON.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + const {start: yearStart, end: yearEnd} = adjustTimeRangeToDateFilters(DateUtils.getYearDateRange(yearGroupItem.year), existingDateFilter); const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); - const {start: yearStart, end: yearEnd} = DateUtils.getYearDateRange(yearGroupItem.year); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, filters: [ @@ -978,8 +982,13 @@ function Search({ if (quarterGroupItem.year === undefined || quarterGroupItem.quarter === undefined) { return; } + // Extract the existing date filter to check for year-to-date or other date limits + const existingDateFilter = queryJSON.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + const {start: quarterStart, end: quarterEnd} = adjustTimeRangeToDateFilters( + DateUtils.getQuarterDateRange(quarterGroupItem.year, quarterGroupItem.quarter), + existingDateFilter, + ); const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); - const {start: quarterStart, end: quarterEnd} = DateUtils.getQuarterDateRange(quarterGroupItem.year, quarterGroupItem.quarter); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, filters: [ diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 89de5f1b4deb6..9d1f256d85f1b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ // TODO: Remove this disable once SearchUIUtils is refactored (see dedicated refactor issue) -import {endOfMonth, format, startOfMonth, startOfYear, subMonths} from 'date-fns'; +import {addDays, endOfMonth, format, parse, startOfMonth, startOfYear, subDays, subMonths} from 'date-fns'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -2472,6 +2472,7 @@ function getTagSections(data: OnyxTypes.SearchResults['data'], queryJSON: Search */ function getMonthSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionMonthGroupListItemType[], number] { const monthSections: Record = {}; + const dateFilters = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); for (const key in data) { if (isGroupEntry(key)) { const monthGroup = data[key]; @@ -2480,9 +2481,8 @@ function getMonthSections(data: OnyxTypes.SearchResults['data'], queryJSON: Sear continue; } let transactionsQueryJSON: SearchQueryJSON | undefined; + const {start: monthStart, end: monthEnd} = adjustTimeRangeToDateFilters(DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month), dateFilters); if (queryJSON && monthGroup.year && monthGroup.month) { - // Create date range for the month (first day to last day of the month) - const {start: monthStart, end: monthEnd} = DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month); const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, @@ -2521,6 +2521,7 @@ function getMonthSections(data: OnyxTypes.SearchResults['data'], queryJSON: Sear */ function getWeekSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionWeekGroupListItemType[], number] { const weekSections: Record = {}; + const dateFilters = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); for (const key in data) { if (isGroupEntry(key)) { const weekGroup = data[key]; @@ -2529,10 +2530,7 @@ function getWeekSections(data: OnyxTypes.SearchResults['data'], queryJSON: Searc continue; } let transactionsQueryJSON: SearchQueryJSON | undefined; - const {start: weekStart, end: weekEnd} = adjustTimeRangeToDateFilters( - DateUtils.getWeekDateRange(weekGroup.week), - queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE), - ); + const {start: weekStart, end: weekEnd} = adjustTimeRangeToDateFilters(DateUtils.getWeekDateRange(weekGroup.week), dateFilters); if (queryJSON && weekGroup.week) { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); newFlatFilters.push({ @@ -2568,6 +2566,7 @@ function getWeekSections(data: OnyxTypes.SearchResults['data'], queryJSON: Searc */ function getYearSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionYearGroupListItemType[], number] { const yearSections: Record = {}; + const dateFilters = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); for (const key in data) { if (isGroupEntry(key)) { const yearGroup = data[key]; @@ -2576,8 +2575,7 @@ function getYearSections(data: OnyxTypes.SearchResults['data'], queryJSON: Searc continue; } let transactionsQueryJSON: SearchQueryJSON | undefined; - const yearStart = `${yearGroup.year}-01-01`; - const yearEnd = `${yearGroup.year}-12-31`; + const {start: yearStart, end: yearEnd} = adjustTimeRangeToDateFilters(DateUtils.getYearDateRange(yearGroup.year), dateFilters); if (queryJSON && yearGroup.year !== undefined) { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); newFlatFilters.push({ @@ -2610,6 +2608,7 @@ function getYearSections(data: OnyxTypes.SearchResults['data'], queryJSON: Searc function getQuarterSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionQuarterGroupListItemType[], number] { const quarterSections: Record = {}; + const dateFilters = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); for (const key in data) { if (isGroupEntry(key)) { const quarterGroup = data[key]; @@ -2618,7 +2617,7 @@ function getQuarterSections(data: OnyxTypes.SearchResults['data'], queryJSON: Se continue; } let transactionsQueryJSON: SearchQueryJSON | undefined; - const {start: quarterStart, end: quarterEnd} = DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter); + const {start: quarterStart, end: quarterEnd} = adjustTimeRangeToDateFilters(DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter), dateFilters); if (queryJSON && quarterGroup.year !== undefined && quarterGroup.quarter !== undefined) { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); newFlatFilters.push({ @@ -3878,57 +3877,77 @@ function isDatePreset(value: string | number | undefined): value is SearchDatePr return Object.values(CONST.SEARCH.DATE_PRESETS).some((datePreset) => datePreset === value); } +/** + * Adjusts a time range based on date filters, intersecting preset ranges with additional constraints. + * When combining date presets (e.g., `date:on=last_month`) with constraints (e.g., `date:>=2025-04-01`), + * takes the intersection to narrow the range rather than overwriting it. + * + * @param timeRange - The base time range to adjust (e.g., a year/month/quarter range) + * @param dateFilter - Optional date filter containing preset and/or constraint filters + * @returns Adjusted time range that respects all date filters (intersected, not overwritten) + */ function adjustTimeRangeToDateFilters(timeRange: {start: string; end: string}, dateFilter: QueryFilters[0] | undefined): {start: string; end: string} { if (!dateFilter?.filters) { return timeRange; } const {start: timeRangeStart, end: timeRangeEnd} = timeRange; - const startLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO); - const endLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO); const equalToFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); let limitsStart: string | undefined; let limitsEnd: string | undefined; - - if (startLimitFilter?.value) { - const value = String(startLimitFilter.value); + // Date presets come with the equals operator, so we need to check if the value is a date preset + if (equalToFilter?.value) { + const value = String(equalToFilter.value); if (isDatePreset(value)) { const presetRange = getDateRangeForPreset(value); limitsStart = presetRange.start || undefined; + limitsEnd = presetRange.end || undefined; } else { limitsStart = value; + limitsEnd = value; } } + let startLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO); + if (startLimitFilter?.value) { + const constraintStart = String(startLimitFilter.value); + if (limitsStart) { + limitsStart = limitsStart > constraintStart ? limitsStart : constraintStart; + } else { + limitsStart = constraintStart; + } + } + + startLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN); + if (startLimitFilter?.value) { + const date = parse(String(startLimitFilter.value), 'yyyy-MM-dd', new Date()); + const constraintStart = format(addDays(date, 1), 'yyyy-MM-dd'); + if (limitsStart) { + limitsStart = limitsStart > constraintStart ? limitsStart : constraintStart; + } else { + limitsStart = constraintStart; + } + } + + let endLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO); if (endLimitFilter?.value) { - const value = String(endLimitFilter.value); - if (isDatePreset(value)) { - const presetRange = getDateRangeForPreset(value); - limitsEnd = presetRange.end || undefined; + const constraintEnd = String(endLimitFilter.value); + if (limitsEnd) { + limitsEnd = limitsEnd < constraintEnd ? limitsEnd : constraintEnd; } else { - limitsEnd = value; + limitsEnd = constraintEnd; } } - if (equalToFilter?.value) { - const value = String(equalToFilter.value); - if (isDatePreset(value)) { - const presetRange = getDateRangeForPreset(value); - if (presetRange.start && presetRange.end) { - if (!limitsStart) { - limitsStart = presetRange.start; - } - if (!limitsEnd) { - limitsEnd = presetRange.end; - } - if (limitsStart && presetRange.start > limitsStart) { - limitsStart = presetRange.start; - } - if (limitsEnd && presetRange.end < limitsEnd) { - limitsEnd = presetRange.end; - } - } + endLimitFilter = dateFilter.filters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN); + if (endLimitFilter?.value) { + const date = parse(String(endLimitFilter.value), 'yyyy-MM-dd', new Date()); + const constraintEnd = format(subDays(date, 1), 'yyyy-MM-dd'); + if (limitsEnd) { + limitsEnd = limitsEnd < constraintEnd ? limitsEnd : constraintEnd; + } else { + limitsEnd = constraintEnd; } } diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index a42a060668fee..bec8c06bec976 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -33,6 +33,7 @@ import {setOptimisticDataForTransactionThreadPreview} from '@userActions/Search' import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils'; +import DateUtils from '@src/libs/DateUtils'; import {getUserFriendlyValue} from '@src/libs/SearchQueryUtils'; import * as SearchUIUtils from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -2952,6 +2953,214 @@ describe('SearchUIUtils', () => { expect(SearchUIUtils.isTransactionYearGroupListItemType(yearItem)).toBe(true); }); + it('should apply date filter when expanding year group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies a date filter + // when expanding a year group (e.g., date >= 2025-12-01) + const yearDateRange = DateUtils.getYearDateRange(2025); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-12-01', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(yearDateRange, dateFilter); + + // The start date should be adjusted to 2025-12-01 (the filter limit) + // instead of 2025-01-01 (the year start) + expect(result.start).toBe('2025-12-01'); + // The end date should remain 2025-12-31 (the year end) + expect(result.end).toBe('2025-12-31'); + }); + + it('should apply date filter with both start and end limits when expanding year group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies both start and end date filters + const yearDateRange = DateUtils.getYearDateRange(2025); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-06-15', + }, + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + value: '2025-09-30', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(yearDateRange, dateFilter); + + // The start date should be adjusted to 2025-06-15 (the filter limit) + expect(result.start).toBe('2025-06-15'); + // The end date should be adjusted to 2025-09-30 (the filter limit) + expect(result.end).toBe('2025-09-30'); + }); + + it('should return original time range when no date filter is provided', () => { + const yearDateRange = DateUtils.getYearDateRange(2025); + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(yearDateRange, undefined); + + // Should return the original year date range unchanged + expect(result.start).toBe('2025-01-01'); + expect(result.end).toBe('2025-12-31'); + }); + + it('should apply date filter when expanding month group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies a date filter + // when expanding a month group (e.g., date >= 2025-12-15) + const monthDateRange = DateUtils.getMonthDateRange(2025, 12); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-12-15', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(monthDateRange, dateFilter); + + // The start date should be adjusted to 2025-12-15 (the filter limit) + // instead of 2025-12-01 (the month start) + expect(result.start).toBe('2025-12-15'); + // The end date should remain 2025-12-31 (the month end) + expect(result.end).toBe('2025-12-31'); + }); + + it('should apply date filter with both start and end limits when expanding month group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies both start and end date filters + const monthDateRange = DateUtils.getMonthDateRange(2025, 6); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-06-10', + }, + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + value: '2025-06-20', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(monthDateRange, dateFilter); + + // The start date should be adjusted to 2025-06-10 (the filter limit) + expect(result.start).toBe('2025-06-10'); + // The end date should be adjusted to 2025-06-20 (the filter limit) + expect(result.end).toBe('2025-06-20'); + }); + + it('should apply date filter when expanding quarter group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies a date filter + // when expanding a quarter group (e.g., date >= 2025-09-15) + const quarterDateRange = DateUtils.getQuarterDateRange(2025, 3); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-09-15', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(quarterDateRange, dateFilter); + + // The start date should be adjusted to 2025-09-15 (the filter limit) + // instead of 2025-07-01 (the quarter start) + expect(result.start).toBe('2025-09-15'); + // The end date should remain 2025-09-30 (the quarter end) + expect(result.end).toBe('2025-09-30'); + }); + + it('should apply date filter with both start and end limits when expanding quarter group', () => { + // Test that adjustTimeRangeToDateFilters correctly applies both start and end date filters + const quarterDateRange = DateUtils.getQuarterDateRange(2025, 2); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-05-10', + }, + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + value: '2025-06-20', + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(quarterDateRange, dateFilter); + + // The start date should be adjusted to 2025-05-10 (the filter limit) + expect(result.start).toBe('2025-05-10'); + // The end date should be adjusted to 2025-06-20 (the filter limit) + expect(result.end).toBe('2025-06-20'); + }); + + it('should intersect date preset with additional constraints instead of overwriting', () => { + // Test that when combining a date preset (EQUAL_TO) with additional constraints, + // we intersect the ranges (take max for start, min for end) rather than overwriting + const yearDateRange = DateUtils.getYearDateRange(2026); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, // e.g., January 2026: 2026-01-01 to 2026-01-31 + }, + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + value: '2025-04-01', // Earlier than preset start + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(yearDateRange, dateFilter); + + // Should intersect: max(preset start, constraint start) = max(2026-01-01, 2025-04-01) = 2026-01-01 + // The preset start should be preserved, not overwritten by the earlier constraint + expect(result.start).toBe('2026-01-01'); + // End should remain the preset end (2026-01-31) + expect(result.end).toBe('2026-01-31'); + }); + + it('should intersect date preset end limit with additional constraints', () => { + // Test that when combining a date preset with an end constraint, + // we take the minimum (earliest) end date to intersect ranges + const yearDateRange = DateUtils.getYearDateRange(2026); + const dateFilter = { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, // e.g., January 2026: 2026-01-01 to 2026-01-31 + }, + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + value: '2026-01-15', // Earlier than preset end + }, + ], + }; + + const result = SearchUIUtils.adjustTimeRangeToDateFilters(yearDateRange, dateFilter); + + // Start should remain the preset start (2026-01-01) + expect(result.start).toBe('2026-01-01'); + // Should intersect: min(preset end, constraint end) = min(2026-01-31, 2026-01-15) = 2026-01-15 + // The constraint end should be used (earlier date) + expect(result.end).toBe('2026-01-15'); + }); + it('should return getQuarterSections result when type is EXPENSE and groupBy is quarter', () => { const transactionQuarterGroupListItems: TransactionQuarterGroupListItemType[] = [ {