diff --git a/packages/desktop-client/src/components/budget/goals/BudgetAutomation.tsx b/packages/desktop-client/src/components/budget/goals/BudgetAutomation.tsx
index 7f1adfb214..584dcce077 100644
--- a/packages/desktop-client/src/components/budget/goals/BudgetAutomation.tsx
+++ b/packages/desktop-client/src/components/budget/goals/BudgetAutomation.tsx
@@ -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}
/>
)}
diff --git a/packages/desktop-client/src/components/budget/goals/BudgetAutomationEditor.tsx b/packages/desktop-client/src/components/budget/goals/BudgetAutomationEditor.tsx
index 8d4bdd45d5..d54e774725 100644
--- a/packages/desktop-client/src/components/budget/goals/BudgetAutomationEditor.tsx
+++ b/packages/desktop-client/src/components/budget/goals/BudgetAutomationEditor.tsx
@@ -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: Add a fixed amount to this category each month.,
+ limit: (
+
+ 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).
+
+ ),
+ refill: (
+
+ Refill the category up to the balance limit set by the balance limit
+ automation.
+
+ ),
week: (
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 = (
-
+
+ );
+ break;
+ case 'refill':
+ automationEditor = (
+
);
break;
case 'week':
@@ -116,6 +142,7 @@ export function BudgetAutomationEditor({
);
break;
default:
+ state satisfies never;
automationEditor = (
Unrecognized automation type.
diff --git a/packages/desktop-client/src/components/budget/goals/BudgetAutomationReadOnly.tsx b/packages/desktop-client/src/components/budget/goals/BudgetAutomationReadOnly.tsx
index 72862f4875..f5d46ebf3d 100644
--- a/packages/desktop-client/src/components/budget/goals/BudgetAutomationReadOnly.tsx
+++ b/packages/desktop-client/src/components/budget/goals/BudgetAutomationReadOnly.tsx
@@ -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 = (
-
+
);
break;
+ case 'refill':
+ automationReadOnly = ;
+ break;
case 'week':
automationReadOnly = ;
break;
diff --git a/packages/desktop-client/src/components/budget/goals/constants.ts b/packages/desktop-client/src/components/budget/goals/constants.ts
index b8af0ab5b4..2d59b11fc4 100644
--- a/packages/desktop-client/src/components/budget/goals/constants.ts
+++ b/packages/desktop-client/src/components/budget/goals/constants.ts
@@ -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;
diff --git a/packages/desktop-client/src/components/budget/goals/editor/LimitAutomation.tsx b/packages/desktop-client/src/components/budget/goals/editor/LimitAutomation.tsx
new file mode 100644
index 0000000000..0155e23d53
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/goals/editor/LimitAutomation.tsx
@@ -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 = (
+
+
+
+
+ );
+
+ const amountField = (
+
+
+
+ dispatch(
+ updateTemplate({
+ type: 'limit',
+ amount: integerToAmount(value, format.currency.decimalPlaces),
+ }),
+ )
+ }
+ />
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {period === 'weekly' ? weekdayField : amountField}
+
+
+
+ {period === 'weekly' && amountField}
+
+
+
+
+ {period !== 'weekly' && }
+
+ >
+ );
+};
diff --git a/packages/desktop-client/src/components/budget/goals/editor/LimitAutomationReadOnly.tsx b/packages/desktop-client/src/components/budget/goals/editor/LimitAutomationReadOnly.tsx
new file mode 100644
index 0000000000..0db99fa656
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/goals/editor/LimitAutomationReadOnly.tsx
@@ -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 ? (
+ Set a balance limit of {{ daily: amount }}/day (soft cap)
+ ) : (
+ Set a balance limit of {{ daily: amount }}/day (hard cap)
+ );
+ case 'weekly':
+ return hold ? (
+
+ Set a balance limit of {{ weekly: amount }}/week (soft cap)
+
+ ) : (
+
+ Set a balance limit of {{ weekly: amount }}/week (hard cap)
+
+ );
+ case 'monthly':
+ return hold ? (
+
+ Set a balance limit of {{ monthly: amount }}/month (soft cap)
+
+ ) : (
+
+ Set a balance limit of {{ monthly: amount }}/month (hard cap)
+
+ );
+ default:
+ return null;
+ }
+};
diff --git a/packages/desktop-client/src/components/budget/goals/editor/RefillAutomation.tsx b/packages/desktop-client/src/components/budget/goals/editor/RefillAutomation.tsx
new file mode 100644
index 0000000000..0b1df5d7dd
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/goals/editor/RefillAutomation.tsx
@@ -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 (
+
+
+ Uses the balance limit automation for this category.
+
+ {!hasLimitAutomation && (
+
+
+
+
+ Add a balance limit automation to set the refill target.
+
+
+ {onAddLimitAutomation && (
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/budget/goals/editor/RefillAutomationReadOnly.tsx b/packages/desktop-client/src/components/budget/goals/editor/RefillAutomationReadOnly.tsx
new file mode 100644
index 0000000000..333ede2d6d
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/goals/editor/RefillAutomationReadOnly.tsx
@@ -0,0 +1,5 @@
+import { Trans } from 'react-i18next';
+
+export const RefillAutomationReadOnly = () => {
+ return Refill to balance limit;
+};
diff --git a/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomation.tsx b/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomation.tsx
deleted file mode 100644
index f6fac8595d..0000000000
--- a/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomation.tsx
+++ /dev/null
@@ -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 (
-
-
-
- dispatch(
- updateTemplate({
- type: 'simple',
- monthly: value,
- }),
- )
- }
- />
-
- );
-};
diff --git a/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomationReadOnly.tsx b/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomationReadOnly.tsx
deleted file mode 100644
index eabca674ad..0000000000
--- a/packages/desktop-client/src/components/budget/goals/editor/SimpleAutomationReadOnly.tsx
+++ /dev/null
@@ -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 (
-
- Budget{' '}
-
- {
- {
- amount: format(template.monthly ?? 0, 'financial'),
- } as TransObjectLiteral
- }
- {' '}
- each month
-
- );
-};
diff --git a/packages/desktop-client/src/components/budget/goals/reducer.ts b/packages/desktop-client/src/components/budget/goals/reducer.ts
index ab6e5e1541..455c7b3ba1 100644
--- a/packages/desktop-client/src/components/budget/goals/reducer.ts
+++ b/packages/desktop-client/src/components/budget/goals/reducer.ts
@@ -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}`);
}
};
diff --git a/packages/desktop-client/src/components/settings/Format.tsx b/packages/desktop-client/src/components/settings/Format.tsx
index a3cd122e0c..2464044e05 100644
--- a/packages/desktop-client/src/components/settings/Format.tsx
+++ b/packages/desktop-client/src/components/settings/Format.tsx
@@ -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() {