add prompt to convert future transactions to single time schedules (#6065)

This commit is contained in:
Matt Fiddaman
2025-11-06 15:51:03 +00:00
committed by GitHub
parent 407a0d2f7f
commit 6bb90efad3
8 changed files with 507 additions and 24 deletions

View File

@@ -10,6 +10,7 @@ packages/desktop-client/bundle.browser.js
packages/desktop-client/stats.json
packages/desktop-client/.swc/
packages/desktop-client/build/
packages/desktop-client/dev-dist/
packages/desktop-client/locale/
packages/desktop-client/build-electron/
packages/desktop-client/build-stats/
@@ -26,5 +27,7 @@ packages/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
packages/sync-server/coverage/
packages/sync-server/user-files/
packages/sync-server/server-files/
.yarn/*
upcoming-release-notes/*

View File

@@ -84,6 +84,7 @@ export default defineConfig(
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
'packages/desktop-client/dev-dist/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
@@ -100,6 +101,8 @@ export default defineConfig(
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/build/',
'packages/sync-server/user-files/',
'packages/sync-server/server-files/',
'packages/plugins-service/dist/',
'.yarn/*',
'.github/*',

View File

@@ -19,6 +19,7 @@ import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal'
import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
import { CoverModal } from './modals/CoverModal';
import { CreateAccountModal } from './modals/CreateAccountModal';
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
@@ -140,6 +141,9 @@ export function Modals() {
case 'confirm-transaction-edit':
return <ConfirmTransactionEditModal key={key} {...modal.options} />;
case 'convert-to-schedule':
return <ConvertToScheduleModal key={key} {...modal.options} />;
case 'confirm-delete':
return <ConfirmDeleteModal key={key} {...modal.options} />;

View File

@@ -34,7 +34,7 @@ import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query';
import { getStatusLabel } from 'loot-core/shared/schedules';
import { getStatusLabel, getUpcomingDays } from 'loot-core/shared/schedules';
import {
ungroupTransactions,
updateTransaction,
@@ -65,6 +65,7 @@ import {
} from '@desktop-client/components/mobile/MobileForms';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -77,7 +78,9 @@ import {
SingleActiveEditFormProvider,
useSingleActiveEditForm,
} from '@desktop-client/hooks/useSingleActiveEditForm';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useSelector, useDispatch } from '@desktop-client/redux';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
@@ -488,6 +491,9 @@ const TransactionEditInner = memo(function TransactionEditInner({
const navigate = useNavigate();
const dispatch = useDispatch();
const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
const [upcomingLength = '7'] = useSyncedPref(
'upcomingScheduledTransactionLength',
);
const transactions = useMemo(
() =>
unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) ||
@@ -589,6 +595,69 @@ const TransactionEditInner = memo(function TransactionEditInner({
navigate(-1);
};
const today = monthUtils.currentDay();
const isFuture = unserializedTransaction.date > today;
if (isFuture) {
const upcomingDays = getUpcomingDays(upcomingLength, today);
const daysUntilTransaction = monthUtils.differenceInCalendarDays(
unserializedTransaction.date,
today,
);
const isBeyondWindow = daysUntilTransaction > upcomingDays;
dispatch(
pushModal({
modal: {
name: 'convert-to-schedule',
options: {
isBeyondWindow,
daysUntilTransaction,
upcomingDays,
onConfirm: async () => {
if (
!isAdding &&
unserializedTransaction.id &&
!unserializedTransaction.id.startsWith('temp')
) {
await send('transaction-delete', {
id: unserializedTransaction.id,
});
}
const transactionForSchedule = unserializedTransaction.is_parent
? {
...unserializedTransaction,
subtransactions: unserializedTransactions.filter(
t =>
t.is_child &&
t.parent_id === unserializedTransaction.id,
),
}
: unserializedTransaction;
await createSingleTimeScheduleFromTransaction(
transactionForSchedule,
);
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
navigate(-1);
},
onCancel: onConfirmSave,
},
},
}),
);
return;
}
if (unserializedTransaction.reconciled) {
// On mobile any save gives the warning.
// On the web only certain changes trigger a warning.
@@ -608,7 +677,15 @@ const TransactionEditInner = memo(function TransactionEditInner({
} else {
onConfirmSave();
}
}, [isAdding, dispatch, navigate, onSave, unserializedTransactions]);
}, [
isAdding,
dispatch,
navigate,
onSave,
unserializedTransactions,
upcomingLength,
t,
]);
const onUpdateInner = useCallback(
async (serializedTransaction, name, value) => {

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { Block } from '@actual-app/components/block';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
Modal,
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type ConvertToScheduleModalProps = Extract<
ModalType,
{ name: 'convert-to-schedule' }
>['options'];
export function ConvertToScheduleModal({
onCancel,
onConfirm,
isBeyondWindow,
daysUntilTransaction,
upcomingDays,
}: ConvertToScheduleModalProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const narrowButtonStyle = isNarrowWidth
? {
height: styles.mobileMinHeight,
}
: {};
return (
<Modal
name="convert-to-schedule"
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
<>
<ModalHeader
title={t('Convert to Schedule')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Block>
<Trans>
This transaction has a future date. Would you like to convert it
to a single-time schedule instead?
</Trans>
</Block>
{isBeyondWindow ? (
<Block
style={{
marginTop: 10,
padding: 10,
backgroundColor: theme.warningBackground,
borderRadius: 4,
}}
>
<Trans>
<strong>Warning:</strong> This transaction is{' '}
{{ daysUntilTransaction }} days away, which is beyond your
configured upcoming length of {{ upcomingDays }} days. The
schedule preview will not be visible in your account until it
gets closer to the date.
</Trans>
</Block>
) : (
<Block style={{ marginTop: 10 }}>
<Trans>
The transaction will appear as a schedule preview in your
account.
</Trans>
</Block>
)}
<View
style={{
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
<Button
aria-label={t('Cancel')}
style={{
marginRight: 10,
...narrowButtonStyle,
...(isNarrowWidth && { flex: 1 }),
}}
onPress={() => {
close();
onCancel?.();
}}
>
<Trans>No, keep as transaction</Trans>
</Button>
<InitialFocus>
<Button
aria-label={t('Convert to Schedule')}
variant="primary"
style={{
...narrowButtonStyle,
...(isNarrowWidth && { flex: 1 }),
}}
onPress={() => {
close();
onConfirm();
}}
>
<Trans>Yes, create schedule</Trans>
</Button>
</InitialFocus>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -1,10 +1,14 @@
// @ts-strict-ignore
// TODO: remove strict
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from '@actual-app/components/theme';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import { getUpcomingDays } from 'loot-core/shared/schedules';
import {
addSplitTransaction,
applyTransactionDiff,
@@ -17,6 +21,7 @@ import {
type AccountEntity,
type CategoryEntity,
type PayeeEntity,
type RuleActionEntity,
type RuleConditionEntity,
type ScheduleEntity,
type TransactionEntity,
@@ -78,6 +83,157 @@ async function saveDiffAndApply(diff, changes, onChange, learnCategories) {
);
}
export async function createSingleTimeScheduleFromTransaction(
transaction: TransactionEntity,
): Promise<ScheduleEntity['id']> {
const conditions: RuleConditionEntity[] = [
{ op: 'is', field: 'date', value: transaction.date },
];
const actions: RuleActionEntity[] = [];
const conditionFields = ['amount', 'payee', 'account'];
conditionFields.forEach(field => {
const value = transaction[field];
if (value != null && value !== '') {
conditions.push({
op: 'is',
field,
value,
} as RuleConditionEntity);
}
});
if (transaction.is_parent && transaction.subtransactions) {
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
options: {
splitIndex: 0,
},
} as RuleActionEntity);
}
transaction.subtransactions.forEach((split, index) => {
const splitIndex = index + 1;
if (split.amount != null) {
actions.push({
op: 'set-split-amount',
value: split.amount,
options: {
splitIndex,
method: 'fixed-amount',
},
} as RuleActionEntity);
}
if (split.category) {
actions.push({
op: 'set',
field: 'category',
value: split.category,
options: {
splitIndex,
},
} as RuleActionEntity);
}
if (split.notes) {
actions.push({
op: 'set',
field: 'notes',
value: split.notes,
options: {
splitIndex,
},
} as RuleActionEntity);
}
});
} else {
if (transaction.category) {
actions.push({
op: 'set',
field: 'category',
value: transaction.category,
} as RuleActionEntity);
}
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
} as RuleActionEntity);
}
}
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
const timestamp = Date.now();
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
const scheduleId = await send('schedule/create', {
conditions,
schedule: {
posts_transaction: true,
name: scheduleName,
},
});
if (actions.length > 0) {
const schedules = await send(
'query',
q('schedules').filter({ id: scheduleId }).select('rule').serialize(),
);
const ruleId = schedules?.data?.[0]?.rule;
if (ruleId) {
const rule = await send('rule-get', { id: ruleId });
if (rule) {
const linkScheduleActions = rule.actions.filter(
a => a.op === 'link-schedule',
);
await send('rule-update', {
...rule,
actions: [...linkScheduleActions, ...actions],
});
}
}
}
return scheduleId;
}
function isFutureTransaction(transaction: TransactionEntity): boolean {
const today = monthUtils.currentDay();
return transaction.date > today;
}
function calculateFutureTransactionInfo(
transaction: TransactionEntity,
upcomingLength: string,
) {
const today = monthUtils.currentDay();
const upcomingDays = getUpcomingDays(upcomingLength, today);
const daysUntilTransaction = monthUtils.differenceInCalendarDays(
transaction.date,
today,
);
const isBeyondWindow = daysUntilTransaction > upcomingDays;
return {
isBeyondWindow,
daysUntilTransaction,
upcomingDays,
};
}
type TransactionListProps = Pick<
TransactionTableProps,
| 'accounts'
@@ -164,54 +320,152 @@ export function TransactionList({
onScheduleAction,
onMakeAsNonSplitTransactions,
}: TransactionListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const [learnCategories = 'true'] = useSyncedPref('learn-categories');
const isLearnCategoriesEnabled = String(learnCategories) === 'true';
const [upcomingLength = '7'] = useSyncedPref(
'upcomingScheduledTransactionLength',
);
const transactionsLatest = useRef<readonly TransactionEntity[]>([]);
useLayoutEffect(() => {
transactionsLatest.current = transactions;
}, [transactions]);
const promptToConvertToSchedule = useCallback(
(
transaction: TransactionEntity,
onConfirm: () => Promise<void>,
onCancel: () => Promise<void>,
) => {
const futureInfo = calculateFutureTransactionInfo(
transaction,
upcomingLength,
);
dispatch(
pushModal({
modal: {
name: 'convert-to-schedule',
options: {
...futureInfo,
onConfirm: async () => {
await onConfirm();
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
onRefetch();
},
onCancel: async () => {
await onCancel();
onRefetch();
},
},
},
}),
);
},
[dispatch, onRefetch, upcomingLength, t],
);
const onAdd = useCallback(
async (newTransactions: TransactionEntity[]) => {
newTransactions = realizeTempTransactions(newTransactions);
const parentTransaction = newTransactions.find(t => !t.is_child);
if (parentTransaction && isFutureTransaction(parentTransaction)) {
const transactionWithSubtransactions = {
...parentTransaction,
subtransactions: newTransactions.filter(
t => t.is_child && t.parent_id === parentTransaction.id,
),
};
promptToConvertToSchedule(
transactionWithSubtransactions,
async () => {
await createSingleTimeScheduleFromTransaction(
transactionWithSubtransactions,
);
},
async () => {
await saveDiff(
{ added: newTransactions },
isLearnCategoriesEnabled,
);
},
);
return;
}
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
onRefetch();
},
[isLearnCategoriesEnabled, onRefetch],
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
);
const onSave = useCallback(
async (transaction: TransactionEntity) => {
const changes = updateTransaction(
transactionsLatest.current,
transaction,
);
transactionsLatest.current = changes.data;
const saveTransaction = async () => {
const changes = updateTransaction(
transactionsLatest.current,
transaction,
);
transactionsLatest.current = changes.data;
if (changes.diff.updated.length > 0) {
const dateChanged = !!changes.diff.updated[0].date;
if (dateChanged) {
// Make sure it stays at the top of the list of transactions
// for that date
changes.diff.updated[0].sort_order = Date.now();
await saveDiff(changes.diff, isLearnCategoriesEnabled);
onRefetch();
} else {
onChange(changes.newTransaction, changes.data);
saveDiffAndApply(
changes.diff,
changes,
onChange,
isLearnCategoriesEnabled,
if (changes.diff.updated.length > 0) {
const dateChanged = !!changes.diff.updated[0].date;
if (dateChanged) {
changes.diff.updated[0].sort_order = Date.now();
await saveDiff(changes.diff, isLearnCategoriesEnabled);
onRefetch();
} else {
onChange(changes.newTransaction, changes.data);
saveDiffAndApply(
changes.diff,
changes,
onChange,
isLearnCategoriesEnabled,
);
}
}
};
if (isFutureTransaction(transaction)) {
const originalTransaction = transactionsLatest.current.find(
t => t.id === transaction.id,
);
const dateChanged =
!originalTransaction || originalTransaction.date !== transaction.date;
if (dateChanged || !originalTransaction) {
promptToConvertToSchedule(
transaction,
async () => {
if (transaction.id && !transaction.id.startsWith('temp')) {
await send('transaction-delete', { id: transaction.id });
}
await createSingleTimeScheduleFromTransaction(transaction);
},
saveTransaction,
);
return;
}
}
await saveTransaction();
},
[isLearnCategoriesEnabled, onChange, onRefetch],
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
);
const onAddSplit = useCallback(

View File

@@ -482,6 +482,16 @@ export type Modal =
confirmReason: string;
};
}
| {
name: 'convert-to-schedule';
options: {
onConfirm: () => void;
onCancel?: () => void;
isBeyondWindow?: boolean;
daysUntilTransaction?: number;
upcomingDays?: number;
};
}
| {
name: 'confirm-delete';
options: {

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [matt-fidd]
---
Add prompt to convert future transactions to single time schedules