show all occurrences of upcoming schedules within the upcoming period (#4166)

* load all instances of scheduled transactions that occur within the upcoming period

* correct status in transaction table

* ts

* note

* ci

* upcoming -> forceUpcoming

* remove caveat from upcoming length setting modal
This commit is contained in:
Matt Fiddaman
2025-01-16 17:04:31 +00:00
committed by GitHub
parent ceeef91a45
commit 6655f51ccc
8 changed files with 140 additions and 80 deletions

View File

@@ -58,11 +58,6 @@ export function UpcomingLength() {
data is stored. It can be changed at any time.
</Trans>
</Paragraph>
<Paragraph>
<Trans>
Only the first instance of a recurring transaction will be shown.
</Trans>
</Paragraph>
<View>
<Select
options={upcomingLengthOptions.map(x => [

View File

@@ -1025,10 +1025,13 @@ const Transaction = memo(function Transaction({
category: categoryId,
cleared,
reconciled,
forceUpcoming,
is_parent: isParent,
_unmatched = false,
} = transaction;
const previewStatus = forceUpcoming ? 'upcoming' : categoryId;
// Join in some data
const payee = payees && payeeId && getPayeesById(payees)[payeeId];
const account = accounts && accountId && getAccountsById(accounts)[accountId];
@@ -1352,17 +1355,17 @@ const Transaction = memo(function Transaction({
<View
style={{
color:
categoryId === 'missed'
previewStatus === 'missed'
? theme.errorText
: categoryId === 'due'
: previewStatus === 'due'
? theme.warningText
: selected
? theme.formLabelText
: theme.upcomingText,
backgroundColor:
categoryId === 'missed'
previewStatus === 'missed'
? theme.errorBackground
: categoryId === 'due'
: previewStatus === 'due'
? theme.warningBackground
: selected
? theme.formLabelBackground
@@ -1375,7 +1378,7 @@ const Transaction = memo(function Transaction({
display: 'inline-block',
}}
>
{titleFirst(categoryId)}
{titleFirst(previewStatus)}
</View>
)}
<CellButton
@@ -1604,7 +1607,7 @@ const Transaction = memo(function Transaction({
isPreview={isPreview}
status={
isPreview
? categoryId
? previewStatus
: reconciled
? 'reconciled'
: cleared

View File

@@ -1,10 +1,17 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { useSyncedPref } from '@actual-app/web/src/hooks/useSyncedPref';
import * as d from 'date-fns';
import debounce from 'lodash/debounce';
import { send } from '../../platform/client/fetch';
import { currentDay, addDays, parseDate } from '../../shared/months';
import { type Query } from '../../shared/query';
import { getScheduledAmount } from '../../shared/schedules';
import {
getScheduledAmount,
extractScheduleConds,
getNextDate,
} from '../../shared/schedules';
import { ungroupTransactions } from '../../shared/transactions';
import {
type ScheduleEntity,
@@ -138,25 +145,69 @@ export function usePreviewTransactions(): UsePreviewTransactionsResult {
const [isLoading, setIsLoading] = useState(isSchedulesLoading);
const [error, setError] = useState<Error | undefined>(undefined);
const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength');
const scheduleTransactions = useMemo(() => {
if (isSchedulesLoading) {
return [];
}
const today = d.startOfDay(parseDate(currentDay()));
const upcomingPeriodEnd = d.startOfDay(
parseDate(addDays(today, parseInt(upcomingLength ?? '7'))),
);
// Kick off an async rules application
const schedulesForPreview = schedules.filter(s =>
isForPreview(s, statuses),
);
return schedulesForPreview.map(schedule => ({
id: 'preview/' + schedule.id,
payee: schedule._payee,
account: schedule._account,
amount: getScheduledAmount(schedule._amount),
date: schedule.next_date,
schedule: schedule.id,
}));
}, [isSchedulesLoading, schedules, statuses]);
return schedulesForPreview
.map(schedule => {
const { date: dateConditions } = extractScheduleConds(
schedule._conditions,
);
let day = parseDate(schedule.next_date);
const dates: Set<string> = new Set();
while (day <= upcomingPeriodEnd) {
const nextDate = getNextDate(dateConditions, day);
day = parseDate(addDays(nextDate, 1));
if (dates.has(nextDate)) break;
if (parseDate(nextDate) > upcomingPeriodEnd) break;
dates.add(nextDate);
}
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: schedules.length > 0,
});
});
return schedules;
})
.flat()
.sort(
(a, b) => parseDate(b.date).getTime() - parseDate(a.date).getTime(),
);
}, [isSchedulesLoading, schedules, statuses, upcomingLength]);
useEffect(() => {
let isUnmounted = false;
@@ -204,7 +255,7 @@ export function usePreviewTransactions(): UsePreviewTransactionsResult {
return () => {
isUnmounted = true;
};
}, [scheduleTransactions, schedules, statuses]);
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
return {
data: previewTransactions,

View File

@@ -1,13 +1,13 @@
// @ts-strict-ignore
import * as monthUtils from '../../shared/months';
import { extractScheduleConds } from '../../shared/schedules';
import { CategoryEntity } from '../../types/models';
import * as db from '../db';
import {
getRuleForSchedule,
getNextDate,
getDateWithSkippedWeekend,
} from '../schedules/app';
extractScheduleConds,
} from '../../shared/schedules';
import { CategoryEntity } from '../../types/models';
import * as db from '../db';
import { getRuleForSchedule } from '../schedules/app';
import { isReflectBudget } from './actions';
import { ScheduleTemplate, Template } from './types/templates';

View File

@@ -2,13 +2,13 @@
import MockDate from 'mockdate';
import { q } from '../../shared/query';
import { getNextDate } from '../../shared/schedules';
import { loadRules, updateRule } from '../accounts/transaction-rules';
import { runQuery as aqlQuery } from '../aql';
import { loadMappings } from '../db/mappings';
import {
updateConditions,
getNextDate,
createSchedule,
updateSchedule,
deleteSchedule,

View File

@@ -13,8 +13,10 @@ import {
getScheduledAmount,
getStatus,
recurConfigToRSchedule,
getNextDate,
getDateWithSkippedWeekend,
} from '../../shared/schedules';
import { Condition, Rule } from '../accounts/rules';
import { Rule } from '../accounts/rules';
import { addTransactions } from '../accounts/sync';
import {
getRules,
@@ -66,41 +68,6 @@ export function updateConditions(conditions, newConditions) {
return updated.concat(added);
}
export function getNextDate(
dateCond,
start = new Date(currentDay()),
noSkipWeekend = false,
) {
start = d.startOfDay(start);
const cond = new Condition(dateCond.op, 'date', dateCond.value, null);
const value = cond.getValue();
if (value.type === 'date') {
return value.date;
} else if (value.type === 'recur') {
let dates = value.schedule.occurrences({ start, take: 1 }).toArray();
if (dates.length === 0) {
// Could be a schedule with limited occurrences, so we try to
// find the last occurrence
dates = value.schedule.occurrences({ reverse: true, take: 1 }).toArray();
}
if (dates.length > 0) {
let date = dates[0].date;
if (value.schedule.data.skipWeekend && !noSkipWeekend) {
date = getDateWithSkippedWeekend(
date,
value.schedule.data.weekendSolve,
);
}
return dayFromDate(date);
}
}
return null;
}
export async function getRuleForSchedule(id: string | null): Promise<Rule> {
if (id == null) {
throw new Error('Schedule not attached to a rule');
@@ -581,19 +548,3 @@ app.events.on('sync', ({ type }) => {
}
}
});
export function getDateWithSkippedWeekend(
date: Date,
solveMode: 'after' | 'before',
) {
if (d.isWeekend(date)) {
if (solveMode === 'after') {
return d.nextMonday(date);
} else if (solveMode === 'before') {
return d.previousFriday(date);
} else {
throw new Error('Unknown weekend solve mode, this should not happen!');
}
}
return date;
}

View File

@@ -1,5 +1,8 @@
// @ts-strict-ignore
import type { IRuleOptions } from '@rschedule/core';
import * as d from 'date-fns';
import { Condition } from '../server/accounts/rules';
import * as monthUtils from './months';
import { q } from './query';
@@ -272,6 +275,57 @@ export function extractScheduleConds(conditions) {
};
}
export function getNextDate(
dateCond,
start = new Date(monthUtils.currentDay()),
noSkipWeekend = false,
) {
start = d.startOfDay(start);
const cond = new Condition(dateCond.op, 'date', dateCond.value, null);
const value = cond.getValue();
if (value.type === 'date') {
return value.date;
} else if (value.type === 'recur') {
let dates = value.schedule.occurrences({ start, take: 1 }).toArray();
if (dates.length === 0) {
// Could be a schedule with limited occurrences, so we try to
// find the last occurrence
dates = value.schedule.occurrences({ reverse: true, take: 1 }).toArray();
}
if (dates.length > 0) {
let date = dates[0].date;
if (value.schedule.data.skipWeekend && !noSkipWeekend) {
date = getDateWithSkippedWeekend(
date,
value.schedule.data.weekendSolve,
);
}
return monthUtils.dayFromDate(date);
}
}
return null;
}
export function getDateWithSkippedWeekend(
date: Date,
solveMode: 'after' | 'before',
) {
if (d.isWeekend(date)) {
if (solveMode === 'after') {
return d.nextMonday(date);
} else if (solveMode === 'before') {
return d.previousFriday(date);
} else {
throw new Error('Unknown weekend solve mode, this should not happen!');
}
}
return date;
}
export function getScheduledAmount(
amount: number | { num1: number; num2: number },
inverse: boolean = false,

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [matt-fidd]
---
Show all occurrences of upcoming schedules within the upcoming period