Compare commits

..

11 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
efaee98d2a Update error message 2026-01-12 15:29:58 -08:00
Joel Jeremy Marquez
0b156d1815 Add onInsert logic to useDragAndDrop 2026-01-12 15:00:45 -08:00
autofix-ci[bot]
5e9f38ea45 [autofix.ci] apply automated fixes 2026-01-12 21:54:47 +00:00
Joel Jeremy Marquez
b901e7a6bd [Mobile] Fix drag and drop across category groups 2026-01-12 13:53:34 -08:00
Matiss Janis Aboltins
843e957757 Remove force reload feature (#6626)
* Remove force reload feature flag and related code

Co-authored-by: matiss <matiss@mja.lv>

* Add release notes for PR #6626

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-12 08:39:04 +00:00
RMcGhee
073725e270 Bug/1617 rules modal error (#6625)
* Remove error thrown and nonprod check

* Formatting

* Added release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-11 22:11:08 +00:00
Çağdaş Şenel
0e20e17fa4 enable include current month option for last month (#6577) 2026-01-11 20:32:19 +00:00
Matiss Janis Aboltins
f1fd99eeac docs: blog post for Actual Budget Wrapped 2025 (#6580)
* Add blog post for Actual Budget Wrapped 2025

* Add release notes for PR #6580

* Delete upcoming-release-notes/6580.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-10 12:00:36 +00:00
RMcGhee
cf3a42792f Bug/5679 payee filter (#6594)
* Fix filters, added tests

* Added release notes

* [autofix.ci] apply automated fixes

* Fix missing awaits in test, use placeholder locator

* Added release notes file

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6594

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-10 10:16:08 +00:00
dependabot[bot]
d41af58daf chore(deps-dev): bump react-router from 7.9.6 to 7.12.0 in /packages/desktop-client (#6608)
* chore(deps-dev): bump react-router in /packages/desktop-client

Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.9.6 to 7.12.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Add release notes for PR #6608

* fix release notes

Updated authors field to remove bot notation.

* yarn lock

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>
2026-01-10 09:50:36 +00:00
youngcw
25ee19c1e1 [Goals] Fix some schedule template regressions (#6610)
* Small fix

* Add release notes for PR #6610

* Apply suggestions from code review

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

* review

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
2026-01-10 00:29:38 +00:00
37 changed files with 335 additions and 733 deletions

View File

@@ -14,7 +14,6 @@
"./block": "./src/Block.tsx",
"./button": "./src/Button.tsx",
"./card": "./src/Card.tsx",
"./combo-box": "./src/ComboBox.tsx",
"./form-error": "./src/FormError.tsx",
"./initial-focus": "./src/InitialFocus.ts",
"./inline-field": "./src/InlineField.tsx",

View File

@@ -1,198 +0,0 @@
import {
type ComponentProps,
useRef,
useContext,
type KeyboardEvent,
useEffect,
createContext,
type ReactNode,
} from 'react';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
ListBoxSection,
type ListBoxSectionProps,
type ComboBoxProps as AriaComboBoxProps,
type ListBoxItemProps,
ComboBoxStateContext as AriaComboBoxStateContext,
type Key,
} from 'react-aria-components';
import { type ComboBoxState as AriaComboBoxState } from 'react-stately';
import { css, cx } from '@emotion/css';
import { Input } from './Input';
import { Popover } from './Popover';
import { styles } from './styles';
import { theme } from './theme';
import { View } from './View';
const popoverClassName = () =>
css({
...styles.darkScrollbar,
...styles.popover,
backgroundColor: theme.menuAutoCompleteBackground,
color: theme.menuAutoCompleteText,
padding: '5px 0',
borderRadius: 4,
});
const listBoxClassName = ({ width }: { width?: number }) =>
css({
width,
minWidth: 200,
maxHeight: 200,
overflow: 'auto',
'& [data-focused]': {
backgroundColor: theme.menuAutoCompleteBackgroundHover,
},
});
type ComboBoxProps<T extends object> = Omit<
AriaComboBoxProps<T>,
'children'
> & {
inputPlaceholder?: string;
children: ComponentProps<typeof ListBox<T>>['children'];
};
export function ComboBox<T extends object>({
children,
...props
}: ComboBoxProps<T>) {
const viewRef = useRef<HTMLDivElement | null>(null);
return (
<AriaComboBox<T>
allowsEmptyCollection
allowsCustomValue
menuTrigger="focus"
{...props}
>
<View ref={viewRef}>
<ComboBoxInput placeholder={props.inputPlaceholder} />
</View>
<Popover isNonModal className={popoverClassName()}>
<ListBox<T>
className={listBoxClassName({ width: viewRef.current?.clientWidth })}
>
{children}
</ListBox>
</Popover>
</AriaComboBox>
);
}
type ComboBoxInputContextValue = {
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
};
const ComboBoxInputContext = createContext<ComboBoxInputContextValue | null>(
null,
);
type ComboBoxInputProviderProps = {
children: ReactNode;
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
};
export function ComboBoxInputProvider({
children,
getFocusedKey,
}: ComboBoxInputProviderProps) {
return (
<ComboBoxInputContext.Provider value={{ getFocusedKey }}>
{children}
</ComboBoxInputContext.Provider>
);
}
type ComboBoxInputProps = ComponentProps<typeof Input>;
function ComboBoxInput({ onKeyUp, ...props }: ComboBoxInputProps) {
const state = useContext(AriaComboBoxStateContext);
const _onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
state?.revert();
}
onKeyUp?.(e);
};
const comboBoxInputContext = useContext(ComboBoxInputContext);
useEffect(() => {
if (state && state.inputValue && !state.selectionManager.focusedKey) {
const focusedKey: Key | null =
(comboBoxInputContext?.getFocusedKey
? comboBoxInputContext.getFocusedKey(state)
: defaultGetFocusedKey(state)) ?? null;
state.selectionManager.setFocusedKey(focusedKey);
}
}, [comboBoxInputContext, state, state?.inputValue]);
return <Input onKeyUp={_onKeyUp} {...props} />;
}
function defaultGetFocusedKey<T>(state: AriaComboBoxState<T>) {
// Focus on the first suggestion item when typing.
const keys = Array.from(state.collection.getKeys());
return (
keys
.map(key => state.collection.getItem(key))
.find(i => i && i.type === 'item')?.key ?? null
);
}
const defaultComboBoxSectionClassName = () =>
css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
'& header': {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 10,
color: theme.menuAutoCompleteTextHeader,
},
});
type ComboBoxSectionProps<T extends object> = ListBoxSectionProps<T>;
export function ComboBoxSection<T extends object>({
className,
...props
}: ComboBoxSectionProps<T>) {
return (
<ListBoxSection
className={cx(defaultComboBoxSectionClassName(), className)}
{...props}
/>
);
}
const defaultComboBoxItemClassName = () =>
css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 20,
});
type ComboBoxItemProps = ListBoxItemProps;
export function ComboBoxItem({ className, ...props }: ComboBoxItemProps) {
return (
<ListBoxItem
className={
typeof className === 'function'
? renderProps =>
cx(defaultComboBoxItemClassName(), className(renderProps))
: cx(defaultComboBoxItemClassName(), className)
}
{...props}
/>
);
}

