fix(schedules): prevent past missed schedule dates from being marked as upcoming (#6925)

Fixes #6872
This commit is contained in:
Gabriel J.
2026-02-11 02:04:42 +01:00
committed by GitHub
parent 078da08ad5
commit 2ca352aaa7
12 changed files with 237 additions and 103 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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';

View File

@@ -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!)
);
}

View File

@@ -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'],

View File

@@ -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);
});
});
});
});

View File

@@ -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,
);
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [rznn7]
---
Prevent past missed schedule dates from being marked as upcoming