mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
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:
@@ -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 => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
upcoming-release-notes/4166.md
Normal file
6
upcoming-release-notes/4166.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Show all occurrences of upcoming schedules within the upcoming period
|
||||
Reference in New Issue
Block a user