Add limit/refill automation types (#6692)

* Add limit/refill automation components

* Add release note

* Fix typecheck

* Rabbit PR feedback

* Review
This commit is contained in:
Julian Dominguez-Schatz
2026-02-21 15:58:54 -05:00
committed by GitHub
parent a68b2acac3
commit cfc18c240a
14 changed files with 385 additions and 125 deletions

View File

@@ -3,6 +3,7 @@ import { useMemo, useReducer, useRef, useState } from 'react';
import { SpaceBetween } from '@actual-app/components/space-between';
import type { CSSProperties } from '@actual-app/components/styles';
import { firstDayOfMonth } from 'loot-core/shared/months';
import type {
CategoryGroupEntity,
ScheduleEntity,
@@ -11,6 +12,7 @@ import type { Template } from 'loot-core/types/models/templates';
import { BudgetAutomationEditor } from './BudgetAutomationEditor';
import { BudgetAutomationReadOnly } from './BudgetAutomationReadOnly';
import type { DisplayTemplateType } from './constants';
import { DEFAULT_PRIORITY, getInitialState, templateReducer } from './reducer';
import { useEffectAfterMount } from '@desktop-client/hooks/useEffectAfterMount';
@@ -19,17 +21,24 @@ type BudgetAutomationProps = {
categories: CategoryGroupEntity[];
schedules: readonly ScheduleEntity[];
template?: Template;
onSave?: (template: Template) => void;
onSave?: (template: Template, displayType: DisplayTemplateType) => void;
onDelete?: () => void;
style?: CSSProperties;
readOnlyStyle?: CSSProperties;
inline?: boolean;
hasLimitAutomation?: boolean;
onAddLimitAutomation?: () => void;
};
const DEFAULT_TEMPLATE: Template = {
directive: 'template',
type: 'simple',
monthly: 0,
type: 'periodic',
amount: 0,
period: {
period: 'month',
amount: 1,
},
starting: firstDayOfMonth(new Date()),
priority: DEFAULT_PRIORITY,
};
@@ -42,18 +51,19 @@ export const BudgetAutomation = ({
style,
template,
inline = false,
hasLimitAutomation,
onAddLimitAutomation,
}: BudgetAutomationProps) => {
const [isEditing, setIsEditing] = useState(false);
const [state, dispatch] = useReducer(
templateReducer,
const [state, dispatch] = useReducer(templateReducer, null, () =>
getInitialState(template ?? DEFAULT_TEMPLATE),
);
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffectAfterMount(() => {
onSaveRef.current?.(state.template);
onSaveRef.current?.(state.template, state.displayType);
}, [state]);
const categoryNameMap = useMemo(() => {
@@ -91,6 +101,8 @@ export const BudgetAutomation = ({
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
)}
</SpaceBetween>

View File

@@ -18,9 +18,10 @@ import type { Action } from './actions';
import { displayTemplateTypes } from './constants';
import type { ReducerState } from './constants';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
import { SimpleAutomation } from './editor/SimpleAutomation';
import { WeekAutomation } from './editor/WeekAutomation';
import {
@@ -35,10 +36,25 @@ type BudgetAutomationEditorProps = {
dispatch: (action: Action) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation?: boolean;
onAddLimitAutomation?: () => void;
};
const displayTypeToDescription = {
simple: <Trans>Add a fixed amount to this category each month.</Trans>,
limit: (
<Trans>
Set a cap for all budget contributions to this category across all
automations. The maximum can be set on a monthly, weekly, or daily basis.
For example, a $100 weekly cap would result in a $400 monthly cap ($500
depending on the month).
</Trans>
),
refill: (
<Trans>
Refill the category up to the balance limit set by the balance limit
automation.
</Trans>
),
week: (
<Trans>
Add a fixed amount to this category for each week in the month. For
@@ -77,14 +93,24 @@ export function BudgetAutomationEditor({
dispatch,
schedules,
categories,
hasLimitAutomation = false,
onAddLimitAutomation,
}: BudgetAutomationEditorProps) {
const { t } = useTranslation();
let automationEditor: ReactNode;
switch (state.displayType) {
case 'simple':
case 'limit':
automationEditor = (
<SimpleAutomation template={state.template} dispatch={dispatch} />
<LimitAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'refill':
automationEditor = (
<RefillAutomation
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
);
break;
case 'week':
@@ -116,6 +142,7 @@ export function BudgetAutomationEditor({
);
break;
default:
state satisfies never;
automationEditor = (
<Text>
<Trans>Unrecognized automation type.</Trans>

View File

@@ -15,9 +15,10 @@ import { View } from '@actual-app/components/view';
import type { ReducerState } from './constants';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
import { SimpleAutomationReadOnly } from './editor/SimpleAutomationReadOnly';
import { WeekAutomationReadOnly } from './editor/WeekAutomationReadOnly';
type BudgetAutomationReadOnlyProps = {
@@ -43,11 +44,14 @@ export function BudgetAutomationReadOnly({
let automationReadOnly;
switch (state.displayType) {
case 'simple':
case 'limit':
automationReadOnly = (
<SimpleAutomationReadOnly template={state.template} />
<LimitAutomationReadOnly template={state.template} />
);
break;
case 'refill':
automationReadOnly = <RefillAutomationReadOnly />;
break;
case 'week':
automationReadOnly = <WeekAutomationReadOnly template={state.template} />;
break;

View File

@@ -1,16 +1,18 @@
import type {
AverageTemplate,
CopyTemplate,
LimitTemplate,
PercentageTemplate,
PeriodicTemplate,
RefillTemplate,
ScheduleTemplate,
SimpleTemplate,
} from 'loot-core/types/models/templates';
export const displayTemplateTypes = [
['simple', 'Fixed (monthly)'] as const,
['limit', 'Balance limit'] as const,
['refill', 'Refill'] as const,
['week', 'Fixed (weekly)'] as const,
['schedule', 'Schedule'] as const,
['schedule', 'Existing schedule'] as const,
['percentage', 'Percent of category'] as const,
['historical', 'Copy past budgets'] as const,
];
@@ -19,8 +21,12 @@ export type DisplayTemplateType = (typeof displayTemplateTypes)[number][0];
export type ReducerState =
| {
template: SimpleTemplate;
displayType: 'simple';
template: LimitTemplate;
displayType: 'limit';
}
| {
template: RefillTemplate;
displayType: 'refill';
}
| {
template: PeriodicTemplate;

View File

@@ -0,0 +1,138 @@
import { useTranslation } from 'react-i18next';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { getDay } from 'date-fns/getDay';
import { setDay } from 'date-fns/setDay';
import { currentDate, dayFromDate, parseDate } from 'loot-core/shared/months';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
import type { LimitTemplate } from 'loot-core/types/models/templates';
import { updateTemplate } from '@desktop-client/components/budget/goals/actions';
import type { Action } from '@desktop-client/components/budget/goals/actions';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useDaysOfWeek } from '@desktop-client/hooks/useDaysOfWeek';
import { useFormat } from '@desktop-client/hooks/useFormat';
type LimitAutomationProps = {
template: LimitTemplate;
dispatch: (action: Action) => void;
};
export const LimitAutomation = ({
template,
dispatch,
}: LimitAutomationProps) => {
const { t } = useTranslation();
const format = useFormat();
const daysOfWeek = useDaysOfWeek();
const period = template.period;
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const start = template.start;
const dayOfWeek = start ? getDay(parseDate(start)) : 0;
const hold = template.hold;
const selectButtonClassName = css({
'&[data-hovered]': {
backgroundColor: theme.buttonNormalBackgroundHover,
},
});
const weekdayField = (
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Weekday')} htmlFor="weekday-field" />
<Select
id="weekday-field"
value={dayOfWeek.toString()}
onChange={value =>
dispatch(
updateTemplate({
type: 'limit',
start: dayFromDate(setDay(currentDate(), Number(value))),
}),
)
}
options={Object.entries(daysOfWeek)}
className={selectButtonClassName}
/>
</FormField>
);
const amountField = (
<FormField key="amount-field" style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'limit',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
);
return (
<>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField key="cadence-field" style={{ flex: 1 }}>
<FormLabel title={t('Cadence')} htmlFor="cadence-field" />
<Select
id="cadence-field"
value={period}
onChange={cadence =>
dispatch(updateTemplate({ type: 'limit', period: cadence }))
}
options={[
['daily', t('Daily')],
['weekly', t('Weekly')],
['monthly', t('Monthly')],
]}
className={selectButtonClassName}
/>
</FormField>
{period === 'weekly' ? weekdayField : amountField}
</SpaceBetween>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
{period === 'weekly' && amountField}
<FormField key="excess-funds-field" style={{ flex: 1 }}>
<FormLabel
title={t('Excess funds mode')}
htmlFor="excess-funds-field"
/>
<Select
id="excess-funds-field"
value={hold}
onChange={value =>
dispatch(updateTemplate({ type: 'limit', hold: value }))
}
options={[
[false, t('Remove all funds over the limit')],
[true, t('Retain any funds over the limit')],
]}
className={selectButtonClassName}
/>
</FormField>
{period !== 'weekly' && <View style={{ flex: 1 }} />}
</SpaceBetween>
</>
);
};

View File

@@ -0,0 +1,54 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from 'loot-core/shared/util';
import type { LimitTemplate } from 'loot-core/types/models/templates';
import { useFormat } from '@desktop-client/hooks/useFormat';
type LimitAutomationReadOnlyProps = {
template: LimitTemplate;
};
export const LimitAutomationReadOnly = ({
template,
}: LimitAutomationReadOnlyProps) => {
const format = useFormat();
const period = template.period;
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const hold = template.hold;
switch (period) {
case 'daily':
return hold ? (
<Trans>Set a balance limit of {{ daily: amount }}/day (soft cap)</Trans>
) : (
<Trans>Set a balance limit of {{ daily: amount }}/day (hard cap)</Trans>
);
case 'weekly':
return hold ? (
<Trans>
Set a balance limit of {{ weekly: amount }}/week (soft cap)
</Trans>
) : (
<Trans>
Set a balance limit of {{ weekly: amount }}/week (hard cap)
</Trans>
);
case 'monthly':
return hold ? (
<Trans>
Set a balance limit of {{ monthly: amount }}/month (soft cap)
</Trans>
) : (
<Trans>
Set a balance limit of {{ monthly: amount }}/month (hard cap)
</Trans>
);
default:
return null;
}
};

View File

@@ -0,0 +1,48 @@
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Warning } from '@desktop-client/components/alerts';
type RefillAutomationProps = {
hasLimitAutomation: boolean;
onAddLimitAutomation?: () => void;
};
export function RefillAutomation({
hasLimitAutomation,
onAddLimitAutomation,
}: RefillAutomationProps) {
const { t } = useTranslation();
return (
<SpaceBetween direction="vertical" gap={10} style={{ marginTop: 10 }}>
<Text>
<Trans>Uses the balance limit automation for this category.</Trans>
</Text>
{!hasLimitAutomation && (
<Warning>
<SpaceBetween gap={10} align="center" style={{ flexWrap: 'wrap' }}>
<View>
<Trans>
Add a balance limit automation to set the refill target.
</Trans>
</View>
{onAddLimitAutomation && (
<Button
variant="bare"
onPress={onAddLimitAutomation}
aria-label={t('Add balance limit automation')}
>
<Trans>Add balance limit</Trans>
</Button>
)}
</SpaceBetween>
</Warning>
)}
</SpaceBetween>
);
}

View File

@@ -0,0 +1,5 @@
import { Trans } from 'react-i18next';
export const RefillAutomationReadOnly = () => {
return <Trans>Refill to balance limit</Trans>;
};

View File

@@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { SimpleTemplate } from 'loot-core/types/models/templates';
import { updateTemplate } from '@desktop-client/components/budget/goals/actions';
import type { Action } from '@desktop-client/components/budget/goals/actions';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
type SimpleAutomationProps = {
template: SimpleTemplate;
dispatch: (action: Action) => void;
};
export const SimpleAutomation = ({
template,
dispatch,
}: SimpleAutomationProps) => {
const { t } = useTranslation();
return (
<FormField>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
key="amount-input"
value={template.monthly ?? 0}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'simple',
monthly: value,
}),
)
}
/>
</FormField>
);
};

View File

@@ -1,30 +0,0 @@
import { Trans } from 'react-i18next';
import type { SimpleTemplate } from 'loot-core/types/models/templates';
import type { TransObjectLiteral } from 'loot-core/types/util';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { useFormat } from '@desktop-client/hooks/useFormat';
type SimpleAutomationReadOnlyProps = {
template: SimpleTemplate;
};
export const SimpleAutomationReadOnly = ({
template,
}: SimpleAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget{' '}
<FinancialText>
{
{
amount: format(template.monthly ?? 0, 'financial'),
} as TransObjectLiteral
}
</FinancialText>{' '}
each month
</Trans>
);
};

View File

@@ -1,3 +1,4 @@
import { firstDayOfMonth } from 'loot-core/shared/months';
import type { Template } from 'loot-core/types/models/templates';
import type { Action } from './actions';
@@ -13,8 +14,18 @@ export const getInitialState = (template: Template | null): ReducerState => {
switch (type) {
case 'simple':
return {
template,
displayType: 'simple',
template: {
type: 'periodic',
amount: template.monthly ?? 0,
period: {
period: 'month',
amount: 1,
},
starting: firstDayOfMonth(new Date()),
priority: template.priority,
directive: template.directive,
},
displayType: 'week',
};
case 'percentage':
return {
@@ -37,9 +48,15 @@ export const getInitialState = (template: Template | null): ReducerState => {
case 'remainder':
throw new Error('Remainder is not yet supported');
case 'limit':
throw new Error('Limit is not yet supported');
return {
template,
displayType: 'limit',
};
case 'refill':
throw new Error('Refill is not yet supported');
return {
template,
displayType: 'refill',
};
case 'average':
case 'copy':
return {
@@ -60,16 +77,30 @@ const changeType = (
visualType: DisplayTemplateType,
): ReducerState => {
switch (visualType) {
case 'simple':
if (prevState.template.type === 'simple') {
case 'limit':
if (prevState.template.type === 'limit') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'simple',
monthly: 5,
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
};
case 'refill':
if (prevState.template.type === 'refill') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'refill',
priority: DEFAULT_PRIORITY,
},
};
@@ -187,13 +218,10 @@ function mapTemplateTypesForUpdate(
}
if (state.template.type === template.type) {
const { type: _1, directive: _2, ...rest } = template;
const mergedTemplate = Object.assign({}, state.template, template);
return {
...state,
...getInitialState({
...state.template,
...rest,
}),
...getInitialState(mergedTemplate),
};
}
@@ -207,7 +235,8 @@ export const templateReducer = (
state: ReducerState,
action: Action,
): ReducerState => {
switch (action.type) {
const type = action.type;
switch (type) {
case 'set-type':
return {
...state,
@@ -221,6 +250,7 @@ export const templateReducer = (
case 'update-template':
return mapTemplateTypesForUpdate(state, action.payload);
default:
return state;
// Make sure we're not missing any cases
throw new Error(`Unknown display type: ${type satisfies never}`);
}
};

View File

@@ -17,28 +17,9 @@ import { Column, Setting } from './UI';
import { Checkbox } from '@desktop-client/components/forms';
import { useSidebar } from '@desktop-client/components/sidebar/SidebarProvider';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useDaysOfWeek } from '@desktop-client/hooks/useDaysOfWeek';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
// Follows Pikaday 'firstDay' numbering
// https://github.com/Pikaday/Pikaday
function useDaysOfWeek() {
const { t } = useTranslation();
const daysOfWeek: {
value: SyncedPrefs['firstDayOfWeekIdx'];
label: string;
}[] = [
{ value: '0', label: t('Sunday') },
{ value: '1', label: t('Monday') },
{ value: '2', label: t('Tuesday') },
{ value: '3', label: t('Wednesday') },
{ value: '4', label: t('Thursday') },
{ value: '5', label: t('Friday') },
{ value: '6', label: t('Saturday') },
] as const;
return { daysOfWeek };
}
const dateFormats: { value: SyncedPrefs['dateFormat']; label: string }[] = [
{ value: 'MM/dd/yyyy', label: 'MM/DD/YYYY' },
{ value: 'dd/MM/yyyy', label: 'DD/MM/YYYY' },
@@ -61,7 +42,7 @@ export function FormatSettings() {
const numberFormat = _numberFormat || 'comma-dot';
const [hideFraction, setHideFractionPref] = useSyncedPref('hideFraction');
const { daysOfWeek } = useDaysOfWeek();
const daysOfWeek = useDaysOfWeek();
const selectButtonClassName = css({
'&[data-hovered]': {
@@ -125,7 +106,7 @@ export function FormatSettings() {
<Select
value={firstDayOfWeekIdx}
onChange={idx => setFirstDayOfWeekIdxPref(idx)}
options={daysOfWeek.map(f => [f.value, f.label])}
options={Object.entries(daysOfWeek)}
className={selectButtonClassName}
/>
</Column>

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
// Follows Pikaday 'firstDay' numbering
// https://github.com/Pikaday/Pikaday
export function useDaysOfWeek() {
const { t } = useTranslation();
const daysOfWeek = {
0: t('Sunday'),
1: t('Monday'),
2: t('Tuesday'),
3: t('Wednesday'),
4: t('Thursday'),
5: t('Friday'),
6: t('Saturday'),
} satisfies Record<number, string>;
return daysOfWeek;
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jfdoming]
---
Add limit/refill automation editors