diff --git a/packages/desktop-client/src/components/mobile/schedules/SchedulesList.tsx b/packages/desktop-client/src/components/mobile/schedules/SchedulesList.tsx index cddc4c60ec..ac4db34afe 100644 --- a/packages/desktop-client/src/components/mobile/schedules/SchedulesList.tsx +++ b/packages/desktop-client/src/components/mobile/schedules/SchedulesList.tsx @@ -6,13 +6,13 @@ import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; +import type { ScheduleStatusType } from 'loot-core/shared/schedules'; import type { ScheduleEntity } from 'loot-core/types/models'; import { SchedulesListItem } from './SchedulesListItem'; import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem'; import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs'; -import type { ScheduleStatusType } from '@desktop-client/hooks/useSchedules'; type CompletedSchedulesItem = { id: 'show-completed' }; type SchedulesListEntry = ScheduleEntity | CompletedSchedulesItem; diff --git a/packages/desktop-client/src/components/mobile/schedules/SchedulesListItem.tsx b/packages/desktop-client/src/components/mobile/schedules/SchedulesListItem.tsx index e5649e9cad..176c755dc1 100644 --- a/packages/desktop-client/src/components/mobile/schedules/SchedulesListItem.tsx +++ b/packages/desktop-client/src/components/mobile/schedules/SchedulesListItem.tsx @@ -10,6 +10,7 @@ import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; import { format as monthUtilFormat } from 'loot-core/shared/months'; +import type { ScheduleStatusType } from 'loot-core/shared/schedules'; import { getScheduledAmount } from 'loot-core/shared/schedules'; import type { ScheduleEntity } from 'loot-core/types/models'; import type { WithRequired } from 'loot-core/types/util'; @@ -19,7 +20,6 @@ import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge'; import { DisplayId } from '@desktop-client/components/util/DisplayId'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useFormat } from '@desktop-client/hooks/useFormat'; -import type { ScheduleStatusType } from '@desktop-client/hooks/useSchedules'; type SchedulesListItemProps = { onDelete: () => void; diff --git a/packages/desktop-client/src/components/rules/RuleEditor.tsx b/packages/desktop-client/src/components/rules/RuleEditor.tsx index 53c838c62e..798fbbd28b 100644 --- a/packages/desktop-client/src/components/rules/RuleEditor.tsx +++ b/packages/desktop-client/src/components/rules/RuleEditor.tsx @@ -40,6 +40,7 @@ import { parse, unparse, } from 'loot-core/shared/rules'; +import type { ScheduleStatusType } from 'loot-core/shared/schedules'; import type { NewRuleEntity, RuleActionEntity, @@ -58,7 +59,6 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag'; import { useFormat } from '@desktop-client/hooks/useFormat'; import { useSchedules } from '@desktop-client/hooks/useSchedules'; -import type { ScheduleStatusType } from '@desktop-client/hooks/useSchedules'; import { SelectedProvider, useSelected, diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx index 547b7a74be..697e9fbf6c 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx @@ -16,6 +16,10 @@ import { View } from '@actual-app/components/view'; import { format as monthUtilFormat } from 'loot-core/shared/months'; import { getNormalisedString } from 'loot-core/shared/normalisation'; import { getScheduledAmount } from 'loot-core/shared/schedules'; +import type { + ScheduleStatuses, + ScheduleStatusType, +} from 'loot-core/shared/schedules'; import type { ScheduleEntity } from 'loot-core/types/models'; import { StatusBadge } from './StatusBadge'; @@ -35,11 +39,6 @@ import { useContextMenu } from '@desktop-client/hooks/useContextMenu'; import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useFormat } from '@desktop-client/hooks/useFormat'; import { usePayees } from '@desktop-client/hooks/usePayees'; -import type { - ScheduleStatuses, - ScheduleStatusType, -} from '@desktop-client/hooks/useSchedules'; - type SchedulesTableProps = { isLoading?: boolean; schedules: readonly ScheduleEntity[]; diff --git a/packages/desktop-client/src/components/schedules/StatusBadge.tsx b/packages/desktop-client/src/components/schedules/StatusBadge.tsx index eb38cdc213..653b340ad5 100644 --- a/packages/desktop-client/src/components/schedules/StatusBadge.tsx +++ b/packages/desktop-client/src/components/schedules/StatusBadge.tsx @@ -15,10 +15,9 @@ import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; import { getStatusLabel } from 'loot-core/shared/schedules'; +import type { ScheduleStatusType } from 'loot-core/shared/schedules'; import { titleFirst } from 'loot-core/shared/util'; -import type { ScheduleStatusType } from '@desktop-client/hooks/useSchedules'; - // Consists of Schedule Statuses + Transaction statuses export type StatusTypes = | ScheduleStatusType diff --git a/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplateIndicator.ts b/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplateIndicator.ts index ae616b2e27..ff897f244a 100644 --- a/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplateIndicator.ts +++ b/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplateIndicator.ts @@ -6,11 +6,11 @@ import type { TFunction } from 'i18next'; import * as monthUtils from 'loot-core/shared/months'; import { getUpcomingDays } from 'loot-core/shared/schedules'; +import type { ScheduleStatusType } from 'loot-core/shared/schedules'; import type { CategoryEntity, ScheduleEntity } from 'loot-core/types/models'; import { useCategoryScheduleGoalTemplates } from './useCategoryScheduleGoalTemplates'; import { useLocale } from './useLocale'; -import type { ScheduleStatusType } from './useSchedules'; import { useSyncedPref } from './useSyncedPref'; type UseCategoryScheduleGoalTemplateProps = { diff --git a/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplates.ts b/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplates.ts index 14bcbe3e7e..b59c7ac765 100644 --- a/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplates.ts +++ b/packages/desktop-client/src/hooks/useCategoryScheduleGoalTemplates.ts @@ -1,10 +1,11 @@ import { useMemo } from 'react'; +import type { ScheduleStatuses } from 'loot-core/shared/schedules'; import type { CategoryEntity, ScheduleEntity } from 'loot-core/types/models'; import { useCachedSchedules } from './useCachedSchedules'; import { useFeatureFlag } from './useFeatureFlag'; -import type { ScheduleStatuses, ScheduleStatusLabels } from './useSchedules'; +import type { ScheduleStatusLabels } from './useSchedules'; type ScheduleGoalDefinition = { type: 'schedule'; diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts index 4f343c7373..ae3a745c17 100644 --- a/packages/desktop-client/src/hooks/usePreviewTransactions.ts +++ b/packages/desktop-client/src/hooks/usePreviewTransactions.ts @@ -1,22 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import * as d from 'date-fns'; - import { send } from 'loot-core/platform/client/fetch'; -import { addDays, currentDay, parseDate } from 'loot-core/shared/months'; -import { - extractScheduleConds, - getNextDate, - getScheduledAmount, - getUpcomingDays, - scheduleIsRecurring, -} from 'loot-core/shared/schedules'; +import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules'; import { ungroupTransactions } from 'loot-core/shared/transactions'; import type { IntegerAmount } from 'loot-core/shared/util'; import type { ScheduleEntity, TransactionEntity } from 'loot-core/types/models'; import { useCachedSchedules } from './useCachedSchedules'; -import type { ScheduleStatuses } from './useSchedules'; import { useSyncedPref } from './useSyncedPref'; import { calculateRunningBalancesBottomUp } from './useTransactions'; @@ -74,76 +64,12 @@ export function usePreviewTransactions({ return []; } - const schedulesForPreview = schedules - .filter(s => isForPreview(s, statuses)) - .filter(filter ? filter : () => true); - - const today = d.startOfDay(parseDate(currentDay())); - - const upcomingPeriodEnd = d.startOfDay( - parseDate(addDays(today, getUpcomingDays(upcomingLength))), + return computeSchedulePreviewTransactions( + schedules, + statuses, + upcomingLength, + filter, ); - - return schedulesForPreview - .map(schedule => { - const { date: dateConditions } = extractScheduleConds( - schedule._conditions, - ); - - const status = statuses.get(schedule.id); - const isRecurring = scheduleIsRecurring(dateConditions); - - const dates: string[] = [schedule.next_date]; - let day = d.startOfDay(parseDate(schedule.next_date)); - if (isRecurring) { - while (day <= upcomingPeriodEnd) { - const nextDate = getNextDate(dateConditions, day); - - if (d.startOfDay(parseDate(nextDate)) > upcomingPeriodEnd) break; - - if (dates.includes(nextDate)) { - day = d.startOfDay(parseDate(addDays(day, 1))); - continue; - } - - dates.push(nextDate); - day = d.startOfDay(parseDate(addDays(nextDate, 1))); - } - } - - if (status === 'paid') { - dates.shift(); - } - - const schedules: { - id: string; - payee: string; - account: string; - amount: number; - date: string; - schedule: string; - forceUpcoming: boolean; - }[] = []; - dates.forEach(date => { - schedules.push({ - id: 'preview/' + schedule.id + `/${date}`, - payee: schedule._payee, - account: schedule._account, - amount: getScheduledAmount(schedule._amount), - date, - schedule: schedule.id, - forceUpcoming: date !== schedule.next_date || status === 'paid', - }); - }); - - return schedules; - }) - .flat() - .sort( - (a, b) => - parseDate(b.date).getTime() - parseDate(a.date).getTime() || - a.amount - b.amount, - ); }, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]); useEffect(() => { @@ -221,11 +147,3 @@ export function usePreviewTransactions({ ...(returnError && { error: returnError }), }; } - -function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) { - const status = statuses.get(schedule.id); - return ( - !schedule.completed && - ['due', 'upcoming', 'missed', 'paid'].includes(status!) - ); -} diff --git a/packages/desktop-client/src/hooks/useSchedules.ts b/packages/desktop-client/src/hooks/useSchedules.ts index bd985b7b39..5f5d0005cd 100644 --- a/packages/desktop-client/src/hooks/useSchedules.ts +++ b/packages/desktop-client/src/hooks/useSchedules.ts @@ -7,6 +7,7 @@ import { getStatus, getStatusLabel, } from 'loot-core/shared/schedules'; +import type { ScheduleStatuses } from 'loot-core/shared/schedules'; import type { AccountEntity, ScheduleEntity, @@ -19,9 +20,6 @@ import { accountFilter } from '@desktop-client/queries'; import { liveQuery } from '@desktop-client/queries/liveQuery'; import type { LiveQuery } from '@desktop-client/queries/liveQuery'; -export type ScheduleStatusType = ReturnType; -export type ScheduleStatuses = Map; - export type ScheduleStatusLabelType = ReturnType; export type ScheduleStatusLabels = Map< ScheduleEntity['id'], diff --git a/packages/loot-core/src/shared/schedules.test.ts b/packages/loot-core/src/shared/schedules.test.ts index afae41d1bf..7f8bd15b9a 100644 --- a/packages/loot-core/src/shared/schedules.test.ts +++ b/packages/loot-core/src/shared/schedules.test.ts @@ -2,12 +2,16 @@ import { enUS } from 'date-fns/locale'; import i18next from 'i18next'; import MockDate from 'mockdate'; +import type { ScheduleEntity } from '../types/models'; + import * as monthUtils from './months'; import { + computeSchedulePreviewTransactions, getRecurringDescription, getStatus, getUpcomingDays, } from './schedules'; +import type { ScheduleStatuses } from './schedules'; i18next.init({ lng: 'en', @@ -444,4 +448,123 @@ describe('schedules', () => { }, ); }); + + describe('computeSchedulePreviewTransactions', () => { + describe('forceUpcoming flag', () => { + function makeSchedule( + overrides: Partial & + Pick, + ): ScheduleEntity { + return { + rule: 'rule-1', + completed: false, + posts_transaction: false, + tombstone: false, + _payee: 'payee-1', + _account: 'acct-1', + _amount: -10000, + _amountOp: 'is', + _date: overrides.next_date, + _actions: [], + ...overrides, + }; + } + + it('sets forceUpcoming=false for past dates of a missed recurring schedule', () => { + const schedule = makeSchedule({ + id: 'sched-1', + next_date: '2016-12-19', + _conditions: [ + { + field: 'date', + op: 'isapprox', + value: { start: '2016-12-01', frequency: 'weekly' }, + }, + ], + }); + + const statuses: ScheduleStatuses = new Map([['sched-1', 'missed']]); + const result = computeSchedulePreviewTransactions( + [schedule], + statuses, + '7', + ); + + const pastEntries = result.filter(r => r.date < '2017-01-01'); + expect(pastEntries.length).toBeGreaterThan(0); + expect(pastEntries.every(r => r.forceUpcoming === false)).toBe(true); + }); + + it('sets forceUpcoming=true for future dates that differ from next_date', () => { + const schedule = makeSchedule({ + id: 'sched-1', + next_date: '2016-12-19', + _conditions: [ + { + field: 'date', + op: 'isapprox', + value: { start: '2016-12-01', frequency: 'weekly' }, + }, + ], + }); + + const statuses: ScheduleStatuses = new Map([['sched-1', 'missed']]); + const result = computeSchedulePreviewTransactions( + [schedule], + statuses, + '7', + ); + + const futureEntries = result.filter(r => r.date > '2017-01-01'); + expect(futureEntries.length).toBeGreaterThan(0); + expect(futureEntries.every(r => r.forceUpcoming === true)).toBe(true); + }); + + it('sets forceUpcoming=false for next_date when not paid', () => { + const schedule = makeSchedule({ + id: 'sched-1', + next_date: '2017-01-03', + _conditions: [{ field: 'date', op: 'is', value: '2017-01-03' }], + }); + + const statuses: ScheduleStatuses = new Map([['sched-1', 'upcoming']]); + const result = computeSchedulePreviewTransactions( + [schedule], + statuses, + '7', + ); + + expect(result).toHaveLength(1); + expect(result[0].forceUpcoming).toBe(false); + }); + + it('shifts next_date and forces upcoming for paid schedules', () => { + const schedule = makeSchedule({ + id: 'sched-1', + next_date: '2017-01-02', + _conditions: [ + { + field: 'date', + op: 'isapprox', + value: { start: '2016-12-01', frequency: 'weekly' }, + }, + ], + }); + + const statuses: ScheduleStatuses = new Map([['sched-1', 'paid']]); + const result = computeSchedulePreviewTransactions( + [schedule], + statuses, + '7', + ); + + expect(result.find(r => r.date === '2017-01-02')).toBeUndefined(); + expect( + result + .filter(r => r.date >= '2017-01-01') + .every(r => r.forceUpcoming === true), + ).toBe(true); + }); + }); + }); }); diff --git a/packages/loot-core/src/shared/schedules.ts b/packages/loot-core/src/shared/schedules.ts index b0d8517dc9..745c7745a8 100644 --- a/packages/loot-core/src/shared/schedules.ts +++ b/packages/loot-core/src/shared/schedules.ts @@ -469,3 +469,93 @@ export function scheduleIsRecurring(dateCond: Condition | null) { return value.type === 'recur'; } + +export type ScheduleStatusType = ReturnType; +export type ScheduleStatuses = Map; + +export function isForPreview( + schedule: ScheduleEntity, + statuses: ScheduleStatuses, +) { + const status = statuses.get(schedule.id); + return ( + !schedule.completed && + ['due', 'upcoming', 'missed', 'paid'].includes(status!) + ); +} + +export function computeSchedulePreviewTransactions( + schedules: readonly ScheduleEntity[], + statuses: ScheduleStatuses, + upcomingLength?: string, + filter?: (schedule: ScheduleEntity) => boolean, +) { + const schedulesForPreview = schedules + .filter(s => isForPreview(s, statuses)) + .filter(filter ? filter : () => true); + + const today = d.startOfDay(monthUtils.parseDate(monthUtils.currentDay())); + + const upcomingPeriodEnd = d.startOfDay( + monthUtils.parseDate( + monthUtils.addDays(today, getUpcomingDays(upcomingLength)), + ), + ); + + return schedulesForPreview + .flatMap(schedule => { + const { date: dateConditions } = extractScheduleConds( + schedule._conditions, + ); + + const status = statuses.get(schedule.id); + const isRecurring = scheduleIsRecurring(dateConditions); + + const dates = [schedule.next_date]; + let day = d.startOfDay(monthUtils.parseDate(schedule.next_date)); + if (isRecurring) { + while (day <= upcomingPeriodEnd) { + const nextDate = getNextDate(dateConditions, day); + + if ( + d.startOfDay(monthUtils.parseDate(nextDate)) > upcomingPeriodEnd + ) { + break; + } + + if (dates.includes(nextDate)) { + day = d.startOfDay( + monthUtils.parseDate(monthUtils.addDays(day, 1)), + ); + continue; + } + + dates.push(nextDate); + day = d.startOfDay( + monthUtils.parseDate(monthUtils.addDays(nextDate, 1)), + ); + } + } + + if (status === 'paid') { + dates.shift(); + } + + return dates.map(date => ({ + id: 'preview/' + schedule.id + `/${date}`, + payee: schedule._payee, + account: schedule._account, + amount: getScheduledAmount(schedule._amount), + date, + schedule: schedule.id, + forceUpcoming: + (date !== schedule.next_date || status === 'paid') && + date >= monthUtils.currentDay(), + })); + }) + .sort( + (a, b) => + monthUtils.parseDate(b.date).getTime() - + monthUtils.parseDate(a.date).getTime() || a.amount - b.amount, + ); +} diff --git a/upcoming-release-notes/6925.md b/upcoming-release-notes/6925.md new file mode 100644 index 0000000000..0d992e7d59 --- /dev/null +++ b/upcoming-release-notes/6925.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [rznn7] +--- + +Prevent past missed schedule dates from being marked as upcoming