View File

@@ -84,6 +84,78 @@ test.describe('Transactions', () => {
);
await expect(page).toMatchThemeScreenshots();
});
test('by payee', async () => {
accountPage = await navigation.goToAccountPage('Capital One Checking');
const filterTooltip = await accountPage.filterBy('Payee');
const filtersMenuTooltip = page.getByTestId('filters-menu-tooltip');
await expect(filterTooltip.locator).toMatchThemeScreenshots();
// Type in the autocomplete box
const autocomplete = filtersMenuTooltip.getByLabel('Payee');
await expect(autocomplete).toMatchThemeScreenshots();
// Open the textbox, auto-open is currently broken for anything that's not "is not"
await autocomplete.click();
await page.getByTestId('Kroger-payee-item').click();
await filterTooltip.applyButton.click();
// Assert that all Payees are Kroger
for (let i = 0; i < 10; i++) {
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
'Kroger',
);
}
await accountPage.removeFilter(0);
await accountPage.filterBy('Payee');
await filtersMenuTooltip
.getByRole('button', { name: 'contains' })
.click();
const textInput = filtersMenuTooltip.getByPlaceholder('nothing');
await textInput.fill('De');
await filterTooltip.applyButton.click();
// Assert that all Payees are Deposit
for (let i = 0; i < 9; i++) {
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
'Deposit',
);
}
await accountPage.removeFilter(0);
await accountPage.filterBy('Payee');
await filtersMenuTooltip
.getByRole('button', { name: 'contains' })
.click();
await textInput.fill('l');
await filterTooltip.applyButton.click();
// Assert that both Payees contain the letter 'l'
for (let i = 0; i < 2; i++) {
await expect(accountPage.getNthTransaction(i).payee).toHaveText(/l/);
}
await accountPage.removeFilter(0);
await accountPage.filterBy('Payee');
await filtersMenuTooltip
.getByRole('button', { name: 'does not contain' })
.click();
await textInput.fill('l');
await filterTooltip.applyButton.click();
// Assert that all Payees DO NOT contain the letter 'l'
for (let i = 0; i < 19; i++) {
await expect(accountPage.getNthTransaction(i).payee).not.toHaveText(
/l/,
);
}
await expect(page).toMatchThemeScreenshots();
});
});
test('creates a test transaction', async () => {

View File

@@ -79,7 +79,7 @@
"react-markdown": "^10.1.0",
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.9.6",
"react-router": "7.12.0",
"react-simple-pull-to-refresh": "^1.3.3",
"react-spring": "10.0.0",
"react-swipeable": "^7.0.2",

View File

@@ -1,331 +0,0 @@
import { type ComponentProps, useMemo, useState } from 'react';
import { Header, type Key } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import {
ComboBox,
ComboBoxInputProvider,
ComboBoxItem,
ComboBoxSection,
} from '@actual-app/components/combo-box';
import { SvgAdd } from '@actual-app/components/icons/v1';
import { theme } from '@actual-app/components/theme';
import {
normalisedEquals,
normalisedIncludes,
} from 'loot-core/shared/normalisation';
import { type AccountEntity, type PayeeEntity } from 'loot-core/types/models';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
import { usePrevious } from '@desktop-client/hooks/usePrevious';
import {
createPayee,
getActivePayees,
} from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
type PayeeComboBoxItem = PayeeEntity & {
type: 'account' | 'payee' | 'suggested';
};
type PayeeComboBoxProps = Omit<ComponentProps<typeof ComboBox>, 'children'> & {
showInactive?: boolean;
};
export function PayeeComboBox({
showInactive,
selectedKey,
onOpenChange,
onSelectionChange,
...props
}: PayeeComboBoxProps) {
const { t } = useTranslation();
const payees = usePayees();
const commonPayees = useCommonPayees();
const accounts = useAccounts();
const [focusTransferPayees] = useState(false);
const allPayeeSuggestions: PayeeComboBoxItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(commonPayees, payees);
let filteredSuggestions: PayeeComboBoxItem[] = [...suggestions];
if (!showInactive) {
filteredSuggestions = filterActivePayees(filteredSuggestions, accounts);
}
if (focusTransferPayees) {
filteredSuggestions = filterTransferPayees(filteredSuggestions);
}
return filteredSuggestions;
}, [commonPayees, payees, showInactive, focusTransferPayees, accounts]);
const [inputValue, setInputValue] = useState(
getPayeeName(allPayeeSuggestions, selectedKey || null),
);
const [_selectedKey, setSelectedKey] = useState<Key | null>(
selectedKey || null,
);
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const previousIsAutocompeleteOpen = usePrevious(isAutocompleteOpen);
const isInitialAutocompleteOpen =
!previousIsAutocompeleteOpen && isAutocompleteOpen;
const filter = (textValue: string, inputValue: string) => {
return (
isInitialAutocompleteOpen || normalisedIncludes(textValue, inputValue)
);
};
const filteredPayeeSuggestions = allPayeeSuggestions.filter(p =>
filter(p.name, inputValue),
);
const suggestedPayees = filteredPayeeSuggestions.filter(
p => p.type === 'suggested',
);
const regularPayees = filteredPayeeSuggestions.filter(
p => !p.favorite && p.type === 'payee',
);
const accountPayees = filteredPayeeSuggestions.filter(
p => p.type === 'account',
);
const findExactMatchPayee = () =>
filteredPayeeSuggestions.find(
p => p.id !== 'new' && normalisedEquals(p.name, inputValue),
);
const exactMatchPayee = findExactMatchPayee();
const dispatch = useDispatch();
const onCreatePayee = () => {
return dispatch(createPayee({ name: inputValue })).unwrap();
};
const _onSelectionChange = async (id: Key | null) => {
if (id === 'new') {
const newPayeeId = await onCreatePayee?.();
setSelectedKey(newPayeeId);
onSelectionChange?.(newPayeeId);
return;
}
setSelectedKey(id);
setInputValue(getPayeeName(filteredPayeeSuggestions, id));
onSelectionChange?.(id);
};
const _onOpenChange = (isOpen: boolean) => {
setIsAutocompleteOpen(isOpen);
onOpenChange?.(isOpen);
};
const getFocusedKey: ComponentProps<
typeof ComboBoxInputProvider
>['getFocusedKey'] = state => {
const keys = Array.from(state.collection.getKeys());
const found = keys
.map(key => state.collection.getItem(key))
.find(i => i && i.type === 'item' && i.key !== 'new');
// Focus on the first suggestion item when typing.
// Otherwise, if there are no results, focus on the "new" item to allow creating a new entry.
return found?.key || 'new';
};
return (
<ComboBoxInputProvider getFocusedKey={getFocusedKey}>
<ComboBox
aria-label={t('Payee autocomplete')}
inputPlaceholder="nothing"
inputValue={inputValue}
onInputChange={setInputValue}
selectedKey={_selectedKey || selectedKey}
onSelectionChange={_onSelectionChange}
onOpenChange={_onOpenChange}
{...props}
>
<PayeeList
showCreatePayee={!!inputValue && !exactMatchPayee}
inputValue={inputValue}
suggestedPayees={suggestedPayees}
regularPayees={regularPayees}
accountPayees={accountPayees}
/>
{/* <ComboBoxSection className={css({ position: 'sticky', bottom: 0, })}>
<Button variant="menu" slot={null}>
<Trans>Make transfer</Trans>
</Button>
<Button variant="menu" slot={null}>
<Trans>Manage payees</Trans>
</Button>
</ComboBoxSection> */}
</ComboBox>
</ComboBoxInputProvider>
);
}
type PayeeListProps = {
showCreatePayee: boolean;
inputValue: string;
suggestedPayees: PayeeComboBoxItem[];
regularPayees: PayeeComboBoxItem[];
accountPayees: PayeeComboBoxItem[];
};
function PayeeList({
showCreatePayee,
inputValue,
suggestedPayees,
regularPayees,
accountPayees,
}: PayeeListProps) {
return (
<>
{showCreatePayee && (
<ComboBoxItem
key="new"
id="new"
textValue={inputValue}
style={{ paddingLeft: 10, color: theme.noticeText }}
>
<SvgAdd width={8} height={8} style={{ marginRight: 5 }} />
Create payee: {inputValue}
</ComboBoxItem>
)}
{suggestedPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Suggested Payees</Trans>
</Header>
{suggestedPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
{regularPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Payees</Trans>
</Header>
{regularPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
{accountPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Transfer To/From</Trans>
</Header>
{accountPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
</>
);
}
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
commonPayees: PayeeEntity[],
payees: PayeeEntity[],
): PayeeComboBoxItem[] {
const favoritePayees = payees
.filter(p => p.favorite)
.map(p => {
return { ...p, type: determineType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
let additionalCommonPayees: PayeeComboBoxItem[] = [];
if (commonPayees?.length > 0) {
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
additionalCommonPayees = commonPayees
.filter(
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
)
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length)
.map(p => {
return { ...p, type: determineType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
}
if (favoritePayees.length + additionalCommonPayees.length) {
const filteredPayees: PayeeComboBoxItem[] = payees
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
.map<PayeeComboBoxItem>(p => {
return { ...p, type: determineType(p, false) };
});
return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
}
return payees.map(p => {
return { ...p, type: determineType(p, false) };
});
}
function filterActivePayees<T extends PayeeEntity>(
payees: T[],
accounts: AccountEntity[],
): T[] {
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
}
function filterTransferPayees(payees: PayeeComboBoxItem[]) {
return payees.filter(payee => !!payee.transfer_acct);
}
function determineType(
payee: PayeeEntity,
isCommon: boolean,
): PayeeComboBoxItem['type'] {
if (payee.transfer_acct) {
return 'account';
}
if (isCommon) {
return 'suggested';
} else {
return 'payee';
}
}
function getPayeeName<T extends PayeeEntity>(items: T[], id: Key | null) {
return items.find(p => p.id === id)?.name || '';
}

View File

@@ -126,6 +126,10 @@ function ConfigureField<T extends RuleConditionEntity>({
return value;
}, [value, field, subfield, dateFormat]);
// For ops that filter based on payeeId, those use PayeeFilter, otherwise we use GenericInput
const isPayeeIdOp = (op: T['op']) =>
['is', 'is not', 'one of', 'not one of'].includes(op);
return (
<FocusScope>
<View style={{ marginBottom: 10 }}>
@@ -260,7 +264,7 @@ function ConfigureField<T extends RuleConditionEntity>({
});
}}
>
{type !== 'boolean' && field !== 'payee' && (
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
<GenericInput
ref={inputRef}
// @ts-expect-error - fix me
@@ -292,7 +296,7 @@ function ConfigureField<T extends RuleConditionEntity>({
/>
)}
{field === 'payee' && (
{field === 'payee' && isPayeeIdOp(op) && (
<PayeeFilter
// @ts-expect-error - fix me
value={formattedValue}

View File

@@ -1,4 +1,4 @@
import { type DragItem } from 'react-aria';
import { isTextDropItem, type DragItem } from 'react-aria';
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
@@ -13,8 +13,11 @@ import {
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
import { moveCategory } from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDispatch } from '@desktop-client/redux';
const DRAG_TYPE = 'mobile-expense-category-list/category-id';
type ExpenseCategoryListProps = {
categoryGroup: CategoryGroupEntity;
categories: CategoryEntity[];
@@ -37,14 +40,14 @@ export function ExpenseCategoryList({
shouldHideCategory,
}: ExpenseCategoryListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const { reorderCategory } = useReorderCategory();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
[...keys].map(
key =>
({
'text/plain': key as CategoryEntity['id'],
[DRAG_TYPE]: key as CategoryEntity['id'],
}) as DragItem,
),
renderDropIndicator: target => {
@@ -54,7 +57,7 @@ export function ExpenseCategoryList({
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
backgroundColor: theme.tableBorderHover,
opacity: 1,
borderRadius: 4,
},
@@ -62,59 +65,25 @@ export function ExpenseCategoryList({
/>
);
},
acceptedDragTypes: [DRAG_TYPE],
getDropOperation: () => 'move',
onInsert: async e => {
const [id] = await Promise.all(
e.items.filter(isTextDropItem).map(item => item.getText(DRAG_TYPE)),
);
reorderCategory({
id: id as CategoryEntity['id'],
targetId: e.target.key as CategoryEntity['id'],
dropPosition: e.target.dropPosition,
});
},
onReorder: e => {
const [key] = e.keys;
const categoryIdToMove = key as CategoryEntity['id'];
const categoryToMove = categories.find(c => c.id === categoryIdToMove);
if (!categoryToMove) {
throw new Error(
`Internal error: category with ID ${categoryIdToMove} not found.`,
);
}
if (!categoryToMove.group) {
throw new Error(
`Internal error: category ${categoryIdToMove} is not in a group and cannot be moved.`,
);
}
const targetCategoryId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
targetId: targetCategoryId,
}),
);
} else if (e.target.dropPosition === 'after') {
const targetCategoryIndex = categories.findIndex(
c => c.id === targetCategoryId,
);
if (targetCategoryIndex === -1) {
throw new Error(
`Internal error: category with ID ${targetCategoryId} not found.`,
);
}
const nextToTargetCategory = categories[targetCategoryIndex + 1];
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
}
reorderCategory({
id: key as CategoryEntity['id'],
targetId: e.target.key as CategoryEntity['id'],
dropPosition: e.target.dropPosition,
});
},
});
@@ -149,3 +118,76 @@ export function ExpenseCategoryList({
</GridList>
);
}
function useReorderCategory() {
const dispatch = useDispatch();
const { list: categories } = useCategories();
const reorderCategory = ({
id,
targetId,
dropPosition,
}: {
id: CategoryEntity['id'];
targetId: CategoryEntity['id'];
dropPosition: 'on' | 'before' | 'after';
}) => {
const categoryToMove = categories.find(c => c.id === id);
if (!categoryToMove) {
throw new Error(`Internal error: category with ID ${id} not found.`);
}
if (!categoryToMove.group) {
throw new Error(
`Internal error: Failed to move category ${id} because it is not in a group.`,
);
}
const targetCategoryGroupId = categories.find(
c => c.id === targetId,
)?.group;
if (!targetCategoryGroupId) {
throw new Error(
`Internal error: Failed to move category ${id} because target category ${targetId} is not in a group.`,
);
}
if (dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: targetCategoryGroupId,
targetId,
}),
);
} else if (dropPosition === 'after') {
const targetCategoryIndex = categories.findIndex(c => c.id === targetId);
if (targetCategoryIndex === -1) {
throw new Error(
`Internal error: category with ID ${targetId} not found.`,
);
}
const nextToTargetCategory = categories[targetCategoryIndex + 1];
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: targetCategoryGroupId,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
}
};
return {
reorderCategory,
};
}

View File

@@ -16,8 +16,11 @@ import {
} from './ExpenseGroupListItem';
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDispatch } from '@desktop-client/redux';
const DRAG_TYPE = 'mobile-expense-group-list/category-group-id';
type ExpenseGroupListProps = {
categoryGroups: CategoryGroupEntity[];
show3Columns: boolean;
@@ -44,14 +47,14 @@ export function ExpenseGroupList({
onToggleCollapse,
}: ExpenseGroupListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const { reorderCategoryGroup } = useReorderCategoryGroup();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
[...keys].map(
key =>
({
'text/plain': key as CategoryEntity['id'],
[DRAG_TYPE]: key as CategoryGroupEntity['id'],
}) as DragItem,
),
renderDropIndicator: target => {
@@ -61,7 +64,7 @@ export function ExpenseGroupList({
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
backgroundColor: theme.tableBorderHover,
opacity: 1,
borderRadius: 4,
},
@@ -70,7 +73,7 @@ export function ExpenseGroupList({
);
},
renderDragPreview: items => {
const draggedGroupId = items[0]['text/plain'];
const draggedGroupId = items[0][DRAG_TYPE];
const group = categoryGroups.find(c => c.id === draggedGroupId);
if (!group) {
throw new Error(
@@ -92,49 +95,11 @@ export function ExpenseGroupList({
},
onReorder: e => {
const [key] = e.keys;
const groupIdToMove = key as CategoryGroupEntity['id'];
const groupToMove = categoryGroups.find(c => c.id === groupIdToMove);
if (!groupToMove) {
throw new Error(
`Internal error: category group with ID ${groupIdToMove} not found.`,
);
}
const targetGroupId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategoryGroup({
id: groupToMove.id,
targetId: targetGroupId,
}),
);
} else if (e.target.dropPosition === 'after') {
const targetGroupIndex = categoryGroups.findIndex(
c => c.id === targetGroupId,
);
if (targetGroupIndex === -1) {
throw new Error(
`Internal error: category group with ID ${targetGroupId} not found.`,
);
}
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
dispatch(
moveCategoryGroup({
id: groupToMove.id,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
}
reorderCategoryGroup({
id: key as CategoryGroupEntity['id'],
targetId: e.target.key as CategoryGroupEntity['id'],
dropPosition: e.target.dropPosition,
});
},
});
@@ -174,3 +139,60 @@ export function ExpenseGroupList({
</GridList>
);
}
function useReorderCategoryGroup() {
const dispatch = useDispatch();
const { list: categoryGroups } = useCategories();
const reorderCategoryGroup = ({
id,
targetId,
dropPosition,
}: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'];
dropPosition: 'on' | 'before' | 'after';
}) => {
const groupToMove = categoryGroups.find(c => c.id === id);
if (!groupToMove) {
throw new Error(
`Internal error: category group with ID ${id} not found.`,
);
}
if (dropPosition === 'before') {
dispatch(
moveCategoryGroup({
id: groupToMove.id,
targetId,
}),
);
} else if (dropPosition === 'after') {
const targetGroupIndex = categoryGroups.findIndex(c => c.id === targetId);
if (targetGroupIndex === -1) {
throw new Error(
`Internal error: category group with ID ${targetId} not found.`,
);
}
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
dispatch(
moveCategoryGroup({
id: groupToMove.id,
// Due to the way `moveCategory` works, we use the category next to the
// actual target category here because `moveCategory` always shoves the
// category *before* the target category.
// On the other hand, using `null` as `targetId` moves the category
// to the end of the list.
targetId: nextToTargetCategory?.id || null,
}),
);
}
};
return {
reorderCategoryGroup,
};
}

View File

@@ -1,9 +1,6 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
import {
Modal,
@@ -21,14 +18,6 @@ type ManageRulesModalProps = Extract<
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const location = useLocation();
if (isNonProductionEnvironment()) {
if (location.pathname !== '/payees') {
throw new Error(
`Possibly invalid use of ManageRulesModal, add the current url \`${location.pathname}\` to the allowlist if you're confident the modal can never appear on top of the \`/rules\` page.`,
);
}
}
return (
<Modal name="manage-rules" isLoading={loading}>

View File

@@ -38,10 +38,6 @@ const currentIntervalOptions = [
description: t('Year to date'),
disableInclude: true,
},
{
description: t('Last month'),
disableInclude: true,
},
{
description: t('Last year'),
disableInclude: true,

View File

@@ -54,7 +54,6 @@ import {
import { FormulaActionEditor } from './FormulaActionEditor';
import { PayeeComboBox } from '@desktop-client/components/autocomplete/PayeeComboBox';
import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge';
import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable';
import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput';
@@ -320,13 +319,6 @@ function ConditionEditor({
onChange={v => onChange('value', v)}
/>
);
} else if (field === 'payee') {
valueEditor = (
<PayeeComboBox
selectedKey={value}
onSelectionChange={id => onChange('value', id)}
/>
);
} else {
valueEditor = (
<GenericInput

View File

@@ -202,9 +202,6 @@ export function ExperimentalFeatures() {
>
<Trans>Crossover Report</Trans>
</FeatureToggle>
<FeatureToggle flag="forceReload">
<Trans>Force reload app button</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"

View File

@@ -5,7 +5,6 @@ import { ButtonWithLoading } from '@actual-app/components/button';
import { Text } from '@actual-app/components/text';
import { send } from 'loot-core/platform/client/fetch';
import { isElectron } from 'loot-core/shared/environment';
import { Setting } from './UI';
@@ -89,46 +88,3 @@ export function ResetSync() {
</Setting>
);
}
export function ForceReload() {
const [reloading, setReloading] = useState(false);
async function onForceReload() {
setReloading(true);
try {
if (!isElectron()) {
const registration =
await window.navigator.serviceWorker.getRegistration('/');
if (registration) {
await registration.update();
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
}
} catch {
// Do nothing
} finally {
window.location.reload();
}
}
return (
<Setting
primaryAction={
<ButtonWithLoading isLoading={reloading} onPress={onForceReload}>
<Trans>Force reload app</Trans>
</ButtonWithLoading>
}
>
<Text>
<Trans>
<strong>Force reload app</strong> will clear the cached version of the
app and load a fresh one. This is useful if you&apos;re experiencing
issues with the app after an update or if cached files are causing
problems. The app will reload automatically after clearing the cache.
</Trans>
</Text>
</Setting>
);
}

View File

@@ -23,7 +23,7 @@ import { ExportBudget } from './Export';
import { FormatSettings } from './Format';
import { LanguageSettings } from './LanguageSettings';
import { RepairTransactions } from './RepairTransactions';
import { ForceReload, ResetCache, ResetSync } from './Reset';
import { ResetCache, ResetSync } from './Reset';
import { ThemeSettings } from './Themes';
import { AdvancedToggle, Setting } from './UI';
@@ -176,7 +176,6 @@ export function Settings() {
const [budgetName] = useMetadataPref('budgetName');
const dispatch = useDispatch();
const isCurrencyExperimentalEnabled = useFeatureFlag('currency');
const isForceReloadEnabled = useFeatureFlag('forceReload');
const [_, setDefaultCurrencyCodePref] = useSyncedPref('defaultCurrencyCode');
const onCloseBudget = () => {
@@ -253,7 +252,6 @@ export function Settings() {
<ExportBudget />
<AdvancedToggle>
<AdvancedAbout />
{isForceReloadEnabled && <ForceReload />}
<ResetCache />
<ResetSync />
<RepairTransactions />

View File

@@ -9,7 +9,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
formulaMode: false,
currency: false,
crossoverReport: false,
forceReload: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -0,0 +1,31 @@
---
title: Reflecting on Your 2025 Finances
description: We created a small app to help you look back at your financial year — Actual Budget Wrapped 2025.
date: 2026-01-10T10:00
slug: actual-budget-wrapped-2025
tags: [announcement]
hide_table_of_contents: false
authors: MatissJanis
---
As we step into 2026, it's natural to look back at the year that just passed. While many apps send you a "year in review" at the end of December, we thought it would be fun to create something special for Actual Budget users — a way to reflect on your financial journey throughout 2025.
<!--truncate-->
## Introducing Actual Budget Wrapped 2025
We've built a small web app that helps you visualize your financial story from the past year. [Actual Budget Wrapped 2025](https://wrapped.actualbudget.org) takes your Actual Budget data and transforms it into a personalized financial recap.
Whether you want to see your biggest spending categories, track your income trends, or simply marvel at how many transactions you've managed — this tool gives you a different perspective on your financial data. Sometimes it's helpful to step back and see the bigger picture of where your money went throughout the year.
The app is designed to be simple and privacy-focused. Your data stays in your browser and is processed locally. We don't collect or store any of your financial information — it's just between you and your Actual Budget file.
## Why Reflect?
Looking back at your finances can be eye-opening. Maybe you'll discover patterns you weren't aware of, or perhaps you'll be pleasantly surprised by how much progress you've made toward your goals. Maybe you'll spot areas where you can improve, or celebrate wins you've forgotten about.
Personal finance isn't just about numbers on a screen — it's about the choices we make, the goals we set, and the progress we track along the way. Sometimes taking a step back helps us appreciate how far we've come.
So head over to [wrapped.actualbudget.org](https://wrapped.actualbudget.org) and take a few minutes to see your 2025 financial story. We hope it brings you some insights, or at the very least, a moment of reflection as you plan for the year ahead.
Here's to making 2026 another great year for your finances!

View File

@@ -20,7 +20,7 @@ type ScheduleTemplateTarget = {
target: number;
next_date_string: string;
target_interval: number;
target_frequency: string;
target_frequency: string | undefined;
num_months: number;
completed: number;
full: boolean;
@@ -238,6 +238,8 @@ function getSinkingBaseContributionTotal(t: ScheduleTemplateTarget[]) {
monthlyAmount = schedule.target / intervalMonths;
break;
default:
// default to same math as monthly for now for non-reoccuring
monthlyAmount = schedule.target / schedule.target_interval;
break;
}
total += monthlyAmount;
@@ -275,17 +277,21 @@ export async function runSchedule(
const isPayMonthOf = c =>
c.full ||
(c.target_frequency === 'monthly' &&
((c.target_frequency === 'monthly' || !c.target_frequency) &&
c.target_interval === 1 &&
c.num_months === 0) ||
(c.target_frequency === 'weekly' && c.target_interval <= 4) ||
(c.target_frequency === 'daily' && c.target_interval <= 31) ||
isReflectBudget();
const isSubMonthly = c =>
c.target_frequency === 'weekly' || c.target_frequency === 'daily';
const t_payMonthOf = t.t.filter(isPayMonthOf);
const t_sinking = t.t
.filter(c => !isPayMonthOf(c))
.sort((a, b) => a.next_date_string.localeCompare(b.next_date_string));
const numSubMonthly = t.t.filter(isSubMonthly).length;
const totalPayMonthOf = getPayMonthOfTotal(t_payMonthOf);
const totalSinking = getSinkingTotal(t_sinking);
const totalSinkingBaseContribution =
@@ -303,7 +309,8 @@ export async function runSchedule(
balance >= totalSinking + totalPayMonthOf ||
(lastMonthGoal < totalSinking + totalPayMonthOf &&
lastMonthGoal !== 0 &&
balance >= lastMonthGoal)
balance >= lastMonthGoal &&
numSubMonthly > 0)
) {
to_budget += Math.round(totalPayMonthOf + totalSinkingBaseContribution);
} else {

View File

@@ -4,11 +4,3 @@ export function getNormalisedString(value: string) {
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
}
export function normalisedEquals(a: string, b: string) {
return getNormalisedString(a) === getNormalisedString(b);
}
export function normalisedIncludes(a: string, b: string) {
return getNormalisedString(a).includes(getNormalisedString(b));
}

View File

@@ -4,8 +4,7 @@ export type FeatureFlag =
| 'actionTemplating'
| 'formulaMode'
| 'currency'
| 'crossoverReport'
| 'forceReload';
| 'crossoverReport';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Implement PayeeAutocomplete2 based on react-aria-component's ComboBox

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [csenel]
---
Enable include current month option for last month

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [RMcGhee]
---
Fix payee filter functionality to improve transaction filtering in the application.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [dependabot]
---
Bump react-router version from 7.9.6 to 7.12.0 for improved functionality and performance.

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [youngcw]
---
Fix schedule template regressions where categories are being underbudgeted and to improve functionality and user experience.

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [RMcGhee]
---
Remove url check that throws error in development

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove the force reload feature from the application settings.

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [joel-jeremy]
---
[Mobile] Fix drag and drop across category groups

View File

@@ -195,7 +195,7 @@ __metadata:
react-markdown: "npm:^10.1.0"
react-modal: "npm:3.16.3"
react-redux: "npm:^9.2.0"
react-router: "npm:7.9.6"
react-router: "npm:7.12.0"
react-simple-pull-to-refresh: "npm:^1.3.3"
react-spring: "npm:10.0.0"
react-swipeable: "npm:^7.0.2"
@@ -23299,9 +23299,9 @@ __metadata:
languageName: node
linkType: hard
"react-router@npm:7.9.6":
version: 7.9.6
resolution: "react-router@npm:7.9.6"
"react-router@npm:7.12.0":
version: 7.12.0
resolution: "react-router@npm:7.12.0"
dependencies:
cookie: "npm:^1.0.1"
set-cookie-parser: "npm:^2.6.0"
@@ -23311,7 +23311,7 @@ __metadata:
peerDependenciesMeta:
react-dom:
optional: true
checksum: 10/f34714b3701caf689c306631f5326a9fdab585799021c234aa3eee75bed6bfcea9250f0867e984e4e3c43c77d947c41bd47b70c0601d76c4290e03247fb7ac23
checksum: 10/578324f792721200bd57a220c7931af692613943051c9bb0c6303613849ec9a2c2365a3a6afe1b3976c13edc8f71616bb9cfdb13c0ac501f239ad11a6884e3f8
languageName: node
linkType: hard