mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 06:02:22 -05:00
fix(schedules): prevent past missed schedule dates from being marked as upcoming (#6925)
Fixes #6872
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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!)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof getStatus>;
|
||||
export type ScheduleStatuses = Map<ScheduleEntity['id'], ScheduleStatusType>;
|
||||
|
||||
export type ScheduleStatusLabelType = ReturnType<typeof getStatusLabel>;
|
||||
export type ScheduleStatusLabels = Map<
|
||||
ScheduleEntity['id'],
|
||||
|
||||
@@ -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<ScheduleEntity> &
|
||||
Pick<ScheduleEntity, 'id' | 'next_date' | '_conditions'>,
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,3 +469,93 @@ export function scheduleIsRecurring(dateCond: Condition | null) {
|
||||
|
||||
return value.type === 'recur';
|
||||
}
|
||||
|
||||
export type ScheduleStatusType = ReturnType<typeof getStatus>;
|
||||
export type ScheduleStatuses = Map<ScheduleEntity['id'], ScheduleStatusType>;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/6925.md
Normal file
6
upcoming-release-notes/6925.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [rznn7]
|
||||
---
|
||||
|
||||
Prevent past missed schedule dates from being marked as upcoming
|
||||
Reference in New Issue
Block a user