From cfc18c240a69acf12e033024e6d51796c04044d0 Mon Sep 17 00:00:00 2001 From: Julian Dominguez-Schatz Date: Sat, 21 Feb 2026 15:58:54 -0500 Subject: [PATCH] Add limit/refill automation types (#6692) * Add limit/refill automation components * Add release note * Fix typecheck * Rabbit PR feedback * Review --- .../budget/goals/BudgetAutomation.tsx | 24 ++- .../budget/goals/BudgetAutomationEditor.tsx | 35 ++++- .../budget/goals/BudgetAutomationReadOnly.tsx | 10 +- .../src/components/budget/goals/constants.ts | 16 +- .../budget/goals/editor/LimitAutomation.tsx | 138 ++++++++++++++++++ .../goals/editor/LimitAutomationReadOnly.tsx | 54 +++++++ .../budget/goals/editor/RefillAutomation.tsx | 48 ++++++ .../goals/editor/RefillAutomationReadOnly.tsx | 5 + .../budget/goals/editor/SimpleAutomation.tsx | 40 ----- .../goals/editor/SimpleAutomationReadOnly.tsx | 30 ---- .../src/components/budget/goals/reducer.ts | 60 ++++++-- .../src/components/settings/Format.tsx | 25 +--- .../desktop-client/src/hooks/useDaysOfWeek.ts | 19 +++ upcoming-release-notes/6692.md | 6 + 14 files changed, 385 insertions(+), 125 deletions(-) create mode 100644 packages/desktop-client/src/components/budget/goals/editor/LimitAutomation.tsx create mode 100644 packages/desktop-client/src/components/budget/goals/editor/LimitAutomationReadOnly.tsx create mode 100644 packages/desktop-client/src/components/budget/goals/editor/RefillAutomation.tsx create mode 100644 packages/desktop-client/src/components/budget/goals/editor/RefillAutomationReadOnly.tsx delete mode 100644 packages/desktop-client/src/components/budget/goals/editor/SimpleAutomation.tsx delete mode 100644 packages/desktop-client/src/components/budget/goals/editor/SimpleAutomationReadOnly.tsx create mode 100644 packages/desktop-client/src/hooks/useDaysOfWeek.ts create mode 100644 upcoming-release-notes/6692.md 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 = ( + + + + + dispatch(updateTemplate({ type: 'limit', period: cadence })) + } + options={[ + ['daily', t('Daily')], + ['weekly', t('Weekly')], + ['monthly', t('Monthly')], + ]} + className={selectButtonClassName} + /> + + {period === 'weekly' ? weekdayField : amountField} + + + + {period === 'weekly' && amountField} + + + + setFirstDayOfWeekIdxPref(idx)} - options={daysOfWeek.map(f => [f.value, f.label])} + options={Object.entries(daysOfWeek)} className={selectButtonClassName} /> diff --git a/packages/desktop-client/src/hooks/useDaysOfWeek.ts b/packages/desktop-client/src/hooks/useDaysOfWeek.ts new file mode 100644 index 0000000000..dcfd0b0fe5 --- /dev/null +++ b/packages/desktop-client/src/hooks/useDaysOfWeek.ts @@ -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; + + return daysOfWeek; +} diff --git a/upcoming-release-notes/6692.md b/upcoming-release-notes/6692.md new file mode 100644 index 0000000000..1a442e4493 --- /dev/null +++ b/upcoming-release-notes/6692.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [jfdoming] +--- + +Add limit/refill automation editors