diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index ddd9f5b96326..acd4edbd91e1 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -1,3 +1,4 @@ +import {endOfDay, endOfMonth, endOfWeek, getDay, lastDayOfMonth, set, startOfMonth, startOfWeek, subDays} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; @@ -233,6 +234,28 @@ function compute(formula?: string, context?: FormulaContext): string { return result; } +/** + * Compute auto-reporting info for a report formula part + */ +function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, subField: string | undefined, format: string | undefined): string { + const {report, policy} = context; + + if (!subField) { + return part.definition; + } + + const {startDate, endDate} = getAutoReportingDates(policy, report); + + switch (subField.toLowerCase()) { + case 'start': + return formatDate(startDate?.toISOString(), format); + case 'end': + return formatDate(endDate?.toISOString(), format); + default: + return part.definition; + } +} + /** * Compute the value of a report formula part */ @@ -265,6 +288,12 @@ function computeReportPart(part: FormulaPart, context: FormulaContext): string { // Backend will always return at least one report action (of type created) and its date is equal to report's creation date // We can make it slightly more efficient in the future by ensuring report.created is always present in backend's responses return formatDate(getOldestReportActionDate(report.reportID), format); + case 'autoreporting': { + const subField = additionalPath.at(0); + // For multi-part formulas, format is everything after the subfield + const autoReportingFormat = additionalPath.length > 1 ? additionalPath.slice(1).join(':') : undefined; + return computeAutoReportingInfo(part, context, subField, autoReportingFormat); + } default: return part.definition; } @@ -482,6 +511,139 @@ function getOldestTransactionDate(reportID: string, context?: FormulaContext): s return oldestDate; } +/** + * Calculate monthly reporting period for a specific day offset + */ +function getMonthlyReportingPeriod(currentDate: Date, offsetDay: number): {startDate: Date; endDate: Date} { + const currentDay = currentDate.getDate(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + + if (currentDay <= offsetDay) { + // We haven't reached the reporting day yet - period is from last month's offset+1 to this month's offset + const prevMonth = currentMonth - 1; + const prevYear = prevMonth < 0 ? currentYear - 1 : currentYear; + const adjustedPrevMonth = prevMonth < 0 ? 11 : prevMonth; + + const prevMonthDays = lastDayOfMonth(new Date(prevYear, adjustedPrevMonth, 1)).getDate(); + const prevOffsetDay = Math.min(offsetDay, prevMonthDays); + + const currentMonthDays = lastDayOfMonth(currentDate).getDate(); + const currentOffsetDay = Math.min(offsetDay, currentMonthDays); + + return { + startDate: new Date(prevYear, adjustedPrevMonth, prevOffsetDay + 1, 0, 0, 0, 0), + endDate: new Date(currentYear, currentMonth, currentOffsetDay, 23, 59, 59, 999), + }; + } + + // We've passed the reporting day - period is from this month's offset+1 to next month's offset + const nextMonth = currentMonth + 1; + const nextYear = nextMonth > 11 ? currentYear + 1 : currentYear; + const adjustedNextMonth = nextMonth > 11 ? 0 : nextMonth; + + const currentMonthDays = lastDayOfMonth(currentDate).getDate(); + const currentOffsetDay = Math.min(offsetDay, currentMonthDays); + + const nextMonthDays = lastDayOfMonth(new Date(nextYear, adjustedNextMonth, 1)).getDate(); + const nextOffsetDay = Math.min(offsetDay, nextMonthDays); + + return { + startDate: new Date(currentYear, currentMonth, currentOffsetDay + 1, 0, 0, 0, 0), + endDate: new Date(nextYear, adjustedNextMonth, nextOffsetDay, 23, 59, 59, 999), + }; +} + +/** + * Calculate monthly reporting period for last business day + */ +function getMonthlyLastBusinessDayPeriod(currentDate: Date): {startDate: Date; endDate: Date} { + let endDate = endOfMonth(currentDate); + + // Move backward to find last business day (Mon-Fri) + while (getDay(endDate) === 0 || getDay(endDate) === 6) { + endDate = subDays(endDate, 1); + } + + return { + startDate: startOfMonth(currentDate), + endDate: endOfDay(endDate), + }; +} + +/** + * Calculate the start and end dates for auto-reporting based on the frequency and current date + */ +function getAutoReportingDates(policy: OnyxEntry, report: Report, currentDate = new Date()): {startDate: Date | undefined; endDate: Date | undefined} { + const frequency = policy?.autoReportingFrequency; + const offset = policy?.autoReportingOffset; + + // Return undefined if no frequency is set + if (!frequency || !policy) { + return {startDate: undefined, endDate: undefined}; + } + + let startDate: Date; + let endDate: Date; + + switch (frequency) { + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY: { + // Weekly: use the app's configured week start convention (Monday) + const weekStartsOn = CONST.WEEK_STARTS_ON; + startDate = startOfWeek(currentDate, {weekStartsOn}); + endDate = endOfWeek(currentDate, {weekStartsOn}); + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY: { + // Semi-monthly: 1st-15th or 16th-end of month + const dayOfMonth = currentDate.getDate(); + if (dayOfMonth <= 15) { + startDate = startOfMonth(currentDate); + endDate = set(currentDate, {date: 15, hours: 23, minutes: 59, seconds: 59, milliseconds: 999}); + } else { + startDate = set(currentDate, {date: 16, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); + endDate = endOfMonth(currentDate); + } + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY: { + // Monthly reporting with different offset configurations + if (offset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { + const period = getMonthlyLastBusinessDayPeriod(currentDate); + startDate = period.startDate; + endDate = period.endDate; + } else if (typeof offset === 'number') { + const period = getMonthlyReportingPeriod(currentDate, offset); + startDate = period.startDate; + endDate = period.endDate; + } else { + // Default to full month + startDate = startOfMonth(currentDate); + endDate = endOfMonth(currentDate); + } + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP: { + // For trip-based, use oldest transaction as start + const oldestTransactionDateString = getOldestTransactionDate(report.reportID); + startDate = oldestTransactionDateString ? new Date(oldestTransactionDateString) : currentDate; + endDate = currentDate; + break; + } + + default: + // For any other frequency, use current date as both start and end + startDate = currentDate; + endDate = currentDate; + break; + } + + return {startDate, endDate}; +} + /** * Get the date of the newest transaction for a given report */ @@ -518,6 +680,6 @@ function getNewestTransactionDate(reportID: string, context?: FormulaContext): s return newestDate; } -export {FORMULA_PART_TYPES, compute, extract, parse}; +export {FORMULA_PART_TYPES, compute, extract, getAutoReportingDates, parse}; export type {FormulaContext, FormulaPart}; diff --git a/src/pages/workspace/reports/ReportsDefaultTitle.tsx b/src/pages/workspace/reports/ReportsDefaultTitle.tsx index 3c35cfe24331..716d37835114 100644 --- a/src/pages/workspace/reports/ReportsDefaultTitle.tsx +++ b/src/pages/workspace/reports/ReportsDefaultTitle.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {View} from 'react-native'; import BulletList from '@components/BulletList'; import FormProvider from '@components/Form/FormProvider'; @@ -10,7 +10,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; @@ -19,7 +19,9 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import variables from '@styles/variables'; import {clearPolicyTitleFieldError, setPolicyDefaultReportTitle} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -34,7 +36,7 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {inputCallbackRef} = useAutoFocusInput(); + const isInputInitializedRef = useRef(false); const RULE_EXAMPLE_BULLET_POINTS = [ translate('workspace.reports.customNameEmailPhoneExample'), translate('workspace.reports.customNameStartDateExample'), @@ -45,6 +47,7 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) { const fieldListItem = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]; const customNameDefaultValue = Str.htmlDecode(fieldListItem?.defaultValue ?? ''); + const [reportTitle, setReportTitle] = useState(() => customNameDefaultValue); const validateCustomName = useCallback( ({defaultTitle}: FormOnyxValues) => { @@ -114,11 +117,24 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) { > { + if (!isInputInitializedRef.current) { + updateMultilineInputRange(el); + } + isInputInitializedRef.current = true; + }} /> { }); }); + describe('Auto-reporting Frequency', () => { + const mockReport = {reportID: '123'} as Report; + const createMockContext = (policy: Policy): FormulaContext => ({report: mockReport, policy}); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-19T14:23:45Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('should compute weekly frequency dates', () => { + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-13'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); + }); + + test('should compute semi-monthly frequency dates', () => { + jest.setSystemTime(new Date('2025-01-10T12:00:00Z')); + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-15'); + + jest.setSystemTime(new Date('2025-01-20T12:00:00Z')); + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-16'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); + }); + + test('should compute monthly frequency with specific offset', () => { + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: 25, + } as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2024-12-26'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-25'); + }); + + test('should compute monthly frequency with last business day', () => { + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, + } as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); + }); + + test('should compute trip frequency dates', () => { + const mockTransactions = [ + {transactionID: 'trans1', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + {transactionID: 'trans2', created: '2025-01-14T16:45:00Z', merchant: 'Restaurant', amount: 3000} as Transaction, + ]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); + }); + + test('should apply custom date formats', () => { + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start:MMMM dd, yyyy}', context)).toBe('January 13, 2025'); + expect(compute('{report:autoreporting:end:MM/dd/yyyy}', context)).toBe('01/19/2025'); + }); + + test('should return formula definition when policy or frequency is missing', () => { + expect(compute('{report:autoreporting:start}', {report: mockReport, policy: undefined})).toBe('{report:autoreporting:start}'); + expect(compute('{report:autoreporting:end}', createMockContext({} as Policy))).toBe('{report:autoreporting:end}'); + }); + }); + describe('Edge Cases', () => { test('should handle malformed braces', () => { const parts = parse('{incomplete');