Compare commits

..

5 Commits

Author SHA1 Message Date
Cursor Agent
31967e36a4 [AI] Resolve bank sync PR merge conflicts
Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 03:09:42 +00:00
github-actions[bot]
c2150e5888 Update VRT screenshots
Auto-generated by VRT workflow

PR: #7449
2026-04-10 02:04:26 +00:00
lelemm
6b0242fa49 code review 2026-04-09 21:07:23 +00:00
lelemm
d8eba18a72 Merge branch 'master' into feat/banksync-page 2026-04-09 20:47:43 +00:00
lelemm
c91eea5439 Bank sync refactor extracted from plugins 2026-04-09 19:41:10 +00:00
316 changed files with 2098 additions and 5638 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
enabled: false
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -3,6 +3,9 @@ contact_links:
- name: Bank-sync issues
url: https://discord.gg/pRYNYr4W5A
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

View File

@@ -1,17 +0,0 @@
name: Tech Support
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
title: '[Support]: '
labels: ['tech-support']
body:
- type: markdown
attributes:
value: |
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
- type: textarea
id: problem
attributes:
label: Describe your problem
description: Please describe, in as much detail as you can, what you need help with.
placeholder: I'm trying to [...] but [...]
validations:
required: true

View File

@@ -1,16 +1,13 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes
(?:^|/)(?i).nojekyll
(?:^|/)(?i)COPYRIGHT
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)LICEN[CS]E
(?:^|/)(?i)README.md
(?:^|/)3rdparty/
(?:^|/)go\.sum$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)pyproject.toml
(?:^|/)requirements(?:-dev|-doc|-test|)\.txt$
(?:^|/)vendor/
(?:^|/)yarn\.lock$
ignore$
\.a$
\.ai$
\.avi$
@@ -56,7 +53,6 @@
\.svgz?$
\.tar$
\.tiff?$
\.tsx$
\.ttf$
\.wav$
\.webm$
@@ -66,12 +62,15 @@
\.zip$
^\.github/actions/spelling/
^\.github/ISSUE_TEMPLATE/
^\.yarn/
^\Q.github/\E$
^\Q.github/workflows/spelling.yml\E$
^\.yarn/
^\Qnode_modules/\E$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md
(?:^|/)(?i).nojekyll
^\static/
^packages/docs/docs/releases\.md$
ignore$
\.tsx$

View File

@@ -38,9 +38,7 @@ Cetelem
cimode
Citi
Citibank
claude
Cloudflare
CLP
CMCIFRPAXXX
COBADEFF
CODEOWNERS
@@ -55,7 +53,6 @@ crt
CZK
Danske
datadir
datamodel
DATEDIF
Depositos
deselection
@@ -64,6 +61,7 @@ Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ecf
EDATE
ENTERCARD
Entra
@@ -85,7 +83,6 @@ Globecard
GLS
gocardless
Grafana
Gruvbox
HABAL
Hampel
HELADEF
@@ -93,7 +90,6 @@ HLOOKUP
HUF
IFERROR
IFNA
Ilavenil
INDUSTRIEL
INGBPLPW
Ingo
@@ -132,7 +128,6 @@ murmurhash
NETWORKDAYS
nginx
nodenext
nord
OIDC
Okabe
overbudgeted
@@ -152,7 +147,6 @@ QNTOFRP
QONTO
Raiffeisen
REGEXREPLACE
relinking
revolut
RIED
RSchedule
@@ -184,7 +178,6 @@ TIMEFRAME
touchscreen
triaging
tsgo
tsgolint
TWD
UAH
ubuntu
@@ -200,6 +193,4 @@ websecure
WEEKNUM
Widiba
WOR
worktree
youngcw
zizmor

View File

@@ -26,7 +26,6 @@ permissions:
jobs:
cut-release-branch:
runs-on: ubuntu-latest
environment: release
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -32,7 +32,6 @@ jobs:
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
name: Build Docker image
runs-on: ubuntu-latest
environment: release
strategy:
matrix:
os: [ubuntu, alpine]

View File

@@ -27,7 +27,6 @@ jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:

View File

@@ -21,7 +21,6 @@ jobs:
# this is so the assets can be added to the release
permissions:
contents: write
environment: release
strategy:
fail-fast: false
matrix:
@@ -124,7 +123,6 @@ jobs:
publish-microsoft-store:
needs: build
runs-on: windows-latest
environment: release
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Install StoreBroker

View File

@@ -1,23 +0,0 @@
name: Close tech support issues with automated message
on:
issues:
types: [labeled]
jobs:
tech-support:
if: ${{ github.event.label.name == 'tech-support' }}
runs-on: ubuntu-latest
steps:
- name: Create comment and close issue
run: |
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
<!-- tech-support-auto-close-comment -->"
gh issue close "$ISSUE_URL"
env:
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,7 +19,6 @@ concurrency:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: release
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -21,7 +21,6 @@ concurrency:
jobs:
publish-flathub:
runs-on: ubuntu-22.04
environment: release
steps:
- name: Resolve version
id: resolve_version

View File

@@ -27,7 +27,6 @@ jobs:
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
environment: release
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -87,7 +87,6 @@ jobs:
runs-on: ubuntu-latest
name: Publish npm packages
needs: build-and-pack
environment: release
permissions:
contents: read
packages: write

View File

@@ -65,10 +65,6 @@ jobs:
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -91,6 +87,9 @@ jobs:
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

View File

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting
@@ -338,11 +337,6 @@
"group": ["**/*.api", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{
"group": ["uuid"],
"importNames": ["*"],
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
},
{
"group": ["**/style", "**/colors"],
"importNames": ["colors"],

View File

@@ -52,7 +52,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.5.2",
"version": "26.4.0",
"description": "An API for Actual",
"license": "MIT",
"repository": {
@@ -49,8 +49,7 @@
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1",
"uuid": "^14.0.0"
"compare-versions": "^6.1.1"
},
"devDependencies": {
"@typescript/native-preview": "beta",

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/cli",
"version": "26.5.2",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"repository": {

View File

@@ -10,10 +10,14 @@
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -21,9 +25,7 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
@@ -34,8 +36,7 @@
},
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^14.0.0"
"murmurhash": "^2.0.1"
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",

View File

@@ -1,5 +1,4 @@
import murmurhash from 'murmurhash';
import { v4 as uuidv4 } from 'uuid';
import type { TrieNode } from './merkle';
@@ -77,7 +76,7 @@ export function deserializeClock(clock: string): Clock {
}
export function makeClientId() {
return uuidv4().replace(/-/g, '').slice(-16);
return crypto.randomUUID().replace(/-/g, '').slice(-16);
}
const config = {

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "26.5.2",
"version": "26.4.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -40,13 +40,9 @@
"#components/banksync/useBankSyncAccountSettings": "./src/components/banksync/useBankSyncAccountSettings.ts",
"#components/budget": "./src/components/budget/index.tsx",
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
"#components/budget/goals/reducer": "./src/components/budget/goals/reducer.ts",
"#components/budget/goals/useBudgetAutomationCategories": "./src/components/budget/goals/useBudgetAutomationCategories.ts",
"#components/budget/goals/validateAutomation": "./src/components/budget/goals/validateAutomation.ts",
"#components/budget/util": "./src/components/budget/util.ts",
"#components/codemirror/autocompleteTabAccept": "./src/components/codemirror/autocompleteTabAccept.ts",
"#components/mobile/utils": "./src/components/mobile/utils.ts",
@@ -199,7 +195,6 @@
"sass": "^1.99.0",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",
"uuid": "^14.0.0",
"vite": "^8.0.5",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.2"

View File

@@ -12,7 +12,6 @@ import type {
} from '@actual-app/core/types/models';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { sync } from '#app/appSlice';
import { useAccounts } from '#hooks/useAccounts';
@@ -44,7 +43,7 @@ const dispatchErrorNotification = (
dispatch(
addNotification({
notification: {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message,
pre: error ? error.message : undefined,

View File

@@ -27,13 +27,6 @@ let worker = null;
// The regular Worker running the backend, created only on the leader tab
let localBackendWorker = null;
function terminateLocalBackendWorker() {
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
}
/**
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
* (onmessage, postMessage, addEventListener, start) to the connection layer.
@@ -50,22 +43,9 @@ class WorkerBridge {
this._onmessage = null;
this._listeners = [];
this._started = false;
this._isInitialized = false;
this._currentBudgetId = null;
this._wasHidden = document.visibilityState === 'hidden';
this._onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this._wasHidden = true;
} else if (this._wasHidden) {
this._wasHidden = false;
this._resumeAssociation();
}
};
// Listen for all messages from the SharedWorker port
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
set onmessage(handler) {
@@ -129,7 +109,10 @@ class WorkerBridge {
// show-budgets normally.
if (msg && msg.type === '__close-and-transfer') {
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
this._applyRole('UNASSIGNED', null);
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
// Only dispatch a synthetic reply if there's an actual close-budget
// request to complete. When requestId is null the eviction was
// triggered externally (e.g. another tab deleted this budget).
@@ -143,7 +126,6 @@ class WorkerBridge {
// Role change notification
if (msg && msg.type === '__role-change') {
this._applyRole(msg.role, msg.budgetId ?? null);
console.log(
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
);
@@ -164,47 +146,13 @@ class WorkerBridge {
}
// Everything else goes to the connection layer
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
this._applyRole('UNASSIGNED', null);
}
this._dispatch(event);
}
markInitialized() {
this._isInitialized = true;
}
_normalizeBudgetId(budgetId) {
if (
typeof budgetId === 'string' &&
budgetId.length > 0 &&
!budgetId.startsWith('__')
) {
return budgetId;
}
return null;
}
_applyRole(role, budgetId) {
this._currentBudgetId = this._normalizeBudgetId(budgetId);
if (role !== 'LEADER') {
terminateLocalBackendWorker();
}
}
_resumeAssociation() {
if (!this._isInitialized) {
return;
}
this._sharedPort.postMessage({
type: '__resume-tab',
budgetId: this._currentBudgetId,
});
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
terminateLocalBackendWorker();
if (localBackendWorker) {
localBackendWorker.terminate();
}
localBackendWorker = new Worker(backendWorkerUrl);
initSQLBackend(localBackendWorker);
@@ -290,12 +238,10 @@ function createBackendWorker() {
'SharedArrayBufferOverride',
),
});
worker.markInitialized();
const notifyTabClosing = () => {
window.addEventListener('beforeunload', () => {
sharedPort.postMessage({ type: 'tab-closing' });
};
window.addEventListener('beforeunload', notifyTabClosing);
});
return;
} catch (e) {
@@ -335,17 +281,10 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
// Skip SW registration in dev so stale cached assets don't override edits
// between page loads. Plugin code that needs a SW can register one itself.
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
// SW lifecycle to swap the page — fall back to a plain reload so callers
// don't hang on the never-resolving promise inside applyAppUpdate.
const updateSW = IS_DEV
? () => window.location.reload()
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

@@ -9,7 +9,6 @@ import type {
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { pushModal } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
@@ -32,7 +31,7 @@ function dispatchErrorNotification(
dispatch(
addNotification({
notification: {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message,
pre: error ? error.message : undefined,
@@ -648,13 +647,6 @@ type ApplyBudgetActionPayload =
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-until-year-end';
month: string;
args: {
category: CategoryEntity['id'];
};
};
export function useBudgetActions() {
@@ -784,12 +776,6 @@ export function useBudgetActions() {
category: args.category,
});
return null;
case 'copy-until-year-end':
await send('budget/copy-until-year-end', {
month,
category: args.category,
});
return null;
default:
throw new Error(`Unknown budget action type: ${String(type)}`);
}

View File

@@ -243,8 +243,8 @@ function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
) : (
<AnimatedRefresh animating={syncing} />
)}
<Text style={isMobile ? { ...mobileTextStyle } : null}>
{syncState === 'disabled' ? ` ${t('Disabled')}` : null}
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
{syncState === 'disabled' ? t('Disabled') : null}
</Text>
</Button>
);

View File

@@ -36,7 +36,6 @@ import type {
import { t } from 'i18next';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { v4 as uuidv4 } from 'uuid';
import {
useReopenAccountMutation,
@@ -1119,7 +1118,7 @@ class AccountInternal extends PureComponent<
const [firstTransaction] = transactions;
const parentTransaction = {
id: uuidv4(),
id: crypto.randomUUID(),
is_parent: true,
cleared: transactions.every(t => !!t.cleared),
date: firstTransaction.date,

View File

@@ -208,19 +208,6 @@ export function BankSyncCheckboxOptions({
<Trans>Reimport deleted transactions</Trans>
</CheckboxOptionWithHelp>
<CheckboxOptionWithHelp
id="form_update_dates"
checked={updateDates}
onChange={() => setUpdateDates(!updateDates)}
disabled={!importTransactions}
helpText={t(
'By enabling this, the transaction date will be overwritten by the one provided by the bank.',
)}
helpMode={helpMode}
>
<Trans>Update Dates</Trans>
</CheckboxOptionWithHelp>
<CheckboxOptionWithHelp
id="form_import_transactions"
checked={!importTransactions}
@@ -232,6 +219,18 @@ export function BankSyncCheckboxOptions({
>
<Trans>Investment Account</Trans>
</CheckboxOptionWithHelp>
<CheckboxOptionWithHelp
id="form_update_dates"
checked={updateDates}
onChange={() => setUpdateDates(!updateDates)}
helpText={t(
'By enabling this, the transaction date will be overwritten by the one provided by the bank.',
)}
helpMode={helpMode}
>
<Trans>Update Dates</Trans>
</CheckboxOptionWithHelp>
</>
);
}

View File

@@ -512,10 +512,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
placement="bottom end"
isOpen={balanceMenuOpen}
onOpenChange={() => setBalanceMenuOpen(false)}
style={{
margin: 1,
minWidth: 190,
}}
style={{ margin: 1 }}
isNonModal
{...balancePosition}
>

View File

@@ -1,76 +0,0 @@
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Action } from './actions';
import type { ReducerState } from './constants';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
type ActiveEditorProps = {
state: ReducerState;
dispatch: (action: Action) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
};
export function ActiveEditor({
state,
dispatch,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
}: ActiveEditorProps) {
switch (state.displayType) {
case 'limit':
return <LimitAutomation template={state.template} dispatch={dispatch} />;
case 'refill':
return (
<RefillAutomation
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
);
case 'fixed':
return <FixedAutomation template={state.template} dispatch={dispatch} />;
case 'schedule':
return (
<ScheduleAutomation
schedules={schedules}
template={state.template}
dispatch={dispatch}
/>
);
case 'percentage':
return (
<PercentageAutomation
dispatch={dispatch}
template={state.template}
categories={categories}
/>
);
case 'historical':
return (
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
case 'by':
return <BySaveAutomation template={state.template} dispatch={dispatch} />;
case 'remainder':
return (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
default:
state satisfies never;
return null;
}
}

View File

@@ -16,17 +16,14 @@ import { FormField, FormLabel, FormTextLabel } from '#components/forms';
import { setType } from './actions';
import type { Action } from './actions';
import type { ReducerState } from './constants';
import { displayTemplateTypes } from './constants';
import { getDisplayTemplateMeta } from './displayTemplateMeta';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
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 { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
import { WeekAutomation } from './editor/WeekAutomation';
type BudgetAutomationEditorProps = {
inline: boolean;
@@ -53,7 +50,7 @@ const displayTypeToDescription = {
automation.
</Trans>
),
fixed: (
week: (
<Trans>
Add a fixed amount to this category for each week in the month. For
example, $100 per week would be $400 per month in a 4-week month.
@@ -83,18 +80,6 @@ const displayTypeToDescription = {
to account for seasonal changes.
</Trans>
),
by: (
<Trans>
Spread a target amount across the months between now and a target date.
Useful for annual goals or saving toward a one-off expense.
</Trans>
),
remainder: (
<Trans>
Split any remaining To Budget across categories using this automation.
Higher weights take a larger share of the leftover funds.
</Trans>
),
};
export function BudgetAutomationEditor({
@@ -123,9 +108,9 @@ export function BudgetAutomationEditor({
/>
);
break;
case 'fixed':
case 'week':
automationEditor = (
<FixedAutomation template={state.template} dispatch={dispatch} />
<WeekAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'schedule':
@@ -151,16 +136,6 @@ export function BudgetAutomationEditor({
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'by':
automationEditor = (
<BySaveAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'remainder':
automationEditor = (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
break;
default:
state satisfies never;
automationEditor = (
@@ -190,10 +165,7 @@ export function BudgetAutomationEditor({
<InitialFocus>
<Select
id="type-field"
options={displayTemplateTypes.map(type => [
type,
getDisplayTemplateMeta(type).label,
])}
options={displayTemplateTypes}
defaultLabel={t('Select an option')}
value={state.displayType}
onChange={type => type && dispatch(setType(type))}

View File

@@ -14,14 +14,12 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { ReducerState } from './constants';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
import { WeekAutomationReadOnly } from './editor/WeekAutomationReadOnly';
type BudgetAutomationReadOnlyProps = {
state: ReducerState;
@@ -54,10 +52,8 @@ export function BudgetAutomationReadOnly({
case 'refill':
automationReadOnly = <RefillAutomationReadOnly />;
break;
case 'fixed':
automationReadOnly = (
<FixedAutomationReadOnly template={state.template} />
);
case 'week':
automationReadOnly = <WeekAutomationReadOnly template={state.template} />;
break;
case 'schedule':
automationReadOnly = (
@@ -77,18 +73,7 @@ export function BudgetAutomationReadOnly({
<HistoricalAutomationReadOnly template={state.template} />
);
break;
case 'by':
automationReadOnly = (
<BySaveAutomationReadOnly template={state.template} />
);
break;
case 'remainder':
automationReadOnly = (
<RemainderAutomationReadOnly template={state.template} />
);
break;
default:
state satisfies never;
automationReadOnly = (
<Text>
<Trans>Unrecognized automation type.</Trans>

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -8,9 +8,7 @@ import { theme } from '@actual-app/components/theme';
import type { CategoryEntity } from '@actual-app/core/types/models';
import { css, cx } from '@emotion/css';
import { MonthsContext } from '#components/budget/MonthsContext';
import { useFeatureFlag } from '#hooks/useFeatureFlag';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
@@ -32,24 +30,15 @@ export function CategoryAutomationButton({
}: CategoryAutomationButtonProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const monthsContext = useContext(MonthsContext);
const month = monthsContext?.months?.[0];
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const hasAutomations = !!category.goal_def?.length;
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
return null;
}
// Income categories don't accept templates in envelope budgets (only the
// tracking budget runs templates against income categories).
if (category.is_income && budgetType !== 'tracking') {
return null;
}
return (
<Button
variant="bare"
@@ -70,7 +59,7 @@ export function CategoryAutomationButton({
pushModal({
modal: {
name: 'category-automations-edit',
options: { categoryId: category.id, month },
options: { categoryId: category.id },
},
}),
);

View File

@@ -1,62 +0,0 @@
import { Trans } from 'react-i18next';
import type { Template } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
type TemplateSentenceProps = {
template: Template;
categoryNameMap: Record<string, string>;
};
export function TemplateSentence({
template,
categoryNameMap,
}: TemplateSentenceProps) {
switch (template.type) {
case 'limit':
return <LimitAutomationReadOnly template={template} />;
case 'refill':
return <RefillAutomationReadOnly />;
case 'periodic':
return <FixedAutomationReadOnly template={template} />;
case 'schedule':
return <ScheduleAutomationReadOnly template={template} />;
case 'percentage':
return (
<PercentageAutomationReadOnly
template={template}
categoryNameMap={categoryNameMap}
/>
);
case 'average':
case 'copy':
return <HistoricalAutomationReadOnly template={template} />;
case 'by':
return <BySaveAutomationReadOnly template={template} />;
case 'remainder':
return <RemainderAutomationReadOnly template={template} />;
case 'simple':
case 'spend':
case 'goal':
case 'error': {
const type = template.type;
return (
<Trans>
Unsupported template type: {{ type } satisfies TransObjectLiteral}
</Trans>
);
}
default:
template satisfies never;
return null;
}
}

View File

@@ -1,84 +0,0 @@
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import type { DisplayTemplateType } from './constants';
import { DEFAULT_PRIORITY } from './reducer';
export type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
export function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export type AutomationExample = {
displayType: DisplayTemplateType;
create: () => AutomationEntry;
};
export function getAutomationExamples(): AutomationExample[] {
return [
{
displayType: 'fixed',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'periodic',
amount: 100,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
priority: DEFAULT_PRIORITY,
},
'fixed',
),
},
{
displayType: 'by',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'by',
amount: 1200,
// Always 12 months out so users in late-year months don't get a
// target that's already passed.
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
'by',
),
},
{
displayType: 'schedule',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'schedule',
name: '',
priority: DEFAULT_PRIORITY,
},
'schedule',
),
},
];
}

View File

@@ -1,178 +0,0 @@
import { Trans } from 'react-i18next';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { formatMonthLabel } from './formatMonthLabel';
import type {
AutomationErrorKind,
GlobalConflictKind,
} from './validateAutomation';
export function AutomationErrorTitle({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return <Trans>Schedule not found</Trans>;
case 'refill-no-cap':
return <Trans>Refill needs a balance cap</Trans>;
case 'percentage-out-of-range':
return <Trans>Percentage out of range</Trans>;
case 'percentage-no-source':
return <Trans>Source category missing</Trans>;
case 'by-no-month':
return <Trans>Target month missing</Trans>;
case 'by-target-past':
return <Trans>Target is in the past</Trans>;
case 'percentage-source-not-found':
return <Trans>Source category not recognised</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorShort({
error,
}: {
error: AutomationErrorKind;
}) {
const locale = useLocale();
switch (error.kind) {
case 'schedule-not-found':
return error.name ? (
<Trans>No schedule named &ldquo;{{ name: error.name }}&rdquo;</Trans>
) : (
<Trans>Pick a schedule</Trans>
);
case 'refill-no-cap':
return <Trans>Add a balance cap above</Trans>;
case 'percentage-out-of-range':
return (
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
);
case 'percentage-no-source':
return <Trans>Pick a source category</Trans>;
case 'by-no-month':
return <Trans>Pick a target month</Trans>;
case 'by-target-past':
return (
<Trans>
{{ month: formatMonthLabel(error.month, locale) }} has already passed
</Trans>
);
case 'percentage-source-not-found':
return <Trans>Pick a valid income category</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorDetail({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return (
<Trans>
Pick an existing schedule, or create one in Schedules. This automation
can&rsquo;t run until it&rsquo;s linked to a schedule.
</Trans>
);
case 'refill-no-cap':
return (
<Trans>
Refill automations must have a &ldquo;Balance cap&rdquo; automation
added to use as the target.
</Trans>
);
case 'percentage-out-of-range':
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
case 'percentage-no-source':
return (
<Trans>
Percentage automations need a source category to calculate against.
</Trans>
);
case 'by-no-month':
return (
<Trans>
Goals by date need a target month. Pick when you want this fully
funded.
</Trans>
);
case 'by-target-past':
return (
<Trans>
Pick a future month, or switch to a recurring annual goal to keep
saving.
</Trans>
);
case 'percentage-source-not-found':
return (
<Trans>
The selected source &ldquo;{{ source: error.source }}&rdquo; is not a
known income category.
</Trans>
);
default:
error satisfies never;
return null;
}
}
export function GlobalConflictTitle({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
switch (conflict.kind) {
case 'over-income':
return <Trans>Automations will demand more than income</Trans>;
case 'percent-over-100':
return (
<Trans>
Percent automations total {{ total: Math.round(conflict.total) }}% of
income
</Trans>
);
default:
conflict satisfies never;
return null;
}
}
export function GlobalConflictDetail({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
const format = useFormat();
switch (conflict.kind) {
case 'over-income':
return (
<Trans>
This month&rsquo;s automations ask for around{' '}
{{ total: format(conflict.total, 'financial') }} but only{' '}
{{ income: format(conflict.income, 'financial') }} is available to
budget. Lower amounts or switch one to &ldquo;Whatever is left&rdquo;.
</Trans>
);
case 'percent-over-100':
return (
<Trans>
Your percent automations add up to more than 100% and will be capped
at 100%.
</Trans>
);
default:
conflict satisfies never;
return null;
}
}

View File

@@ -1,27 +1,23 @@
import type {
AverageTemplate,
ByTemplate,
CopyTemplate,
LimitTemplate,
PercentageTemplate,
PeriodicTemplate,
RefillTemplate,
RemainderTemplate,
ScheduleTemplate,
} from '@actual-app/core/types/models/templates';
export const displayTemplateTypes = [
'fixed',
'schedule',
'by',
'percentage',
'historical',
'limit',
'refill',
'remainder',
] as const;
['limit', 'Balance limit'] as const,
['refill', 'Refill'] as const,
['week', 'Fixed (weekly)'] as const,
['schedule', 'Existing schedule'] as const,
['percentage', 'Percent of category'] as const,
['historical', 'Copy past budgets'] as const,
];
export type DisplayTemplateType = (typeof displayTemplateTypes)[number];
export type DisplayTemplateType = (typeof displayTemplateTypes)[number][0];
export type ReducerState =
| {
@@ -34,7 +30,7 @@ export type ReducerState =
}
| {
template: PeriodicTemplate;
displayType: 'fixed';
displayType: 'week';
}
| {
template: ScheduleTemplate;
@@ -47,12 +43,4 @@ export type ReducerState =
| {
template: CopyTemplate | AverageTemplate;
displayType: 'historical';
}
| {
template: ByTemplate;
displayType: 'by';
}
| {
template: RemainderTemplate;
displayType: 'remainder';
};

View File

@@ -1,91 +0,0 @@
import type { ComponentType, SVGProps } from 'react';
import {
SvgChartPie,
SvgEquals,
SvgMoneyBag,
SvgPiggyBank,
SvgShare,
SvgTime,
} from '@actual-app/components/icons/v1';
import {
SvgArrowsSynchronize,
SvgCalendar3,
} from '@actual-app/components/icons/v2';
import { t } from 'i18next';
import type { DisplayTemplateType } from './constants';
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
export type DisplayTemplateMeta = {
label: string;
description: string;
icon: IconComponent;
};
export function getDisplayTemplateMeta(
displayType: DisplayTemplateType,
): DisplayTemplateMeta {
switch (displayType) {
case 'fixed':
return {
label: t('Fixed amount'),
description: t('Add a set amount every month, week, day, or year.'),
icon: SvgPiggyBank,
};
case 'schedule':
return {
label: t('Cover schedule'),
description: t('Save up for a recurring scheduled transaction.'),
icon: SvgCalendar3,
};
case 'by':
return {
label: t('Save by date'),
description: t(
'Spread a target amount across the months until a deadline.',
),
icon: SvgMoneyBag,
};
case 'percentage':
return {
label: t('% of income'),
description: t("A share of this month's or last month's income."),
icon: SvgChartPie,
};
case 'historical':
return {
label: t('From history'),
description: t(
'Use past months: average, a specific month, or a copy.',
),
icon: SvgTime,
};
case 'limit':
return {
label: t('Balance cap'),
description: t('Never let the category balance exceed a cap.'),
icon: SvgEquals,
};
case 'refill':
return {
label: t('Refill to cap'),
description: t(
'Top the category back up to the balance cap each month.',
),
icon: SvgArrowsSynchronize,
};
case 'remainder':
return {
label: t('Whatever is left'),
description: t(
'Split any remaining To Budget across these categories.',
),
icon: SvgShare,
};
default:
displayType satisfies never;
throw new Error(`Unknown display type: ${String(displayType)}`);
}
}

View File

@@ -1,116 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type BySaveAutomationProps = {
template: ByTemplate;
dispatch: (action: Action) => void;
};
export const BySaveAutomation = ({
template,
dispatch,
}: BySaveAutomationProps) => {
const { t } = useTranslation();
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const committedRepeat = template.repeat ?? 1;
const [rawRepeat, setRawRepeat] = useState(String(committedRepeat));
useEffect(() => {
setRawRepeat(String(committedRepeat));
}, [committedRepeat]);
const commitRepeat = () => {
const parsed = Math.max(1, Math.trunc(Number(rawRepeat)) || 1);
setRawRepeat(String(parsed));
if (parsed !== committedRepeat) {
dispatch(updateTemplate({ type: 'by', repeat: parsed }));
}
};
return (
<>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Total amount')} htmlFor="by-amount-field" />
<AmountInput
id="by-amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'by',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Target date')} htmlFor="by-month-field" />
<GenericInput
type="date"
field="date"
value={template.month ? `${template.month}-01` : ''}
onChange={(value: string) =>
dispatch(
updateTemplate({
type: 'by',
month: value ? value.slice(0, 7) : '',
}),
)
}
/>
</FormField>
</SpaceBetween>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Repeat every')}
htmlFor="by-repeat-amount-field"
/>
<Input
id="by-repeat-amount-field"
type="number"
min={1}
step={1}
value={rawRepeat}
onChangeValue={setRawRepeat}
onBlur={commitRepeat}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="by-period-field" />
<Select
id="by-period-field"
value={template.annual ? 'year' : 'month'}
onChange={value =>
dispatch(updateTemplate({ type: 'by', annual: value === 'year' }))
}
options={[
['month', t('Months')],
['year', t('Years')],
]}
/>
</FormField>
</SpaceBetween>
</>
);
};

View File

@@ -1,52 +0,0 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
type BySaveAutomationReadOnlyProps = {
template: ByTemplate;
};
export const BySaveAutomationReadOnly = ({
template,
}: BySaveAutomationReadOnlyProps) => {
const format = useFormat();
const locale = useLocale();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const month = formatMonthLabel(template.month, locale);
const repeat = template.repeat ?? 1;
if (template.annual) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} years
</Trans>
);
}
if (template.repeat && template.repeat > 0) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} months
</Trans>
);
}
return (
<Trans>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText> by{' '}
{{ month }}
</Trans>
);
};

View File

@@ -1,124 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export const FixedAutomation = ({
template,
dispatch,
}: FixedAutomationProps) => {
const { t } = useTranslation();
const periodUnitOptions: Array<[PeriodUnit, string]> = [
['day', t('days')],
['week', t('weeks')],
['month', t('months')],
['year', t('years')],
];
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const periodUnit = template.period?.period ?? 'month';
const periodAmount = template.period?.amount ?? 1;
const [rawPeriodAmount, setRawPeriodAmount] = useState(String(periodAmount));
// Resync when a different automation row is selected (the component
// instance is reused across rows).
useEffect(() => {
setRawPeriodAmount(String(periodAmount));
}, [periodAmount]);
const commitPeriodAmount = () => {
const parsed = Math.max(1, Math.trunc(Number(rawPeriodAmount)) || 1);
setRawPeriodAmount(String(parsed));
if (parsed !== periodAmount) {
dispatch(
updateTemplate({
type: 'periodic',
period: { period: periodUnit, amount: parsed },
}),
);
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Every')} htmlFor="period-amount-field" />
<Input
id="period-amount-field"
type="number"
min={1}
step={1}
value={rawPeriodAmount}
onChangeValue={setRawPeriodAmount}
onBlur={commitPeriodAmount}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="period-unit-field" />
<Select
id="period-unit-field"
value={periodUnit}
onChange={value =>
dispatch(
updateTemplate({
type: 'periodic',
period: {
period: value,
amount: periodAmount,
},
}),
)
}
options={periodUnitOptions}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Starting')} htmlFor="starting-field" />
<GenericInput
type="date"
field="date"
value={template.starting ?? ''}
onChange={(value: string) =>
dispatch(updateTemplate({ type: 'periodic', starting: value }))
}
/>
</FormField>
</SpaceBetween>
);
};

View File

@@ -1,61 +0,0 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export function FixedAutomationReadOnly({
template,
}: FixedAutomationReadOnlyProps) {
const format = useFormat();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const periodAmount = template.period?.amount ?? 1;
const periodUnit = template.period?.period ?? 'month';
switch (periodUnit) {
case 'day':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} days
</Trans>
);
case 'week':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} weeks
</Trans>
);
case 'month':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} months
</Trans>
);
case 'year':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} years
</Trans>
);
default:
return null;
}
}

View File

@@ -13,12 +13,12 @@ export const HistoricalAutomationReadOnly = ({
template,
}: HistoricalAutomationReadOnlyProps) => {
return template.type === 'copy' ? (
<Trans count={template.lookBack}>
Budget the same amount as {{ count: template.lookBack }} months ago
<Trans>
Budget the same amount as {{ amount: template.lookBack }} months ago
</Trans>
) : (
<Trans count={template.numMonths}>
Budget the average of the last {{ count: template.numMonths }} months
<Trans>
Budget the average of the last {{ amount: template.numMonths }} months
</Trans>
);
};

View File

@@ -1,8 +1,9 @@
import { Trans, useTranslation } from 'react-i18next';
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 {
currentDate,
dayFromDate,
@@ -17,7 +18,6 @@ import { setDay } from 'date-fns/setDay';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { LabeledCheckbox } from '#components/forms/LabeledCheckbox';
import { AmountInput } from '#components/util/AmountInput';
import { useDaysOfWeek } from '#hooks/useDaysOfWeek';
import { useFormat } from '#hooks/useFormat';
@@ -115,21 +115,26 @@ export const LimitAutomation = ({
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
{period === 'weekly' && amountField}
<FormField key="hold-overflow-field" style={{ flex: 1 }}>
<LabeledCheckbox
id="hold-overflow-field"
checked={!!hold}
onChange={e =>
dispatch(
updateTemplate({ type: 'limit', hold: e.target.checked }),
)
<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 }))
}
>
<span style={{ marginLeft: 6, fontSize: 12, whiteSpace: 'nowrap' }}>
<Trans>Retain existing funds over the cap</Trans>
</span>
</LabeledCheckbox>
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

@@ -44,7 +44,7 @@ export const PercentageAutomation = ({
? categories.map(group => ({
...group,
categories: group.categories?.filter(
category => category.id !== 'available funds',
category => category.id !== 'to-budget',
),
}))
: categories
@@ -87,7 +87,7 @@ export const PercentageAutomation = ({
updateTemplate({
type: 'percentage',
previous,
...(previous && template.category === 'available funds'
...(previous && template.category === 'to-budget'
? { category: '' }
: {}),
}),

View File

@@ -13,7 +13,7 @@ export const PercentageAutomationReadOnly = ({
}: PercentageAutomationReadOnlyProps) => {
const { t } = useTranslation();
if (template.category === 'all income') {
if (template.category === 'total') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of total income last month
@@ -25,7 +25,7 @@ export const PercentageAutomationReadOnly = ({
);
}
if (template.category === 'available funds') {
if (template.category === 'to-budget') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of available funds to budget

View File

@@ -1,62 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
type RemainderAutomationProps = {
template: RemainderTemplate;
dispatch: (action: Action) => void;
};
export const RemainderAutomation = ({
template,
dispatch,
}: RemainderAutomationProps) => {
const { t } = useTranslation();
const committedWeight = template.weight ?? 1;
// Track the raw input so the user can clear and retype without the field
// snapping back. Commit (and clamp) on blur.
const [rawWeight, setRawWeight] = useState(String(committedWeight));
useEffect(() => {
setRawWeight(String(committedWeight));
}, [committedWeight]);
const commitWeight = () => {
const parsed = Math.max(1, Math.trunc(Number(rawWeight)) || 1);
setRawWeight(String(parsed));
if (parsed !== committedWeight) {
dispatch(updateTemplate({ type: 'remainder', weight: parsed }));
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Weight')} htmlFor="remainder-weight-field" />
<Input
id="remainder-weight-field"
type="number"
min={1}
step={1}
value={rawWeight}
onChangeValue={setRawWeight}
onBlur={commitWeight}
/>
</FormField>
<Text style={{ flex: 2, color: theme.pageTextSubdued, fontSize: 12 }}>
<Trans>
Categories with higher weights get a bigger share of the leftover To
Budget.
</Trans>
</Text>
</SpaceBetween>
);
};

View File

@@ -1,18 +0,0 @@
import { Trans } from 'react-i18next';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
type RemainderAutomationReadOnlyProps = {
template: RemainderTemplate;
};
export const RemainderAutomationReadOnly = ({
template,
}: RemainderAutomationReadOnlyProps) => {
return (
<Trans>
Share remaining funds to budget (weight {{ weight: template.weight ?? 1 }}
)
</Trans>
);
};

View File

@@ -23,16 +23,8 @@ export const ScheduleAutomation = ({
dispatch,
}: ScheduleAutomationProps) => {
const { t } = useTranslation();
// Match the filter applied to the Select options below — completed and
// tombstoned schedules aren't selectable, so a category whose only
// schedules are completed should fall through to the "no schedules" state
// instead of showing an empty picker.
const selectableSchedules = schedules.filter(
(s): s is typeof s & { name: string } =>
!!s.name && !s.completed && !s.tombstone,
);
return selectableSchedules.length ? (
return schedules.length ? (
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Schedule')} htmlFor="schedule-field" />
@@ -49,7 +41,9 @@ export const ScheduleAutomation = ({
}),
)
}
options={selectableSchedules.map(s => [s.name, s.name] as const)}
options={schedules.flatMap(schedule =>
schedule.name ? [[schedule.name, schedule.name]] : [],
)}
/>
</FormField>
<FormField style={{ flex: 1 }}>

View File

@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
type WeekAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
const { t } = useTranslation();
return (
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
key="amount-input"
value={template.amount ?? 0}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: value,
}),
)
}
/>
</FormField>
);
};

View File

@@ -0,0 +1,31 @@
import { Trans } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type WeekAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export const WeekAutomationReadOnly = ({
template,
}: WeekAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget{' '}
<FinancialText>
{
{
amount: format(template.amount, 'financial'),
} as TransObjectLiteral
}
</FinancialText>{' '}
each week
</Trans>
);
};

View File

@@ -1,14 +0,0 @@
import * as monthUtils from '@actual-app/core/shared/months';
// Format a YYYY-MM string as "MMM yyyy" using the active locale (matching
// the convention used elsewhere in the codebase via monthUtils.format).
// Falls back to the raw input if it doesn't look like YYYY-MM, and to "—"
// for empty/missing values so callers don't need their own guards.
export function formatMonthLabel(
month: string | undefined | null,
locale?: Parameters<typeof monthUtils.format>[2],
): string {
if (!month) return '—';
if (!monthUtils.isValidYearMonth(month)) return month;
return monthUtils.format(`${month}-01`, 'MMM yyyy', locale);
}

View File

@@ -1,9 +1,4 @@
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import { firstDayOfMonth } from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import type { Action } from './actions';
@@ -30,7 +25,7 @@ export const getInitialState = (template: Template | null): ReducerState => {
priority: template.priority,
directive: template.directive,
},
displayType: 'fixed',
displayType: 'week',
};
case 'percentage':
return {
@@ -45,20 +40,13 @@ export const getInitialState = (template: Template | null): ReducerState => {
case 'periodic':
return {
template,
displayType: 'fixed',
displayType: 'week',
};
case 'spend':
throw new Error('Goal is not yet supported');
case 'by':
return {
template,
displayType: 'by',
};
throw new Error('Goal is not yet supported');
case 'remainder':
return {
template,
displayType: 'remainder',
};
throw new Error('Remainder is not yet supported');
case 'limit':
return {
template,
@@ -129,7 +117,7 @@ const changeType = (
type: 'percentage',
percent: 15,
previous: false,
category: 'all income',
category: 'total',
priority: DEFAULT_PRIORITY,
},
};
@@ -146,7 +134,7 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'fixed':
case 'week':
if (prevState.template.type === 'periodic') {
return prevState;
}
@@ -155,12 +143,12 @@ const changeType = (
template: {
directive: 'template',
type: 'periodic',
amount: 100,
amount: 5,
period: {
period: 'month',
period: 'week',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
starting: '',
priority: DEFAULT_PRIORITY,
},
};
@@ -180,35 +168,6 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'by':
if (prevState.template.type === 'by') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'by',
amount: 1200,
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
};
case 'remainder':
if (prevState.template.type === 'remainder') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'remainder',
weight: 1,
priority: null,
},
};
default:
// Make sure we're not missing any cases
throw new Error(

View File

@@ -7,24 +7,21 @@ export function useBudgetAutomationCategories() {
const { t } = useTranslation();
const { data: { grouped } = { grouped: [] } } = useCategories();
const categories = useMemo(() => {
const incomeGroups = grouped.filter(group => group.is_income);
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
return [
{
id: '',
name: t('Special categories'),
categories: [
{ id: 'all income', group: '', name: t('Total of all income') },
{ id: 'total', group: '', name: t('Total of all income') },
{
id: 'available funds',
id: 'to-budget',
group: '',
name: t('Available funds to budget'),
},
],
},
...incomeGroups.map(group => ({
...group,
name: t('Income categories'),
})),
{ ...incomeGroup, name: t('Income categories') },
];
}, [grouped, t]);

View File

@@ -1,69 +0,0 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { describe, expect, it } from 'vitest';
import { validatePercentageAllocation } from './validateAutomation';
function percent(
category: string,
percent: number,
previous = false,
): Template {
return {
type: 'percentage',
percent,
previous,
category,
directive: 'template',
priority: 1,
};
}
describe('validatePercentageAllocation', () => {
it('returns null when no percentage templates are present', () => {
expect(validatePercentageAllocation([])).toBeNull();
});
it('flags a single source over 100%', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('Salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
it('does not sum across distinct income sources', () => {
expect(
validatePercentageAllocation([
percent('Income-HSA', 100, true),
percent('Interest-HSA', 100),
]),
).toBeNull();
});
it('treats this-month and last-month income as different sources', () => {
expect(
validatePercentageAllocation([
percent('Salary', 100, false),
percent('Salary', 100, true),
]),
).toBeNull();
});
it('ignores templates with a missing source', () => {
const orphan = {
...percent('Salary', 100),
category: null as unknown as string,
};
expect(validatePercentageAllocation([orphan])).toBeNull();
});
it('matches sources case-insensitively', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
});

View File

@@ -1,110 +0,0 @@
import * as monthUtils from '@actual-app/core/shared/months';
import type { ScheduleEntity } from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import type { DisplayTemplateType } from './constants';
export type AutomationErrorKind =
| { kind: 'schedule-not-found'; name: string }
| { kind: 'refill-no-cap' }
| { kind: 'percentage-out-of-range'; percent: number }
| { kind: 'percentage-no-source' }
| { kind: 'percentage-source-not-found'; source: string }
| { kind: 'by-no-month' }
| { kind: 'by-target-past'; month: string };
export type GlobalConflictKind =
| { kind: 'over-income'; total: number; income: number }
| { kind: 'percent-over-100'; total: number };
export function validateAutomation(
template: Template,
displayType: DisplayTemplateType,
allTemplates: readonly Template[],
schedules: readonly ScheduleEntity[],
today: Date,
// Set of recognised percentage sources (income category ids, lower-cased
// category names, and special source aliases like 'all income'). When
// omitted the source-not-found check is skipped (the engine still validates
// server-side at apply time).
validPercentageSources?: ReadonlySet<string>,
): AutomationErrorKind | null {
switch (displayType) {
case 'schedule':
if (template.type !== 'schedule') return null;
if (!template.name) return { kind: 'schedule-not-found', name: '' };
if (
!schedules.some(
s => s.name === template.name && !s.completed && !s.tombstone,
)
) {
return { kind: 'schedule-not-found', name: template.name };
}
return null;
case 'refill':
if (!allTemplates.some(t => t.type === 'limit')) {
return { kind: 'refill-no-cap' };
}
return null;
case 'percentage':
if (template.type !== 'percentage') return null;
if (!template.category) return { kind: 'percentage-no-source' };
if (template.percent <= 0 || template.percent > 100) {
return {
kind: 'percentage-out-of-range',
percent: template.percent,
};
}
if (
validPercentageSources &&
!validPercentageSources.has(template.category) &&
!validPercentageSources.has(template.category.toLowerCase())
) {
return {
kind: 'percentage-source-not-found',
source: template.category,
};
}
return null;
case 'by': {
if (template.type !== 'by') return null;
if (!template.month || !monthUtils.isValidYearMonth(template.month)) {
return { kind: 'by-no-month' };
}
const targetMonth = template.month;
const startOfTodayMonth = monthUtils.monthFromDate(today);
// Pass bare YYYY-MM strings, matching the server-side check in
// CategoryTemplateContext.checkByAndScheduleAndSpend and avoiding the
// local-vs-UTC parsing footgun called out in shared/months.ts:_parse.
const monthsRemaining = monthUtils.differenceInCalendarMonths(
targetMonth,
startOfTodayMonth,
);
// Recurring goals (annual/repeat) anchored on a past month are
// legitimate — the engine rolls them forward by the period. Only flag
// the past-target case for one-shot goals. Mirrors the server check in
// CategoryTemplateContext.checkByAndScheduleAndSpend.
if (monthsRemaining < 0 && !template.annual && !template.repeat) {
return { kind: 'by-target-past', month: targetMonth };
}
return null;
}
default:
return null;
}
}
export function validatePercentageAllocation(
templates: readonly Template[],
): GlobalConflictKind | null {
const percentBySource = new Map<string, number>();
for (const t of templates) {
if (t.type !== 'percentage' || !t.category) continue;
const key = `${t.previous}|${t.category.toLocaleLowerCase()}`;
percentBySource.set(key, (percentBySource.get(key) ?? 0) + t.percent);
}
const maxPercent = Math.max(0, ...percentBySource.values());
return maxPercent > 100
? { kind: 'percent-over-100', total: maxPercent }
: null;
}

View File

@@ -13,13 +13,11 @@ type BudgetMenuProps = Omit<
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
};
export function BudgetMenu({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
@@ -41,9 +39,6 @@ export function BudgetMenu({
case 'apply-single-category-template':
onApplyBudgetTemplate?.();
break;
case 'copy-until-year-end':
onCopyUntilYearEnd?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
@@ -70,10 +65,6 @@ export function BudgetMenu({
name: 'set-single-12-avg',
text: t('Set to yearly average'),
},
{
name: 'copy-until-year-end',
text: t('Copy until year end'),
},
...(isGoalTemplatesEnabled
? [
{

View File

@@ -344,14 +344,6 @@ export const CategoryMonth = memo(function CategoryMonth({
message: t(`Budget template applied.`),
});
}}
onCopyUntilYearEnd={() => {
onMenuAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t(`Budget copied until year end.`),
});
}}
/>
</Popover>
</View>

View File

@@ -79,84 +79,61 @@ export function BudgetCell<
);
const onOpenCategoryBudgetMenu = useCallback(() => {
const sharedOptions = {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: (amount: number) => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: (numberOfMonths: number) => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
};
if (budgetType === 'envelope') {
dispatch(
pushModal({
modal: {
name: 'envelope-budget-menu',
options: sharedOptions,
},
}),
);
} else {
dispatch(
pushModal({
modal: {
name: 'tracking-budget-menu',
options: {
...sharedOptions,
onCopyUntilYearEnd: () => {
onBudgetAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t('{{categoryName}} budget copied until year end.', {
categoryName: category.name,
}),
});
},
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
dispatch(
pushModal({
modal: {
name: categoryBudgetMenuModal,
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
},
}),
);
}
},
}),
);
}, [
budgetType,
category.id,
@@ -168,7 +145,6 @@ export function BudgetCell<
showUndoNotification,
onEditNotes,
format,
t,
]);
return (

View File

@@ -1,41 +1,21 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
describe('migrateTemplatesToAutomations', () => {
it('drops simple templates that have no limit and no monthly amount', () => {
// these would otherwise be pushed as a phantom 'fixed' entry that
// crashes FixedAutomationReadOnly (no .amount, no .period)
it('preserves simple templates that have no limit and no monthly amount', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
} satisfies Template;
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
const result = migrateTemplatesToAutomations([simpleTemplate]);
it('drops simple templates whose monthly amount is zero with no limit', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
monthly: 0,
} satisfies Template;
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
it('throws when a goal directive reaches migration', () => {
const goalTemplate = {
type: 'goal',
amount: 1000,
directive: 'goal',
} satisfies Template;
expect(() => migrateTemplatesToAutomations([goalTemplate])).toThrow(
/Unsupported template type/,
);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('week');
expect(result[0].template).toEqual(simpleTemplate);
expect(result[0].id).toMatch(/^automation-/);
});
it('expands a simple template with limit into limit and refill entries', () => {
@@ -83,7 +63,7 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('fixed');
expect(result[0].displayType).toBe('week');
expect(result[0].template).toMatchObject({
type: 'periodic',
amount: 45,
@@ -99,10 +79,7 @@ describe('migrateTemplatesToAutomations', () => {
});
});
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
// `#template 20 up to 200 per week` budgets 20/month and caps at the
// limit — the engine's runSimple returns just the monthly value, so
// there is no implicit refill-to-cap behaviour to migrate.
it('expands a simple template with both limit and monthly into three entries in order', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
@@ -117,9 +94,13 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(2);
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
expect(result[1].template).toMatchObject({
expect(result).toHaveLength(3);
expect(result.map(entry => entry.displayType)).toEqual([
'limit',
'refill',
'week',
]);
expect(result[2].template).toMatchObject({
type: 'periodic',
amount: 20,
directive: 'template',

View File

@@ -0,0 +1,465 @@
import { useCallback, useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import { Warning } from '#components/alerts';
import { BudgetAutomation } from '#components/budget/goals/BudgetAutomation';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { DEFAULT_PRIORITY } from '#components/budget/goals/reducer';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'week';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
default:
return 'week';
}
}
function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
// Expand simple templates into limit, refill, and/or periodic templates
if (template.type === 'simple') {
let hasExpandedTemplate = false;
if (template.limit) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
// If it has a monthly amount, create a periodic template
if (template.monthly != null && template.monthly !== 0) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: template.monthly,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'week',
),
);
}
if (!hasExpandedTemplate) {
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
}
return;
}
// For all other template types, create a single entry
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}
function BudgetAutomationList({
automations,
setAutomations,
schedules,
categories,
style,
}: {
automations: AutomationEntry[];
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
style?: CSSProperties;
}) {
const onAdd = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
type: 'periodic',
amount: 500,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: DEFAULT_PRIORITY,
},
'week',
),
]);
};
const onAddLimit = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
),
]);
};
const onDelete = (index: number) => () => {
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
};
const onSave = useCallback(
(index: number) =>
(template: Template, displayType: DisplayTemplateType) => {
setAutomations(prev =>
prev.map((oldAutomation, mapIndex) =>
mapIndex === index
? { ...oldAutomation, template, displayType }
: oldAutomation,
),
);
},
[setAutomations],
);
const hasLimitAutomation = automations.some(
automation => automation.displayType === 'limit',
);
return (
<SpaceBetween
direction="vertical"
gap={20}
align="stretch"
wrap={false}
style={{
overflowY: 'scroll',
...style,
}}
>
{automations.map((automation, index) => (
<BudgetAutomation
key={automation.id}
onSave={onSave(index)}
onDelete={onDelete(index)}
template={automation.template}
categories={categories}
schedules={schedules}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={
automation.displayType === 'refill' ? onAddLimit : undefined
}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: 16,
paddingLeft: 30,
paddingRight: 16,
}}
/>
))}
<Button onPress={onAdd}>
<Trans>Add new automation</Trans>
</Button>
</SpaceBetween>
);
}
function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
const templates = useMemo(() => {
if (!notes) return null;
const lines = notes.split('\n');
return lines
.flatMap(line => {
if (line.trim().startsWith('#template')) return line;
if (line.trim().startsWith('#goal')) return line;
if (line.trim().startsWith('#cleanup')) return line;
return [];
})
.join('\n');
}, [notes]);
if (!templates) return null;
return (
<Warning style={style}>
<SpaceBetween direction="vertical" style={{ minHeight: 'unset' }}>
<View>
<Trans>
This category uses notes-based automations (formerly "budget
templates"). We have automatically imported your existing
automations below. Please review them for accuracy and hit save to
complete the migration.
</Trans>
</View>
<View>
<Trans>
Original templates:
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
marginTop: 4,
padding: 12,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
>
{templates}
</View>
</Trans>
</View>
</SpaceBetween>
</Warning>
);
}
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const [automations, setAutomations] = useState<
Record<string, AutomationEntry[]>
>({});
const onLoaded = useCallback((result: Record<string, Template[]>) => {
const next: Record<string, AutomationEntry[]> = {};
for (const [id, templates] of Object.entries(result)) {
next[id] = migrateTemplatesToAutomations(templates);
}
setAutomations(next);
}, []);
const { loading } = useBudgetAutomations({
categoryId,
onLoaded,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
query: schedulesQuery,
});
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const onSave = async (close: () => void) => {
if (!automations[categoryId]) {
close();
return;
}
const templates = automations[categoryId].map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{
id: categoryId,
templates,
},
],
source: 'ui',
});
close();
};
return (
<Modal
name="category-automations-edit"
containerProps={{
style: { width: 850, height: 650, paddingBottom: 20 },
}}
>
{({ state }) => (
<SpaceBetween
direction="vertical"
wrap={false}
align="stretch"
style={{ height: '100%' }}
>
<ModalHeader
title={t('Budget automations: {{category}}', {
category: currentCategory?.name,
})}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
{loading ? (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : (
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0 }}
/>
)}
<BudgetAutomationList
automations={automations[categoryId] || []}
setAutomations={(
cb: (prev: AutomationEntry[]) => AutomationEntry[],
) => {
setAutomations(prev => ({
...prev,
[categoryId]: cb(prev[categoryId] || []),
}));
}}
schedules={schedules}
categories={categories}
/>
</SpaceBetween>
)}
<View style={{ flexGrow: 1 }} />
<SpaceBetween
style={{
marginTop: 20,
justifyContent: 'flex-end',
flexShrink: 0,
}}
>
{!needsMigration && (
<Link
variant="text"
onClick={() => {
const templates = automations[categoryId] || [];
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: templates.map(({ template }) => template),
},
},
}),
);
}}
>
<Trans>Un-migrate</Trans>
</Link>
)}
{/* <View style={{ flex: 1 }} /> */}
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={() => onSave(() => state.close())}
>
<Trans>Save</Trans>
</Button>
</SpaceBetween>
</SpaceBetween>
)}
</Modal>
);
}

View File

@@ -1,294 +0,0 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgDelete } from '@actual-app/components/icons/v0';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Input } from '@actual-app/components/input';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import { ActiveEditor } from '#components/budget/goals/ActiveEditor';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import {
AutomationErrorDetail,
AutomationErrorTitle,
} from '#components/budget/goals/automationMessages';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import {
getInitialState,
templateReducer,
} from '#components/budget/goals/reducer';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { TypePicker } from './TypePicker';
const CONFIG_PANEL_CLASS = css({
'& > *:first-child': {
marginTop: 0,
},
'& span > label': {
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
},
// Match Select borders to text inputs (Button uses buttonNormalBorder which
// is brighter than formInputBorder in dark/midnight themes).
'& button[type="button"]:not([aria-pressed])': {
borderColor: theme.formInputBorder,
},
});
const SINGLETON_TYPES: ReadonlySet<DisplayTemplateType> = new Set([
'limit',
'refill',
'remainder',
]);
type AutomationEditorPaneProps = {
entries: AutomationEntry[];
activeIdx: number;
automationErrors: (AutomationErrorKind | null)[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
setEntries: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
onDelete: (index: number) => void;
};
export function AutomationEditorPane({
entries,
activeIdx,
automationErrors,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
setEntries,
onDelete,
}: AutomationEditorPaneProps) {
const active = entries[activeIdx];
const activeError = automationErrors[activeIdx];
const state = active ? getInitialState(active.template) : null;
const dispatch = (action: Parameters<typeof templateReducer>[1]) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const current = getInitialState(entry.template);
const next = templateReducer(current, action);
return {
id: entry.id,
template: next.template,
displayType: next.displayType,
};
}),
);
};
const setPriority = (priority: number) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const t = entry.template;
switch (t.type) {
case 'percentage':
case 'periodic':
case 'by':
case 'spend':
case 'simple':
case 'schedule':
case 'average':
case 'copy':
case 'refill':
return { ...entry, template: { ...t, priority } };
default:
return entry;
}
}),
);
};
const disabledTypes = new Set<DisplayTemplateType>();
entries.forEach((entry, i) => {
if (i !== activeIdx && SINGLETON_TYPES.has(entry.displayType)) {
disabledTypes.add(entry.displayType);
}
});
if (!active || !state) {
return (
<View style={{ padding: 20, color: theme.pageTextSubdued }}>
<Trans>Select an automation on the left.</Trans>
</View>
);
}
return (
<View
style={{
flex: 1,
padding: 20,
overflowY: 'auto',
gap: 14,
}}
>
{activeError && (
<View
style={{
padding: '10px 12px',
borderRadius: 6,
backgroundColor: theme.errorBackground,
border: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 13,
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<SvgAlertTriangle
width={14}
height={14}
style={{ marginTop: 2, color: 'inherit', flexShrink: 0 }}
/>
<View style={{ minWidth: 0 }}>
<Text style={{ fontWeight: 600, color: 'inherit' }}>
<AutomationErrorTitle error={activeError} />
</Text>
<Text
style={{
fontSize: 12,
marginTop: 2,
color: 'inherit',
display: 'block',
}}
>
<AutomationErrorDetail error={activeError} />
</Text>
</View>
</View>
)}
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Automation type</Trans>
</Text>
<TypePicker
active={state.displayType}
disabledTypes={disabledTypes}
onPick={type => dispatch({ type: 'set-type', payload: type })}
/>
{state.displayType !== 'refill' && (
<>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Configuration</Trans>
</Text>
<View
className={CONFIG_PANEL_CLASS}
style={{
padding: 16,
backgroundColor: theme.tableBackground,
borderRadius: 6,
border: `1px solid ${theme.tableBorder}`,
}}
>
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
</View>
</>
)}
{state.displayType === 'refill' && (
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
{'priority' in state.template &&
typeof state.template.priority === 'number' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Text
style={{
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
<Trans>Priority</Trans>
</Text>
<Input
type="number"
style={{ width: 64 }}
value={String(state.template.priority)}
onChangeValue={value => {
if (value === '') return;
const parsed = Math.round(Number(value));
if (Number.isNaN(parsed)) return;
setPriority(Math.max(0, parsed));
}}
/>
</View>
)}
<View style={{ flex: 1 }} />
<Button
variant="bare"
onPress={() => onDelete(activeIdx)}
style={{ color: theme.errorText }}
>
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<SvgDelete width={10} height={10} style={{ color: 'inherit' }} />
<Trans>Delete automation</Trans>
</span>
</Button>
</View>
</View>
);
}

View File

@@ -1,179 +0,0 @@
import { useTranslation } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { AutomationErrorShort } from '#components/budget/goals/automationMessages';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
import { TemplateSentence } from '#components/budget/goals/TemplateSentence';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { useFormat } from '#hooks/useFormat';
type AutomationListRowProps = {
index: number;
entry: AutomationEntry;
isActive: boolean;
error: AutomationErrorKind | null;
contribution: number | null;
categoryNameMap: Record<string, string>;
onSelect: (index: number) => void;
};
export function AutomationListRow({
index,
entry,
isActive,
error,
contribution,
categoryNameMap,
onSelect,
}: AutomationListRowProps) {
const { t } = useTranslation();
const format = useFormat();
const meta = getDisplayTemplateMeta(entry.displayType);
const Icon = meta.icon;
const subtitle = error ? (
<AutomationErrorShort error={error} />
) : (
<TemplateSentence
template={entry.template}
categoryNameMap={categoryNameMap}
/>
);
const borderColor = isActive
? theme.tableBorderSelected
: error
? theme.errorBorder
: 'transparent';
const backgroundColor = isActive
? theme.upcomingBackground
: error
? theme.errorBackground
: 'transparent';
const titleColor = error ? theme.errorText : theme.pageText;
const subtitleColor = error ? theme.errorText : theme.pageTextSubdued;
const priority =
'priority' in entry.template && typeof entry.template.priority === 'number'
? entry.template.priority
: null;
return (
<View
onClick={() => onSelect(index)}
aria-label={t('Select automation')}
style={{
flexShrink: 0,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 10,
marginBottom: 4,
borderRadius: 6,
border: `1px solid ${borderColor}`,
backgroundColor,
cursor: 'pointer',
position: 'relative',
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: error
? theme.errorBackground
: isActive
? theme.upcomingBackground
: theme.pillBackground,
color: error
? theme.errorText
: isActive
? theme.pageTextPositive
: theme.pageTextSubdued,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon width={14} height={14} style={{ color: 'inherit' }} />
</View>
<View style={{ minWidth: 0, flex: 1 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
fontSize: 12,
fontWeight: 600,
color: titleColor,
}}
>
<Text>{meta.label}</Text>
{error && (
<SvgAlertTriangle
width={11}
height={11}
style={{ color: 'inherit' }}
/>
)}
</View>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{subtitle}
</Text>
</View>
<View
style={{
flexShrink: 0,
alignItems: 'flex-end',
gap: 2,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color:
contribution == null ||
Number.isNaN(contribution) ||
contribution === 0
? theme.pageTextSubdued
: theme.pageText,
}}
>
{contribution == null || Number.isNaN(contribution)
? '—'
: contribution > 0
? '+' + format(contribution, 'financial')
: format(contribution, 'financial')}
</Text>
{priority != null && (
<Text
style={{
fontSize: 10,
color: theme.pageTextSubdued,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '0.04em',
}}
>
{t('Priority: {{priority}}', { priority })}
</Text>
)}
</View>
</View>
);
}

View File

@@ -1,68 +0,0 @@
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Warning } from '#components/alerts';
import { useNotes } from '#hooks/useNotes';
export function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
if (!notes) return null;
const templates = notes
.split('\n')
.filter(line => /^\s*#(template|goal|cleanup)\b/.test(line))
.join('\n');
if (!templates) return null;
return (
<Warning
style={{
padding: '8px 12px',
fontSize: 12,
...style,
}}
>
<View style={{ gap: 4 }}>
<Text>
<Trans>
Imported from notes-based templates. Review and Save to complete the
migration.
</Trans>
</Text>
<details>
<summary style={{ cursor: 'pointer', fontSize: 11, opacity: 0.85 }}>
<Trans>Show original templates</Trans>
</summary>
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 11,
marginTop: 6,
padding: 8,
borderRadius: 4,
// Translucent overlay rather than a theme token so the inset
// effect works regardless of the surrounding Warning colour
// (which differs between light/dark/midnight themes).
backgroundColor: 'rgba(0, 0, 0, 0.15)',
maxHeight: 120,
overflowY: 'auto',
}}
>
{templates}
</View>
</details>
</View>
</Warning>
);
}

View File

@@ -1,408 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import debounce from 'lodash/debounce';
import {
createAutomationEntry,
getAutomationExamples,
} from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import {
validateAutomation,
validatePercentageAllocation,
} from '#components/budget/goals/validateAutomation';
import { Link } from '#components/common/Link';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
import { AutomationEditorPane } from './AutomationEditorPane';
import { AutomationListRow } from './AutomationListRow';
import { BudgetAutomationMigrationWarning } from './BudgetAutomationMigrationWarning';
import { ConflictBanner } from './ConflictBanner';
import { EmptyState } from './EmptyState';
const RULE_LIST_WIDTH = 310;
const ALWAYS_SCROLL_CLASS = css({
scrollbarGutter: 'stable',
'&::-webkit-scrollbar': {
width: 11,
backgroundColor: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
width: 7,
minHeight: 24,
borderRadius: 30,
backgroundClip: 'padding-box',
border: '2px solid rgba(0, 0, 0, 0)',
backgroundColor: theme.tableBorder,
},
});
type BudgetAutomationsBodyProps = {
categoryId: string;
categoryName: string;
needsMigration: boolean;
initialEntries: AutomationEntry[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
month: string;
onClose: () => void;
};
export function BudgetAutomationsBody({
categoryId,
categoryName,
needsMigration,
initialEntries,
schedules,
categories,
month,
onClose,
}: BudgetAutomationsBodyProps) {
const dispatch = useDispatch();
const format = useFormat();
const locale = useLocale();
const [entries, setEntries] = useState<AutomationEntry[]>(initialEntries);
const [activeIdx, setActiveIdx] = useState(0);
const [saving, setSaving] = useState(false);
const [dryRun, setDryRun] = useState<{
budgeted: number;
perTemplate: number[];
} | null>(null);
const onAddAutomation = (create?: () => AutomationEntry) => {
const fallback = getAutomationExamples().find(
e => e.displayType === 'fixed',
);
const entry = (create ?? fallback?.create)?.();
if (!entry) return;
setEntries(prev => {
const next = [...prev, entry];
setActiveIdx(next.length - 1);
return next;
});
};
const onAddLimitAutomation = () => {
const entry = createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
);
setEntries(prev => [entry, ...prev]);
setActiveIdx(0);
};
const onDelete = (index: number) => {
setEntries(prev => {
const next = prev.filter((_, i) => i !== index);
setActiveIdx(currentActive => {
if (next.length === 0) return 0;
if (currentActive >= next.length) return next.length - 1;
if (currentActive > index) return currentActive - 1;
return currentActive;
});
return next;
});
};
const onSave = async () => {
if (saving) return;
setSaving(true);
try {
const templatesToSave = entries.map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{ id: categoryId, templates: templatesToSave },
],
source: 'ui',
});
onClose();
} finally {
setSaving(false);
}
};
const onUnmigrate = () => {
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: entries.map(({ template }) => template),
},
},
}),
);
};
const templates = entries.map(e => e.template);
const validPercentageSources = new Set<string>([
'all income',
'available funds',
]);
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (!cat.is_income) continue;
validPercentageSources.add(cat.id);
if (cat.name) validPercentageSources.add(cat.name.toLowerCase());
}
}
const automationErrors = entries.map(entry =>
validateAutomation(
entry.template,
entry.displayType,
templates,
schedules,
new Date(),
validPercentageSources,
),
);
useEffect(() => {
if (templates.length === 0) {
setDryRun({ budgeted: 0, perTemplate: [] });
return;
}
let cancelled = false;
const run = debounce(async () => {
try {
const result = await send('budget/dry-run-category-template', {
month,
categoryId,
templates,
});
if (!cancelled) setDryRun(result);
} catch {
if (!cancelled) setDryRun(null);
}
}, 200);
void run();
return () => {
cancelled = true;
run.cancel();
};
}, [templates, month, categoryId]);
const totalMonthly = dryRun?.budgeted ?? 0;
const contributions: (number | null)[] = entries.map((_, i) =>
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
);
const hasErrors = automationErrors.some(error => error !== null);
const conflict = validatePercentageAllocation(templates);
const categoryNameMap: Record<string, string> = {};
for (const group of categories) {
for (const cat of group.categories ?? []) {
categoryNameMap[cat.id] = cat.name;
}
}
const hasLimitAutomation = entries.some(e => e.displayType === 'limit');
const safeActiveIdx = Math.min(activeIdx, Math.max(0, entries.length - 1));
return (
<View style={{ flex: 1, flexDirection: 'column', minHeight: 0 }}>
<View
style={{
padding: '20px 24px 16px',
borderBottom: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<View style={{ minWidth: 0 }}>
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
<Trans>Budget automation</Trans>
</Text>
<Text
style={{
fontSize: 20,
fontWeight: 600,
color: theme.pageText,
marginTop: 2,
}}
>
{categoryName}
</Text>
</View>
<View style={{ textAlign: 'right', flexShrink: 0, minWidth: 220 }}>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
}}
>
<Trans>
Projected for {{ month: formatMonthLabel(month, locale) }}
</Trans>
</Text>
<Text
style={{
fontSize: 22,
fontWeight: 600,
color: theme.pageTextPositive,
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.2,
display: 'block',
}}
>
{format(totalMonthly, 'financial')}
</Text>
</View>
</View>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0, margin: '12px 24px 0' }}
/>
)}
{conflict && <ConflictBanner conflict={conflict} />}
<View
style={{
flex: 1,
flexDirection: 'row',
minHeight: 0,
}}
>
<View
className={ALWAYS_SCROLL_CLASS}
style={{
width: RULE_LIST_WIDTH,
borderRight: `1px solid ${theme.tableBorder}`,
padding: 10,
overflowY: 'scroll',
}}
>
<View
style={{
flexShrink: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 8px',
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Text>
<Trans>Automations</Trans>
</Text>
</View>
{entries.map((entry, i) => (
<AutomationListRow
key={entry.id}
index={i}
entry={entry}
isActive={i === safeActiveIdx}
error={automationErrors[i]}
contribution={contributions[i]}
categoryNameMap={categoryNameMap}
onSelect={setActiveIdx}
/>
))}
<Button
variant="bare"
onPress={() => onAddAutomation()}
style={{
width: '100%',
marginTop: 8,
padding: 10,
border: `1px dashed ${theme.tableBorder}`,
borderRadius: 6,
color: theme.pageTextPositive,
fontWeight: 600,
fontSize: 12,
justifyContent: 'center',
}}
>
+ <Trans>Add an automation</Trans>
</Button>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
{entries.length === 0 ? (
<EmptyState onAdd={onAddAutomation} />
) : (
<AutomationEditorPane
entries={entries}
activeIdx={safeActiveIdx}
automationErrors={automationErrors}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
setEntries={setEntries}
onDelete={onDelete}
/>
)}
</View>
</View>
<View
style={{
padding: '12px 20px',
borderTop: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
backgroundColor: theme.tableBackground,
flexShrink: 0,
}}
>
{!needsMigration && (
<Link variant="text" onClick={onUnmigrate}>
<Trans>Un-migrate to text notes</Trans>
</Link>
)}
<View style={{ flex: 1 }} />
<Button onPress={onClose}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={onSave}
isDisabled={hasErrors || conflict !== null || saving}
>
<Trans>Save</Trans>
</Button>
</View>
</View>
);
}

View File

@@ -1,136 +0,0 @@
import { useState } from 'react';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { View } from '@actual-app/components/view';
import { currentMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type { Template } from '@actual-app/core/types/models/templates';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Modal } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { BudgetAutomationsBody } from './BudgetAutomationsBody';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
import {
hasCleanupLine,
UnsupportedDirectivesNotice,
} from './UnsupportedDirectivesNotice';
const MODAL_WIDTH = 960;
const MODAL_HEIGHT = 760;
export function BudgetAutomationsModal({
categoryId,
month,
}: {
categoryId: string;
month?: string;
}) {
const [parsedTemplates, setParsedTemplates] = useState<Template[] | null>(
null,
);
const effectiveMonth = month ?? currentMonth();
const onLoaded = (result: Record<string, Template[]>) => {
setParsedTemplates(result[categoryId] ?? []);
};
const { loading } = useBudgetAutomations({ categoryId, onLoaded });
const { schedules } = useSchedules({ query: q('schedules').select('*') });
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const notes = useNotes(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const hasGoalTemplate =
parsedTemplates?.some(t => t.type === 'goal') ?? false;
const hasErrorTemplate =
parsedTemplates?.some(t => t.type === 'error') ?? false;
const hasSpendTemplate =
parsedTemplates?.some(t => t.type === 'spend') ?? false;
// Only surface stale `#cleanup` lines for categories that haven't been
// migrated to UI-managed automations; once `source === 'ui'`, the notes
// are no longer the source of truth.
const hasCleanupDirective = needsMigration && hasCleanupLine(notes);
const hasUnsupportedDirective =
hasGoalTemplate ||
hasErrorTemplate ||
hasSpendTemplate ||
hasCleanupDirective;
const incomeNameToId = new Map<string, string>();
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (cat.name) incomeNameToId.set(cat.name.toLowerCase(), cat.id);
}
}
const resolved = parsedTemplates?.map(t => {
if (t.type !== 'percentage' || !t.category) return t;
const id = incomeNameToId.get(t.category.toLowerCase());
return id ? { ...t, category: id } : t;
});
const initialEntries =
resolved && !hasUnsupportedDirective
? migrateTemplatesToAutomations(resolved)
: null;
return (
<Modal
name="category-automations-edit"
containerProps={{
style: {
width: MODAL_WIDTH,
maxWidth: '95vw',
height: MODAL_HEIGHT,
maxHeight: '90vh',
padding: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
},
}}
>
{({ state }) => (
<View style={{ flex: 1, minHeight: 0 }}>
{loading || parsedTemplates === null ? (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : hasUnsupportedDirective ? (
<UnsupportedDirectivesNotice
hasGoalTemplate={hasGoalTemplate}
hasErrorTemplate={hasErrorTemplate}
hasSpendTemplate={hasSpendTemplate}
hasCleanupDirective={hasCleanupDirective}
onClose={() => state.close()}
/>
) : (
<BudgetAutomationsBody
categoryId={categoryId}
categoryName={currentCategory?.name ?? ''}
needsMigration={needsMigration}
initialEntries={initialEntries ?? []}
schedules={schedules}
categories={categories}
month={effectiveMonth}
onClose={() => state.close()}
/>
)}
</View>
)}
</Modal>
);
}

View File

@@ -1,39 +0,0 @@
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
GlobalConflictDetail,
GlobalConflictTitle,
} from '#components/budget/goals/automationMessages';
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
type ConflictBannerProps = {
conflict: GlobalConflictKind;
};
export function ConflictBanner({ conflict }: ConflictBannerProps) {
return (
<View
style={{
padding: '8px 22px',
backgroundColor: theme.errorBackground,
borderBottom: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 12,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
}}
>
<SvgAlertTriangle width={14} height={14} style={{ color: 'inherit' }} />
<Text style={{ color: 'inherit' }}>
<strong>
<GlobalConflictTitle conflict={conflict} />.
</strong>{' '}
<GlobalConflictDetail conflict={conflict} />
</Text>
</View>
);
}

View File

@@ -1,138 +0,0 @@
import { Trans } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { getAutomationExamples } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type EmptyStateProps = {
onAdd: (create: () => AutomationEntry) => void;
};
export function EmptyState({ onAdd }: EmptyStateProps) {
const examples = getAutomationExamples();
return (
<View
style={{
padding: '40px 20px',
textAlign: 'center',
maxWidth: 540,
margin: '0 auto',
}}
>
<View
style={{
width: 56,
height: 56,
borderRadius: 12,
margin: '0 auto 14px',
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
}}
>
<SvgAlertTriangle width={20} height={20} style={{ color: 'inherit' }} />
</View>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
letterSpacing: '-0.01em',
}}
>
<Trans>No automations yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
marginTop: 4,
marginBottom: 22,
display: 'block',
}}
>
<Trans>
Budget automations keep this category funded with one click each
month. Start with one of these.
</Trans>
</Text>
<View
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 10,
textAlign: 'center',
}}
>
{examples.map(example => {
const meta = getDisplayTemplateMeta(example.displayType);
const Icon = meta.icon;
return (
<View
key={example.displayType}
role="button"
tabIndex={0}
aria-label={meta.label}
onClick={() => onAdd(example.create)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAdd(example.create);
}
}}
style={{
padding: 14,
borderRadius: 8,
backgroundColor: theme.cardBackground,
border: `1px solid ${theme.tableBorder}`,
gap: 6,
cursor: 'pointer',
}}
>
<View
style={{
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 6,
}}
>
<Icon width={16} height={16} />
</View>
<Text
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageText,
}}
>
{meta.label}
</Text>
<Text
style={{
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.4,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
</View>
);
}

View File

@@ -1,109 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { displayTemplateTypes } from '#components/budget/goals/constants';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type TypePickerProps = {
active: DisplayTemplateType;
disabledTypes: ReadonlySet<DisplayTemplateType>;
onPick: (type: DisplayTemplateType) => void;
};
export function TypePicker({ active, disabledTypes, onPick }: TypePickerProps) {
const { t } = useTranslation();
const entries = displayTemplateTypes.map(
id => [id, getDisplayTemplateMeta(id)] as const,
);
const disabledHint = t('Only one of this type allowed per category');
return (
<View
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 8,
}}
>
{entries.map(([id, meta]) => {
const Icon = meta.icon;
const isActive = id === active;
const isDisabled = !isActive && disabledTypes.has(id);
return (
<View
key={id}
role="button"
tabIndex={isDisabled ? -1 : 0}
aria-pressed={isActive}
aria-disabled={isDisabled}
title={isDisabled ? disabledHint : undefined}
onClick={() => {
if (!isDisabled) onPick(id);
}}
onKeyDown={e => {
if (isDisabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPick(id);
}
}}
style={{
padding: '10px 10px 8px',
borderRadius: 6,
backgroundColor: isActive
? theme.upcomingBackground
: theme.cardBackground,
border: `1px solid ${isActive ? theme.pageTextPositive : theme.tableBorder}`,
gap: 6,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.45 : 1,
minWidth: 0,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Icon
width={16}
height={16}
style={{
flexShrink: 0,
color: isActive ? theme.pageTextPositive : theme.pageText,
}}
/>
<Text
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: isActive ? theme.pageTextPositive : theme.pageText,
lineHeight: 1.25,
}}
>
{meta.label}
</Text>
</View>
<Text
style={{
display: 'block',
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.35,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
);
}

View File

@@ -1,100 +0,0 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
export function UnsupportedDirectivesNotice({
hasGoalTemplate,
hasErrorTemplate,
hasSpendTemplate,
hasCleanupDirective,
onClose,
}: {
hasGoalTemplate: boolean;
hasErrorTemplate: boolean;
hasSpendTemplate: boolean;
hasCleanupDirective: boolean;
onClose: () => void;
}) {
return (
<View
style={{
flex: 1,
padding: 32,
gap: 16,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
<SvgAlertTriangle
width={32}
height={32}
style={{ color: theme.errorText }}
/>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
}}
>
<Trans>This category isn&rsquo;t supported in the UI yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
maxWidth: 480,
lineHeight: 1.5,
}}
>
{hasErrorTemplate ? (
<Trans>
One or more <code>#template</code> lines in this category&rsquo;s
notes couldn&rsquo;t be parsed. Fix them as text first, then re-open
this modal to migrate.
</Trans>
) : hasSpendTemplate ? (
<Trans>
This category uses a <code>spend from</code> template, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
) : hasGoalTemplate && hasCleanupDirective ? (
<Trans>
This category&rsquo;s notes use <code>#goal</code> and{' '}
<code>#cleanup</code> directives, neither of which the budget
automations UI handles yet. Keep editing them as text in the
category&rsquo;s notes.
</Trans>
) : hasGoalTemplate ? (
<Trans>
This category uses a <code>#goal</code> directive, which the budget
automations UI doesn&rsquo;t handle yet. Keep editing it as text in
the category&rsquo;s notes.
</Trans>
) : (
<Trans>
This category uses a <code>#cleanup</code> directive, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
)}
</Text>
<Button onPress={onClose}>
<Trans>Close</Trans>
</Button>
</View>
);
}
const CLEANUP_DIRECTIVE = /^\s*#cleanup\b/;
export function hasCleanupLine(notes: string | null | undefined): boolean {
if (!notes) return false;
return notes.split('\n').some(line => CLEANUP_DIRECTIVE.test(line));
}

View File

@@ -1,2 +0,0 @@
export { BudgetAutomationsModal } from './BudgetAutomationsModal';
export { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';

View File

@@ -1,111 +0,0 @@
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import { createAutomationEntry } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'fixed';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
case 'by':
return 'by';
case 'remainder':
return 'remainder';
case 'goal':
case 'error':
case 'spend':
// filtered upstream by hasUnsupportedDirective; surface if it ever isn't
throw new Error(`Unsupported template type reached migration`);
default: {
const _exhaustive: never = template;
void _exhaustive;
throw new Error(`Unhandled template type`);
}
}
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
if (template.type === 'simple') {
const monthly = template.monthly;
const hasMonthly = monthly != null && monthly !== 0;
if (template.limit) {
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
// The implicit refill only applies to a limit-only simple template
// (e.g. `#template up to 200`). When a monthly amount is also set
// (`#template 50 up to 200`), the engine just budgets the monthly
// amount and clamps to the cap — no top-up to the limit.
if (!hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
}
if (hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: monthly,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'fixed',
),
);
}
// a simple template with neither monthly nor limit is a no-op; drop it
// rather than passing through as a phantom 'fixed' entry that would
// crash FixedAutomationReadOnly (no .amount, no .period)
return;
}
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}

View File

@@ -73,6 +73,21 @@ export function ConfirmTransactionEditModal({
out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciledTransfer' ? (
<Block>
<Trans>
This transfer has a linked transaction in another account that
is reconciled. Duplicating it may bring that account's
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
<Trans>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
<Trans>

View File

@@ -42,7 +42,6 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
@@ -201,7 +200,6 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyUntilYearEnd={onCopyUntilYearEnd}
/>
)}
</>

View File

@@ -11,26 +11,9 @@ import type { Template } from '@actual-app/core/types/models/templates';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { Notes } from '#components/Notes';
import { useCategories } from '#hooks/useCategories';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
// The UI's CategoryAutocomplete stores the income category id on a
// percentage template, but text-template grammar addresses categories by
// name. Rewrite percentage templates so the un-migrated notes are readable
// (and don't drift if the category is later renamed).
function sanitizePercentageCategoriesForNotes(
templates: Template[],
idToName: Map<string, string>,
): Template[] {
return templates.map(template => {
if (template.type !== 'percentage') return template;
const name = idToName.get(template.category);
if (name) return { ...template, category: name };
return template;
});
}
export function UnmigrateBudgetAutomationsModal({
categoryId,
templates,
@@ -40,7 +23,6 @@ export function UnmigrateBudgetAutomationsModal({
}) {
const { t } = useTranslation();
const { data: category } = useCategory(categoryId);
const { data: categoryData } = useCategories();
const existingNotes = useNotes(categoryId) || '';
const [editedNotes, setEditedNotes] = useState<string>('');
@@ -48,18 +30,12 @@ export function UnmigrateBudgetAutomationsModal({
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!categoryData?.list) return;
const idToName = new Map<string, string>();
for (const cat of categoryData.list) {
idToName.set(cat.id, cat.name);
}
const sanitized = sanitizePercentageCategoriesForNotes(templates, idToName);
let mounted = true;
void (async () => {
try {
const text: string = await send(
'budget/render-note-templates',
sanitized,
templates,
);
if (mounted) setRendered(text);
} catch {
@@ -69,7 +45,7 @@ export function UnmigrateBudgetAutomationsModal({
return () => {
mounted = false;
};
}, [templates, categoryData]);
}, [templates]);
// Seed editable notes once templates rendered
useEffect(() => {
@@ -111,21 +87,13 @@ export function UnmigrateBudgetAutomationsModal({
async function onSave(close: () => void) {
setSaving(true);
try {
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
// Hand control back to the notes parser: clear the UI-managed goal_def
// and mark notes as the source of truth. `storeNoteTemplates` will
// re-derive goal_def from the notes the next time it runs (e.g. on
// modal open or when applying templates).
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates: [] }],
source: 'notes',
});
await send('budget/store-note-templates');
close();
} finally {
setSaving(false);
}
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates }],
source: 'notes',
});
setSaving(false);
close();
}
return (

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest';
import { calculateSpendingReportTimeRange } from './reportRanges';
// In test mode, monthUtils.currentMonth() returns '2017-01'
describe('calculateSpendingReportTimeRange', () => {
it('preserves the saved compare month for live average reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'average',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare month for live budget reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'budget',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare months for live single month reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
compareTo: '2016-11',
isLive: true,
mode: 'single-month',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-11');
});
it('defaults live average reports to the current month without a saved compare month', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
isLive: true,
mode: 'average',
});
expect(compare).toBe('2017-01');
expect(compareTo).toBe('2017-01');
});
});

View File

@@ -249,12 +249,7 @@ export function calculateSpendingReportTimeRange({
mode?: 'budget' | 'average' | 'single-month';
}): [string, string] {
if (['budget', 'average'].includes(mode) && isLive) {
const month = compare ?? monthUtils.currentMonth();
return [month, month];
}
if (mode === 'single-month' && isLive && compare) {
return [compare, compareTo ?? monthUtils.subMonths(compare, 1)];
return [monthUtils.currentMonth(), monthUtils.currentMonth()];
}
const [start, end] = calculateTimeRange(

View File

@@ -44,7 +44,6 @@ import type {
RuleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { FinancialText } from '#components/FinancialText';
import { StatusBadge } from '#components/schedules/StatusBadge';
@@ -783,7 +782,7 @@ function StageButton({
}
function newInput(item) {
return { ...item, inputKey: uuidv4() };
return { ...item, inputKey: crypto.randomUUID() };
}
function ConditionsList({
@@ -821,7 +820,7 @@ function ConditionsList({
field,
op: 'is',
value: null,
inputKey: uuidv4(),
inputKey: crypto.randomUUID(),
});
onChangeConditions(copy);
}
@@ -1007,7 +1006,9 @@ export function RuleEditor({
}: RuleEditorProps) {
const { t } = useTranslation();
const [conditions, setConditions] = useState(
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuidv4() })),
defaultRule.conditions
.map(parse)
.map(c => ({ ...c, inputKey: crypto.randomUUID() })),
);
const [actionSplits, setActionSplits] = useState(() => {
const parsedActions = defaultRule.actions.map(parse);
@@ -1015,17 +1016,17 @@ export function RuleEditor({
(acc, action) => {
const splitIndex = action.options?.splitIndex ?? 0;
acc[splitIndex] = acc[splitIndex] ?? {
id: uuidv4(),
id: crypto.randomUUID(),
actions: [],
};
acc[splitIndex].actions.push({
...action,
inputKey: uuidv4(),
inputKey: crypto.randomUUID(),
});
return acc;
},
// The pre-split group is always there
[{ id: uuidv4(), actions: [] }],
[{ id: crypto.randomUUID(), actions: [] }],
);
});
const [stage, setStage] = useState(defaultRule.stage);
@@ -1085,12 +1086,12 @@ export function RuleEditor({
function addActionToSplitAfterIndex(splitIndex, actionIndex) {
let newAction;
if (splitIndex && !actionSplits[splitIndex]?.actions?.length) {
actionSplits[splitIndex] = { id: uuidv4(), actions: [] };
actionSplits[splitIndex] = { id: crypto.randomUUID(), actions: [] };
newAction = {
op: 'set-split-amount',
options: { method: 'remainder', splitIndex },
value: null,
inputKey: uuidv4(),
inputKey: crypto.randomUUID(),
};
} else {
const fieldsArray =
@@ -1106,7 +1107,7 @@ export function RuleEditor({
op: 'set',
value: '',
options: { splitIndex },
inputKey: uuidv4(),
inputKey: crypto.randomUUID(),
};
}

View File

@@ -151,7 +151,10 @@ export function ExperimentalFeatures() {
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const showGoalTemplatesUI = goalTemplatesEnabled || goalTemplatesUIEnabled;
const showGoalTemplatesUI =
goalTemplatesUIEnabled ||
(goalTemplatesEnabled &&
localStorage.getItem('devEnableGoalTemplatesUI') === 'true');
const showServerPrefs =
localStorage.getItem('devEnableServerPrefs') === 'true';
@@ -166,10 +169,7 @@ export function ExperimentalFeatures() {
</FeatureToggle>
{showGoalTemplatesUI && (
<View style={{ paddingLeft: 22 }}>
<FeatureToggle
flag="goalTemplatesUIEnabled"
feedbackLink="https://github.com/actualbudget/actual/issues/7692"
>
<FeatureToggle flag="goalTemplatesUIEnabled">
<Trans>Subfeature: Budget automations UI</Trans>
</FeatureToggle>
</View>

View File

@@ -23,7 +23,6 @@ import type {
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { format as formatDate, parse as parseDate } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { AuthProvider } from '#auth/AuthProvider';
import { SchedulesProvider } from '#hooks/useCachedSchedules';
@@ -1087,7 +1086,7 @@ describe('Transactions', () => {
// Change the id to simulate a new transaction being added, and
// work with that one. This makes sure that the transaction table
// properly references new data.
transactions[0] = { ...transactions[0], id: uuidv4() };
transactions[0] = { ...transactions[0], id: crypto.randomUUID() };
updateProps({ transactions });
function expectErrorToNotExist(transactions: TransactionEntity[]) {

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { send } from '@actual-app/core/platform/client/connection';
import * as monthUtils from '@actual-app/core/shared/months';
import { computeSchedulePreviewTransactions } from '@actual-app/core/shared/schedules';
import { ungroupTransactions } from '@actual-app/core/shared/transactions';
import type { IntegerAmount } from '@actual-app/core/shared/util';
@@ -101,13 +100,6 @@ export function usePreviewTransactions({
),
}));
// re-sort in case rule actions have changed the dates
withDefaults.sort(
(a, b) =>
monthUtils.parseDate(b.date).getTime() -
monthUtils.parseDate(a.date).getTime() || a.amount - b.amount,
);
const ungroupedTransactions = ungroupTransactions(withDefaults);
setPreviewTransactions(ungroupedTransactions);

View File

@@ -297,11 +297,7 @@ export function useTransactionBatchActions() {
added: transactions.reduce(
(newTransactions: TransactionEntity[], trans: TransactionEntity) => {
return newTransactions.concat(
realizeTempTransactions(ungroupTransaction(trans)).map(t => ({
...t,
cleared: false,
reconciled: false,
})),
realizeTempTransactions(ungroupTransaction(trans)),
);
},
[],
@@ -313,7 +309,11 @@ export function useTransactionBatchActions() {
onSuccess?.(ids);
};
await onConfirmDuplicate(ids);
await checkForReconciledTransactions(
ids,
'batchDuplicateWithReconciled',
onConfirmDuplicate,
);
};
const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => {
@@ -445,6 +445,7 @@ export function useTransactionBatchActions() {
> = {
batchDeleteWithReconciled: 'batchDeleteWithReconciledTransfer',
batchEditWithReconciled: 'batchEditWithReconciledTransfer',
batchDuplicateWithReconciled: 'batchDuplicateWithReconciledTransfer',
};
const checkForReconciledTransactions = async (

View File

@@ -32,6 +32,8 @@ export type ConfirmTransactionEditReason =
| 'batchDeleteWithReconciledTransfer'
| 'batchEditWithReconciled'
| 'batchEditWithReconciledTransfer'
| 'batchDuplicateWithReconciled'
| 'batchDuplicateWithReconciledTransfer'
| 'editReconciled'
| 'unlockReconciled'
| 'deleteReconciled';
@@ -354,7 +356,6 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
@@ -609,7 +610,6 @@ export type Modal =
name: 'category-automations-edit';
options: {
categoryId: CategoryEntity['id'];
month?: string;
};
}
| {

View File

@@ -1,7 +1,6 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { resetApp } from '#app/appSlice';
@@ -59,7 +58,7 @@ const notificationsSlice = createSlice({
addNotification(state, action: PayloadAction<AddNotificationPayload>) {
const notification = {
...action.payload.notification,
id: action.payload.notification.id || uuidv4(),
id: action.payload.notification.id || crypto.randomUUID(),
};
if (state.notifications.find(n => n.id === notification.id)) {
@@ -69,7 +68,7 @@ const notificationsSlice = createSlice({
},
addGenericErrorNotification(state) {
const notification: NotificationWithId = {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message: t(
'Something internally went wrong. You may want to restart the app if anything looks wrong. ' +

View File

@@ -4,7 +4,6 @@ import { send } from '@actual-app/core/platform/client/connection';
import type { PayeeEntity } from '@actual-app/core/types/models';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
@@ -27,7 +26,7 @@ function dispatchErrorNotification(
dispatch(
addNotification({
notification: {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message,
pre: error ? error.message : undefined,

View File

@@ -13,7 +13,6 @@ import type {
} from '@actual-app/core/types/util';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
@@ -55,7 +54,7 @@ function dispatchErrorNotification(
dispatch(
addNotification({
notification: {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message,
pre: error ? error.message : undefined,

View File

@@ -381,7 +381,7 @@ describe('SharedWorker coordinator', () => {
describe('tab disconnection', () => {
it('leader disconnect promotes follower', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
@@ -392,6 +392,10 @@ describe('SharedWorker coordinator', () => {
});
follower.postMessage.mockClear();
// Find current leader to disconnect it
const group = coordinator.getState().budgetGroups.get('budget-1');
const leader = group.leaderPort as MockPort;
// Leader closes tab
sendMsg(leader, { type: 'tab-closing' });
@@ -478,116 +482,6 @@ describe('SharedWorker coordinator', () => {
});
});
describe('__resume-tab', () => {
it('keeps the lobby leader attached during startup resume signals', () => {
const leader = connectTab(coordinator);
sendInit(leader);
leader.postMessage.mockClear();
sendMsg(leader, { type: '__resume-tab', budgetId: null });
expect(coordinator.getState().portToBudget.get(leader)).toBe('__lobby');
expect(coordinator.getState().unassignedPorts.has(leader)).toBe(false);
expect(
coordinator.getState().budgetGroups.get('__lobby').leaderPort,
).toBe(leader);
expect(leader.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'UNASSIGNED',
}),
);
});
it('does not route orphaned ports through another live budget before they resume', () => {
const orphanedLeader = setupBudgetGroup(coordinator, 'budget-1');
const liveLeader = setupBudgetGroup(coordinator, 'budget-2');
orphanedLeader.postMessage.mockClear();
liveLeader.postMessage.mockClear();
vi.advanceTimersByTime(10_000);
sendMsg(liveLeader, { type: '__heartbeat-pong' });
vi.advanceTimersByTime(10_000);
sendMsg(orphanedLeader, { id: 'req-orphan', name: 'get-budgets' });
expect(liveLeader.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'get-budgets' }),
}),
);
});
it('re-elects an orphaned solo tab as leader and restores its budget', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
leader.postMessage.mockClear();
vi.advanceTimersByTime(10_000);
vi.advanceTimersByTime(10_000);
sendMsg(leader, { type: '__resume-tab', budgetId: 'budget-1' });
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(true);
expect(coordinator.getState().portToBudget.get(leader)).toBe('budget-1');
expect(
coordinator.getState().budgetGroups.get('budget-1').leaderPort,
).toBe(leader);
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-1',
}),
);
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__become-leader',
budgetToRestore: 'budget-1',
}),
);
});
it('reattaches an orphaned tab to an existing budget group as follower', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
vi.advanceTimersByTime(10_000);
sendMsg(leader, { type: '__heartbeat-pong' });
vi.advanceTimersByTime(10_000);
sendMsg(follower, { type: '__resume-tab', budgetId: 'budget-1' });
expect(coordinator.getState().connectedPorts.includes(follower)).toBe(
true,
);
expect(
coordinator
.getState()
.budgetGroups.get('budget-1')
.followers.has(follower),
).toBe(true);
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'FOLLOWER',
budgetId: 'budget-1',
}),
);
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
});
// ── Worker message routing ──────────────────────────────────────────
describe('Worker message routing', () => {
@@ -896,98 +790,6 @@ describe('SharedWorker coordinator', () => {
expect(group.requestNames.get('restore-1')).toBe('load-budget');
expect(group.requestBudgetIds.get('restore-1')).toBe('budget-1');
});
it('waits to broadcast connect until a promoted leader finishes restoring', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(leader, { id: 'cb-leader', name: 'close-budget' });
sendMsg(follower, {
type: '__track-restore',
requestId: '__restore-budget',
budgetId: 'budget-1',
});
const reloaded = connectTab(coordinator);
sendInit(reloaded);
reloaded.postMessage.mockClear();
sendMsg(follower, {
type: '__from-worker',
msg: { type: 'connect' },
});
expect(
coordinator.getState().budgetGroups.get('budget-1').backendConnected,
).toBe(false);
expect(reloaded.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
sendMsg(follower, {
type: '__from-worker',
msg: { type: 'reply', id: '__restore-budget', result: {} },
});
expect(
coordinator.getState().budgetGroups.get('budget-1').backendConnected,
).toBe(true);
expect(reloaded.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
it('still broadcasts connect if restore finishes before the worker connect event', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
sendMsg(leader, { id: 'cb-leader', name: 'close-budget' });
sendMsg(follower, {
type: '__track-restore',
requestId: '__restore-budget',
budgetId: 'budget-1',
});
const reloaded = connectTab(coordinator);
sendInit(reloaded);
reloaded.postMessage.mockClear();
sendMsg(follower, {
type: '__from-worker',
msg: { type: 'reply', id: '__restore-budget', result: {} },
});
expect(reloaded.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
sendMsg(follower, {
type: '__from-worker',
msg: { type: 'connect' },
});
expect(
coordinator.getState().budgetGroups.get('budget-1').backendConnected,
).toBe(true);
expect(reloaded.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
});
// ── Multiple budgets ────────────────────────────────────────────────

View File

@@ -19,8 +19,6 @@ type BudgetGroup = {
leaderPort: CoordinatorPort;
followers: Set<CoordinatorPort>;
backendConnected: boolean;
pendingConnect: boolean;
restoreBudgetId: string | null;
requestToPort: Map<string, CoordinatorPort>;
requestNames: Map<string, string>;
requestBudgetIds: Map<string, string>;
@@ -91,22 +89,12 @@ export function createCoordinator({
leaderPort,
followers: new Set(),
backendConnected: false,
pendingConnect: false,
restoreBudgetId: null,
requestToPort: new Map(),
requestNames: new Map(),
requestBudgetIds: new Map(),
};
}
function broadcastConnect(budgetId: string) {
const connectMsg = { type: 'connect' };
broadcastToAllInGroup(budgetId, connectMsg);
for (const port of unassignedPorts) {
port.postMessage(connectMsg);
}
}
function logState(action: string) {
const groups: string[] = [];
for (const [bid, g] of budgetGroups) {
@@ -117,110 +105,6 @@ export function createCoordinator({
);
}
function isTrackedPort(port: CoordinatorPort) {
return connectedPorts.includes(port);
}
function ensureTrackedPort(port: CoordinatorPort) {
if (!isTrackedPort(port)) {
connectedPorts.push(port);
}
}
function hasConnectedBackend() {
for (const [, group] of budgetGroups) {
if (group.backendConnected) {
return true;
}
}
return false;
}
function isGroupMember(group: BudgetGroup, port: CoordinatorPort) {
return group.leaderPort === port || group.followers.has(port);
}
function movePortToUnassigned(port: CoordinatorPort) {
const prevBudget = portToBudget.get(port);
if (prevBudget) {
removePortFromGroup(port, prevBudget);
}
portToBudget.delete(port);
unassignedPorts.add(port);
}
function restoreUnassignedPort(port: CoordinatorPort) {
movePortToUnassigned(port);
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
if (hasConnectedBackend()) {
port.postMessage({ type: 'connect' });
}
}
function resumePort(port: CoordinatorPort, budgetId?: string | null) {
const normalizedBudgetId = budgetId || null;
const wasTracked = isTrackedPort(port);
const currentBudget = portToBudget.get(port);
const currentGroup = currentBudget ? budgetGroups.get(currentBudget) : null;
const alreadyAttached =
!!normalizedBudgetId &&
currentBudget === normalizedBudgetId &&
!!currentGroup &&
isGroupMember(currentGroup, port);
const alreadyUnassigned =
!normalizedBudgetId &&
!currentBudget &&
wasTracked &&
unassignedPorts.has(port);
const alreadyOnTemporaryGroup =
!normalizedBudgetId &&
!!currentBudget &&
!!currentGroup &&
currentBudget.startsWith('__') &&
isGroupMember(currentGroup, port);
ensureTrackedPort(port);
pendingPongs.delete(port);
if (alreadyAttached) {
logState(`Tab resumed on budget "${normalizedBudgetId}"`);
return;
}
if (alreadyUnassigned) {
logState('Tab resume confirmed while unassigned');
return;
}
if (alreadyOnTemporaryGroup) {
logState(`Tab resumed on coordinator group "${currentBudget}"`);
return;
}
if (!normalizedBudgetId) {
if (budgetGroups.size === 0) {
electLeader('__lobby', port);
logState('Tab resumed into lobby');
} else {
restoreUnassignedPort(port);
logState('Tab resumed as unassigned');
}
return;
}
const existingGroup = budgetGroups.get(normalizedBudgetId);
if (existingGroup) {
addFollower(normalizedBudgetId, port);
logState(`Tab resumed on budget "${normalizedBudgetId}" as follower`);
return;
}
electLeader(normalizedBudgetId, port, normalizedBudgetId);
logState(`Tab resumed on budget "${normalizedBudgetId}" as leader`);
}
function broadcastToGroup(
budgetId: string,
msg: unknown,
@@ -310,15 +194,10 @@ export function createCoordinator({
} else {
group.leaderPort = port;
group.backendConnected = false;
group.pendingConnect = false;
group.restoreBudgetId = budgetToRestore || null;
group.requestToPort.clear();
group.requestNames.clear();
group.requestBudgetIds.clear();
}
if (!group.restoreBudgetId && budgetToRestore) {
group.restoreBudgetId = budgetToRestore;
}
const prevBudget = portToBudget.get(port);
if (prevBudget && prevBudget !== budgetId) {
removePortFromGroup(port, prevBudget);
@@ -444,15 +323,6 @@ export function createCoordinator({
);
}
if (oldGroup.restoreBudgetId === newBudgetId) {
oldGroup.restoreBudgetId = null;
if (oldGroup.pendingConnect) {
oldGroup.backendConnected = true;
oldGroup.pendingConnect = false;
broadcastConnect(newBudgetId);
}
}
logState(`Budget "${newBudgetId}" ready`);
}
@@ -531,14 +401,6 @@ export function createCoordinator({
return;
}
if (msg.type === '__resume-tab') {
resumePort(
port,
typeof msg.budgetId === 'string' ? msg.budgetId : null,
);
return;
}
if (msg.type === '__heartbeat-pong') {
pendingPongs.delete(port);
return;
@@ -551,7 +413,14 @@ export function createCoordinator({
if (lastAppInitFailure) {
port.postMessage(lastAppInitFailure);
} else {
if (hasConnectedBackend()) {
let anyConnected = false;
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
anyConnected = true;
break;
}
}
if (anyConnected) {
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
port.postMessage({ type: 'connect' });
} else if (budgetGroups.size > 0) {
@@ -606,11 +475,10 @@ export function createCoordinator({
group.requestNames.delete(workerMsg.id as string);
}
} else if (workerMsg.type === 'connect') {
if (group.restoreBudgetId) {
group.pendingConnect = true;
} else {
group.backendConnected = true;
broadcastConnect(portBudget!);
group.backendConnected = true;
broadcastToAllInGroup(portBudget!, workerMsg);
for (const p of unassignedPorts) {
p.postMessage(workerMsg);
}
} else if (workerMsg.type === 'app-init-failure') {
lastAppInitFailure = workerMsg;
@@ -625,7 +493,6 @@ export function createCoordinator({
if (msg.type === '__track-restore') {
if (group) {
group.restoreBudgetId = msg.budgetId as string;
group.requestToPort.set(msg.requestId as string, port);
group.requestNames.set(msg.requestId as string, 'load-budget');
group.requestBudgetIds.set(
@@ -822,7 +689,7 @@ export function createCoordinator({
// ── Default: track and forward to leader ───────────────────
let targetGroup = group;
if (!targetGroup && unassignedPorts.has(port) && isTrackedPort(port)) {
if (!targetGroup) {
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
targetGroup = g;

View File

@@ -4,7 +4,6 @@ import { send } from '@actual-app/core/platform/client/connection';
import type { TagEntity } from '@actual-app/core/types/models';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
@@ -26,7 +25,7 @@ function dispatchErrorNotification(
dispatch(
addNotification({
notification: {
id: uuidv4(),
id: crypto.randomUUID(),
type: 'error',
message,
pre: error ? error.message : undefined,

View File

@@ -1,5 +1,4 @@
import type { RuleEntity } from '@actual-app/core/types/models';
import { v4 as uuidv4 } from 'uuid';
export type ActionSplit = {
id: string;
@@ -13,7 +12,7 @@ export function groupActionsBySplitIndex(
const splitIndex =
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
acc[splitIndex] = acc[splitIndex] ?? {
id: uuidv4(),
id: crypto.randomUUID(),
actions: [],
};
acc[splitIndex].actions.push(action);

View File

@@ -376,9 +376,7 @@ export default defineConfig(async ({ mode, command }) => {
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
// Disabled: caches stale assets across reloads in dev. Plugin
// code that explicitly needs a SW can register one itself.
enabled: false,
enabled: true, // We need service worker in dev mode to work with plugins
type: 'module',
},
workbox: {

View File

@@ -39,7 +39,7 @@ const isPlaywrightTest = process.env.EXECUTION_CONTEXT === 'playwright';
const isDev = !isPlaywrightTest && !app.isPackaged; // dev mode if not packaged and not playwright
process.env.lootCoreScript = isDev
? '@actual-app/core/lib-dist/electron/bundle.desktop.js' // serve from local output in development (provides hot-reloading)
? 'loot-core/lib-dist/electron/bundle.desktop.js' // serve from local output in development (provides hot-reloading)
: path.resolve(BUILD_ROOT, 'loot-core/lib-dist/electron/bundle.desktop.js'); // serve from build in production
// This allows relative URLs to be resolved to app:// which makes
@@ -239,11 +239,12 @@ async function startSyncServer() {
),
};
// require.resolve will recursively search up the workspace for the module
const syncServerRoot = path.dirname(
require.resolve('@actual-app/sync-server/package.json'),
const serverPath = path.join(
// require.resolve will recursively search up the workspace for the module
path.dirname(require.resolve('@actual-app/sync-server/package.json')),
'build',
'app.js',
);
const serverPath = path.join(syncServerRoot, 'build/app.js');
const webRoot = path.join(
// require.resolve will recursively search up the workspace for the module

View File

@@ -1,6 +1,6 @@
{
"name": "desktop-electron",
"version": "26.5.2",
"version": "26.4.0",
"description": "A simple and powerful personal finance system",
"author": "Actual",
"main": "build/desktop-electron/index.js",
@@ -50,7 +50,7 @@
"afterSign": "./build/desktop-electron/afterSignHook.js",
"electronFuses": {
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": true,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false
},
"mac": {

View File

@@ -1,170 +0,0 @@
---
title: Release 26.5.0
description: New release of Actual.
date: 2026-05-03T10:00
slug: release-26.5.0
tags: [announcement, release]
hide_table_of_contents: false
authors: jfdoming
---
This release introduces powerful new reporting capabilities as well as numerous fixes:
- Experimental: Age of Money and Sankey Diagram reports
- Experimental: Custom themes expanded with five new community themes (Nord, Ilavenil, Gruvbox Light/Dark, You Need A Theme Light/Dark)
- Tax-style distribution in split transactions
<!--truncate-->
**Docker Tag: 26.5.0**
<!-- release-notes:auto-generated -->
#### Features
- [#6685](https://github.com/actualbudget/actual/pull/6685) Added Age of Money report. — thanks @sztomi
- [#7220](https://github.com/actualbudget/actual/pull/7220) Add Sankey diagram report with two view modes (spent and budgeted) to visualize money flow through categories — thanks @emiltb & @andrewhumble
#### Enhancements
- [#7116](https://github.com/actualbudget/actual/pull/7116) Add Category Group support to Budget Analysis Report — thanks @tabedzki
- [#7217](https://github.com/actualbudget/actual/pull/7217) Add "Last 30 days" date range option to custom reports live mode — thanks @kenkuo
- [#7257](https://github.com/actualbudget/actual/pull/7257) Add option to distribute remaining amount proportionally — thanks @victle
- [#7335](https://github.com/actualbudget/actual/pull/7335) Add BALANCE_OF function to rules formulas for other account balances — thanks @StephenBrown2
- [#7346](https://github.com/actualbudget/actual/pull/7346) Add CLP currency — thanks @vectorcrumb
- [#7378](https://github.com/actualbudget/actual/pull/7378) cli: account ordering, better agent instructions, fix types — thanks @MatissJanis
- [#7382](https://github.com/actualbudget/actual/pull/7382) Add error boundary to dashboard widgets, displaying fallback UI for rendering failures. — thanks @MatissJanis
- [#7392](https://github.com/actualbudget/actual/pull/7392) Clarify that end-to-end encryption only protects budget data, excluding bank sync tokens. — thanks @MatissJanis
- [#7423](https://github.com/actualbudget/actual/pull/7423) Change formatting of reconcile form for clarity and ease of use. — thanks @Juulz
- [#7432](https://github.com/actualbudget/actual/pull/7432) Add rate limiting to authentication endpoints to prevent brute-force attacks. — thanks @actualbudget
- [#7434](https://github.com/actualbudget/actual/pull/7434) Add per-schedule custom upcoming length override, allowing each schedule to have its own "upcoming" notification window instead of using only the global setting. — thanks @jreniel
- [#7437](https://github.com/actualbudget/actual/pull/7437) Add scoped ErrorBoundary to Rules page to contain rendering crashes. — thanks @tmchow
- [#7441](https://github.com/actualbudget/actual/pull/7441) 🎨 You Need A Theme Light, based on 2026 nYNAB light — thanks @Juulz
- [#7442](https://github.com/actualbudget/actual/pull/7442) Add more filtering options for transactions in Spent mode of the Sankey chart — thanks @emiltb
- [#7447](https://github.com/actualbudget/actual/pull/7447) 🎨 You Need A Theme Dark, based on 2026 nYNAB dark theme colors. — thanks @Juulz
- [#7476](https://github.com/actualbudget/actual/pull/7476) Added a new Sankey Options menu with a toggle to view values as percentages. — thanks @emiltb
- [#7495](https://github.com/actualbudget/actual/pull/7495) Custom Themes: custom CSS overrides now persist across theme changes and show a visible indicator next to the Theme dropdown when active — thanks @MatissJanis
- [#7513](https://github.com/actualbudget/actual/pull/7513) Add nord theme to custom catalog — thanks @aadhithbala
- [#7543](https://github.com/actualbudget/actual/pull/7543) Added theme Ilavenil to custom theme catalog — thanks @aadhithbala
- [#7571](https://github.com/actualbudget/actual/pull/7571) Add Gruvbox Light and Gruvbox Dark custom themes to the theme catalog. — thanks @Dakyne
- [#7581](https://github.com/actualbudget/actual/pull/7581) API: export model types via "@actual-app/api/models". — thanks @MatissJanis
- [#7582](https://github.com/actualbudget/actual/pull/7582) Optimize Sankey chart datamodel to include income sources, allow layer filtering and better budget handling — thanks @emiltb
- [#7605](https://github.com/actualbudget/actual/pull/7605) Make double Ctrl-f trigger browser find — thanks @jfdoming
- [#7610](https://github.com/actualbudget/actual/pull/7610) Reimport Deleted is now on by default and persisted between imports — thanks @alecbakholdin
#### Bugfixes
- [#7242](https://github.com/actualbudget/actual/pull/7242) Fix `api.updateTransaction()` corrupting split parent transactions when doing partial updates — thanks @lwarrenthompson
- [#7253](https://github.com/actualbudget/actual/pull/7253) Custom Themes: improved responsiveness of the theme catalog — thanks @MatissJanis
- [#7269](https://github.com/actualbudget/actual/pull/7269) Show confirmation dialog when editing/duplicating/deleting transfers where the other half is reconciled — thanks @matt-fidd
- [#7270](https://github.com/actualbudget/actual/pull/7270) Fix transaction quick search incorrectly treating "?" and "%" as wildcards, causing all transactions to be returned instead of only those matching the literal character — thanks @eduardopio03
- [#7283](https://github.com/actualbudget/actual/pull/7283) Standardise ledger scrolling when using keyboard shortcuts — thanks @JSkinnerUK
- [#7284](https://github.com/actualbudget/actual/pull/7284) Handle normalisation of some common non-latin diacritic characters. — thanks @JSkinnerUK
- [#7296](https://github.com/actualbudget/actual/pull/7296) Fix Net Worth graph showing a time-interval less than specified — thanks @emiltb
- [#7304](https://github.com/actualbudget/actual/pull/7304) Fix UUID showing when switching filter operators — thanks @sk10727-a11y
- [#7324](https://github.com/actualbudget/actual/pull/7324) Fixes transaction query by tag when tag starts with $ — thanks @gust0717
- [#7347](https://github.com/actualbudget/actual/pull/7347) Update code to record timestamp from account linking using handleSyncResponse. — thanks @JkBoyo
- [#7356](https://github.com/actualbudget/actual/pull/7356) Fix custom report editor retaining unsaved settings when navigating between routes. — thanks @tmchow
- [#7368](https://github.com/actualbudget/actual/pull/7368) Allow end to end encryption of budget files in the desktop apps — thanks @pickle-and-pork
- [#7373](https://github.com/actualbudget/actual/pull/7373) Fix date variable in rule formulas — thanks @youngcw
- [#7381](https://github.com/actualbudget/actual/pull/7381) Fix crash when viewing account ledger with expired recurring schedules. — thanks @MatissJanis
- [#7428](https://github.com/actualbudget/actual/pull/7428) Fix path traversal vulnerability in file upload sanitization — thanks @MatissJanis
- [#7453](https://github.com/actualbudget/actual/pull/7453) Fixes unlocking on child split transactions — thanks @JSkinnerUK
- [#7460](https://github.com/actualbudget/actual/pull/7460) Fix bug where total selected balance is not shown when it is zero — thanks @Kennedy242
- [#7468](https://github.com/actualbudget/actual/pull/7468) Ship .d.ts declaration files from @actual-app/core so that downstream API consumers with strict TypeScript settings no longer get type errors. — thanks @MatissJanis
- [#7478](https://github.com/actualbudget/actual/pull/7478) Resolve subpath imports when running server locally — thanks @MatissJanis
- [#7487](https://github.com/actualbudget/actual/pull/7487) Fix bank sync account linking modal being disabled when relinking existing accounts — thanks @matt-fidd
- [#7496](https://github.com/actualbudget/actual/pull/7496) Fixes inconsistency between web UI and mobile UI where make transfer is not available on uncategorized transaction menu. — thanks @tempiz
- [#7515](https://github.com/actualbudget/actual/pull/7515) Fix unreadable "Use Regular Expressions" checkbox label in the notes find-and-replace modal by applying the correct theme color. — thanks @JasmineLCY
- [#7522](https://github.com/actualbudget/actual/pull/7522) Fix runImport failing when ACTUAL_DATA_DIR environment variable is not set — thanks @matt-fidd
- [#7524](https://github.com/actualbudget/actual/pull/7524) Fix build error with typescript v6 — thanks @matt-fidd
- [#7532](https://github.com/actualbudget/actual/pull/7532) Fix error when clearing the payee field of a transaction — thanks @matt-fidd
- [#7564](https://github.com/actualbudget/actual/pull/7564) Fix Docker container failing to start due to unresolved workspace dependencies. — thanks @MatissJanis
- [#7565](https://github.com/actualbudget/actual/pull/7565) crdt: fix nightly publishing of the packages — thanks @MatissJanis
- [#7572](https://github.com/actualbudget/actual/pull/7572) Fix transaction row drag interfering with inline text edits. — thanks @StephenBrown2
- [#7608](https://github.com/actualbudget/actual/pull/7608) Disallow reconfiguring OpenID after initialization — thanks @jfdoming
- [#7619](https://github.com/actualbudget/actual/pull/7619) Sankey card should follow report settings — thanks @matt-fidd
- [#7623](https://github.com/actualbudget/actual/pull/7623) Fix infinite loop when applying remainder templates with an amount that can not be divided — thanks @matt-fidd
- [#7632](https://github.com/actualbudget/actual/pull/7632) Fix Sankey income being shown as spent money, when payee was not set — thanks @emiltb
- [#7656](https://github.com/actualbudget/actual/pull/7656) Fix shared worker resumption after tab suspend — thanks @jfdoming
#### Maintenance
- [#6815](https://github.com/actualbudget/actual/pull/6815) Skip release notes generation for pull requests that contain only documentation changes. — thanks @StephenBrown2
- [#7254](https://github.com/actualbudget/actual/pull/7254) Remove special "\*.browser.ts" file extension; remove file resolutions via alias (prefer conditions) — thanks @MatissJanis
- [#7281](https://github.com/actualbudget/actual/pull/7281) Fix yarn generate:icons command — thanks @JSkinnerUK
- [#7344](https://github.com/actualbudget/actual/pull/7344) Add claude code worktree folder to .gitignore — thanks @MatissJanis
- [#7350](https://github.com/actualbudget/actual/pull/7350) Remove some unused/unnecessary dependencies — thanks @matt-fidd
- [#7352](https://github.com/actualbudget/actual/pull/7352) Replace nordigen-node with our own GoCardless implementation — thanks @matt-fidd
- [#7354](https://github.com/actualbudget/actual/pull/7354) Fix useless assignments to local variables — thanks @matt-fidd
- [#7355](https://github.com/actualbudget/actual/pull/7355) Pin minimatch versions to resolve vulnerability reports — thanks @matt-fidd
- [#7360](https://github.com/actualbudget/actual/pull/7360) Add documentation for bypassing self-signed SSL certificate verification in CLI usage. — thanks @MatissJanis
- [#7367](https://github.com/actualbudget/actual/pull/7367) Bump electron dependencies — thanks @dependabot & @matt-fidd
- [#7380](https://github.com/actualbudget/actual/pull/7380) Bump lodash from 4.17.23 to 4.18.1 — thanks @dependabot
- [#7383](https://github.com/actualbudget/actual/pull/7383) Improved module resolution for better platform compatibility. — thanks @MatissJanis
- [#7393](https://github.com/actualbudget/actual/pull/7393) Improve post-checkout hook to automatically install dependencies for newly created git worktrees. — thanks @MatissJanis
- [#7397](https://github.com/actualbudget/actual/pull/7397) Add documentation for configuring Docker health checks with self-signed certificates using `NODE_EXTRA_CA_CERTS`. — thanks @Kennedy242
- [#7398](https://github.com/actualbudget/actual/pull/7398) Bump vite from 8.0.0 to 8.0.5 — thanks @dependabot
- [#7406](https://github.com/actualbudget/actual/pull/7406) Consolidate all GitHub actions into the main repository — thanks @matt-fidd
- [#7407](https://github.com/actualbudget/actual/pull/7407) Improve release note actions — thanks @matt-fidd
- [#7408](https://github.com/actualbudget/actual/pull/7408) Improve release automation to generate docs pages directly from release notes — thanks @matt-fidd
- [#7411](https://github.com/actualbudget/actual/pull/7411) Fix redirect to login page after signing out of a server — thanks @matt-fidd
- [#7412](https://github.com/actualbudget/actual/pull/7412) Moving View styles into the component library — thanks @MikesGlitch
- [#7417](https://github.com/actualbudget/actual/pull/7417) Migrate svg add-attribute plugin to typescript — thanks @JSkinnerUK
- [#7418](https://github.com/actualbudget/actual/pull/7418) Change release automation to stop us from requiring a merge freeze every month — thanks @matt-fidd
- [#7429](https://github.com/actualbudget/actual/pull/7429) Migrate loot-core internal imports to use Node.js subpath imports (`#server/*`, `#shared/*`, etc.) — thanks @MatissJanis
- [#7430](https://github.com/actualbudget/actual/pull/7430) Pin check-spelling GitHub Actions to commit SHAs in docs-spelling workflow — thanks @MatissJanis
- [#7431](https://github.com/actualbudget/actual/pull/7431) Update browserslist caniuse-lite database — thanks @MatissJanis
- [#7433](https://github.com/actualbudget/actual/pull/7433) Fix script injection patterns in GitHub Actions workflows — thanks @actualbudget
- [#7445](https://github.com/actualbudget/actual/pull/7445) Update Docs to the latest Docusaurus — thanks @MikesGlitch
- [#7446](https://github.com/actualbudget/actual/pull/7446) Standardise module imports in desktop-client — thanks @MatissJanis
- [#7448](https://github.com/actualbudget/actual/pull/7448) Add input validation for release notes and refactor credential handling in GitHub workflows. — thanks @MatissJanis
- [#7461](https://github.com/actualbudget/actual/pull/7461) Update pre-commit hook configuration so it auto-executes. — thanks @MatissJanis
- [#7462](https://github.com/actualbudget/actual/pull/7462) Standardise module imports across all packages — thanks @MatissJanis
- [#7463](https://github.com/actualbudget/actual/pull/7463) Upgrade oxlint, oxfmt, and oxlint-tsgolint to latest versions. — thanks @MatissJanis
- [#7465](https://github.com/actualbudget/actual/pull/7465) Add GitHub Actions check step via `zizmor` — thanks @jfdoming
- [#7466](https://github.com/actualbudget/actual/pull/7466) Refactor browser mode configuration to use `--mode=browser` instead of an environment variable. — thanks @MatissJanis
- [#7467](https://github.com/actualbudget/actual/pull/7467) Add ESLint rule to enforce architectural boundaries and prevent import violations. — thanks @MatissJanis
- [#7469](https://github.com/actualbudget/actual/pull/7469) Add publishConfig.imports sync validator with pre-commit integration — thanks @MatissJanis
- [#7480](https://github.com/actualbudget/actual/pull/7480) Clean up installed dependencies — thanks @MatissJanis
- [#7484](https://github.com/actualbudget/actual/pull/7484) Add desktop app test to check sync server status — thanks @MikesGlitch
- [#7490](https://github.com/actualbudget/actual/pull/7490) Fix the Electron Playwright VRT setup — thanks @MikesGlitch
- [#7493](https://github.com/actualbudget/actual/pull/7493) Update sync error message — thanks @youngcw
- [#7494](https://github.com/actualbudget/actual/pull/7494) Added desktop app tests to ensure budget export saves to disk — thanks @MikesGlitch
- [#7497](https://github.com/actualbudget/actual/pull/7497) Add scoped error boundaries to prevent feature-level crashes from taking down the entire app — thanks @tempiz
- [#7499](https://github.com/actualbudget/actual/pull/7499) Remove duplicate exclusion of package.json and package-lock.json from documentation spelling checks. — thanks @MatissJanis
- [#7503](https://github.com/actualbudget/actual/pull/7503) Optimize CI e2e tests by using pre-built bundles, reducing build time. — thanks @MatissJanis
- [#7506](https://github.com/actualbudget/actual/pull/7506) Bump various dependencies — thanks @matt-fidd
- [#7507](https://github.com/actualbudget/actual/pull/7507) Bump GitHub actions — thanks @matt-fidd
- [#7508](https://github.com/actualbudget/actual/pull/7508) Upgrade eslint to v10 and improve lint plugin performance — thanks @matt-fidd
- [#7520](https://github.com/actualbudget/actual/pull/7520) Clean up some GitHub code quality findings — thanks @matt-fidd
- [#7523](https://github.com/actualbudget/actual/pull/7523) Fix potentially inconsistent state updates — thanks @matt-fidd
- [#7527](https://github.com/actualbudget/actual/pull/7527) Consolidate the internal naming patterns used for budget types — thanks @matt-fidd
- [#7528](https://github.com/actualbudget/actual/pull/7528) Remove unused dependencies — thanks @matt-fidd
- [#7529](https://github.com/actualbudget/actual/pull/7529) Remove some dependencies that can now be replaced by Node.js builtins — thanks @matt-fidd
- [#7533](https://github.com/actualbudget/actual/pull/7533) Run `zizmor` auto-fix tool — thanks @jfdoming
- [#7534](https://github.com/actualbudget/actual/pull/7534) crdt: typecheck test files; fix lint issues — thanks @MatissJanis
- [#7536](https://github.com/actualbudget/actual/pull/7536) Update storybook logo and fonts to match the docs site — thanks @MikesGlitch
- [#7537](https://github.com/actualbudget/actual/pull/7537) Migrate CRDT package build to Vite and generate bundle statistics for CI/CD. — thanks @MatissJanis
- [#7538](https://github.com/actualbudget/actual/pull/7538) Disable bundle minification so production error messages and stack traces are human-readable — thanks @actualbudget
- [#7541](https://github.com/actualbudget/actual/pull/7541) Server: consume latest version of crdt package — thanks @MatissJanis
- [#7547](https://github.com/actualbudget/actual/pull/7547) Disable fail-fast in Electron build workflows to allow all matrix jobs to complete independently. — thanks @MatissJanis
- [#7548](https://github.com/actualbudget/actual/pull/7548) docs: AI usage policy for contributors — thanks @MatissJanis
- [#7551](https://github.com/actualbudget/actual/pull/7551) Share the CI dependency install across `check.yml` and `build.yml` jobs via a single upstream `setup` job to cut redundant `yarn install` runs on cache-cold workflow runs. — thanks @actualbudget
- [#7553](https://github.com/actualbudget/actual/pull/7553) Remove guidance on redundant inline type imports from TypeScript code style documentation. — thanks @MatissJanis
- [#7555](https://github.com/actualbudget/actual/pull/7555) Reduce permissions in `stale` workflow — thanks @jfdoming
- [#7556](https://github.com/actualbudget/actual/pull/7556) Enable [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) for nightly `npm` packages — thanks @jfdoming
- [#7566](https://github.com/actualbudget/actual/pull/7566) Custom Themes: nightly scan to catch broken themes — thanks @MatissJanis
- [#7574](https://github.com/actualbudget/actual/pull/7574) Fix trusted publishing by installing npm version 11.5.1 — thanks @jfdoming
- [#7577](https://github.com/actualbudget/actual/pull/7577) Update nightly package publishing workflow to use Node.js 24 — thanks @jfdoming
- [#7578](https://github.com/actualbudget/actual/pull/7578) Add repository details to package.json files — thanks @matt-fidd
- [#7579](https://github.com/actualbudget/actual/pull/7579) Enable trusted publishing for release `npm` packages — thanks @jfdoming
- [#7583](https://github.com/actualbudget/actual/pull/7583) Consolidate release and nightly npm publishing workflow — thanks @jfdoming
- [#7587](https://github.com/actualbudget/actual/pull/7587) Switch from tsgo development channel to beta — thanks @matt-fidd
- [#7595](https://github.com/actualbudget/actual/pull/7595) Notify Discord when the nightly custom theme catalog scan fails. — thanks @MatissJanis
- [#7606](https://github.com/actualbudget/actual/pull/7606) Migrate file service to TypeScript — thanks @jfdoming
- [#7609](https://github.com/actualbudget/actual/pull/7609) Enable stricter electron build options — thanks @jfdoming
- [#7613](https://github.com/actualbudget/actual/pull/7613) Bump postcss from 8.5.8 to 8.5.10 — thanks @jfdoming
- [#7620](https://github.com/actualbudget/actual/pull/7620) Increase test coverage for budget templates — thanks @matt-fidd
- [#7635](https://github.com/actualbudget/actual/pull/7635) Fix release note generation script failing when conflicting changes are present — thanks @matt-fidd
- [#7640](https://github.com/actualbudget/actual/pull/7640) Make release note generation script respect cherry picked commits — thanks @matt-fidd

View File

@@ -1,25 +0,0 @@
---
title: Release 26.5.1 & 26.5.2
description: New release of Actual.
date: 2026-05-08T10:00
slug: release-26.5.1
tags: [announcement, release]
hide_table_of_contents: false
authors: MatissJanis
---
This patch release delivers bugfixes for authentication rate limiting, self-signed certificate handling, and UUID generation compatibility in insecure context (HTTP).
**Note:** versions 26.5.1 and 26.5.2 are functionally identical. The additional release was created solely to resolve an issue with publishing on the Windows Store.
<!--truncate-->
**Docker Tag: 26.5.1 / 26.5.2**
Version: v26.5.1 and v26.5.2
#### Bugfixes
- [#7707](https://github.com/actualbudget/actual/pull/7707) Count only failed login attempts against the authentication rate limit — thanks @danielhopkins
- [#7713](https://github.com/actualbudget/actual/pull/7713) Fix Desktop app self-signed certificates functionality — thanks @MikesGlitch
- [#7734](https://github.com/actualbudget/actual/pull/7734) Revert UUID generation to use `uuid` library instead of `crypto.randomUUID()`. — thanks @MatissJanis

View File

@@ -13,7 +13,6 @@ for it to be added, your project must have a proper README file.
The following are implementations of bank syncing using the Actual API. For instructions on using them, see the respective repositories.
- **Akahu and Up bank sync to Actual Budget** - https://github.com/tim-smart/actualbudget-sync
- **Enable Actual: Import transactions from European banks using Enable Banking** - https://github.com/2manyvcos/enable-actual
- **ICS Cards Holland CVS exporter** - https://github.com/IeuanK/ICS-Exporter/
- **Lunch Flow: Import transactions from GoCardless, MX, Finicity, Finverse, and more** - https://github.com/lunchflow/actual-flow
- **MoneyMan an israel banks importer** - https://github.com/daniel-hauser/moneyman

View File

@@ -84,7 +84,6 @@ Use custom hooks from `src/hooks` instead of importing directly from react-route
### Never Use
- **`uuid` without destructuring**: Use `import { v4 as uuidv4 } from 'uuid'`
- **Direct color imports**: Use theme instead of importing colors directly
- **`@actual-app/web/*` imports in `loot-core`**: Don't import from web package in core

View File

@@ -1,16 +1,12 @@
# Preview Builds
Each pull request automatically deploys preview builds to Netlify, so you can try out changes without cloning the branch.
It is possible using our deployment pipeline to run preview builds of Actual directly on Netlify.
To find a PR, browse the [open pull requests](https://github.com/actualbudget/actual/pulls). Once you have the PR number, replace `{pr-number}` in the URLs below.
To do this, find the pull request (PR) that you would like to preview in GitHub, you can find the pull requests in scope of the preview builds [here](https://github.com/actualbudget/actual/pulls).
Three previews are deployed per PR:
Once you have the number of the PR navigate to the following URL: https://deploy-preview-pr_number--actualbudget.netlify.app/ replacing pr_number with the number of the PR you would like to preview, for example https://deploy-preview-414--actualbudget.netlify.app/
- **Demo:** `https://deploy-preview-{pr-number}.demo.actualbudget.org/`
- **Storybook:** `https://deploy-preview-{pr-number}--actualbudget-storybook.netlify.app/`
- **Website:** `https://deploy-preview-{pr-number}.www.actualbudget.org/`
The exact URLs are also posted as a comment on each PR by the Netlify bot.
This will load directly on Netlify where you will be able to preview the changes in that pull request without the need to clone the specific branch.
:::info
There is no sync server on preview builds so when asked "Where's the server" select "Don't use a server." Alternatively, you can use your own self-hosted server. You should exercise caution when using a server with preview builds because they are much more likely to have bugs that could damage your budget. Consider running a separate local server for preview builds.

View File

@@ -1,13 +1,10 @@
# How to Cut a Release
## General information
In the open-source version of Actual, there are 4 NPM packages:
In the open-source version of Actual, there are 3 NPM packages:
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node.
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node.
- [@actual-app/cli](https://www.npmjs.com/package/@actual-app/cli): A companion CLI used as a terminal-based client for Actual.
All packages and the main Actual release are versioned together. That makes it clear which version of the package should be used with the version of Actual.
@@ -24,7 +21,7 @@ For example:
- `v23.3.2` - another bugfix launched later in the month of March;
- `v23.4.0` - first release launched on 9th of April, 2023;
### Release branch
## Release branch
A release branch and PR are automatically cut at 17:00 UTC on the 25th of each month. To cut one manually, run [this GitHub Action](https://github.com/actualbudget/actual/actions/workflows/cut-release-branch.yml).
@@ -32,15 +29,13 @@ The release notes workflow automatically generates a blog post and updates `docs
Fixes that need to be included in the release should be cherry-picked onto the release branch manually.
## Release process
### Stabilize the release
## Stabilise the release
- [ ] Fix spelling in the generated release notes as needed.
- [ ] Share the release PR in the release channel on Discord.
- [ ] Wait until at least 2 other maintainers have approved the release.
### Merge and tag the release
## Merge and tag the release
- [ ] Merge the release PR to master.
- [ ] Create the tag on the **release branch** and push it. When the tag is pushed, it triggers the Docker stable image, all NPM packages and the Desktop app to be built and published.
@@ -50,64 +45,22 @@ Fixes that need to be included in the release should be cherry-picked onto the r
git push {remote} vX.Y.Z
```
All NPM packages should be automatically released and pushed to the NPM registry; confirm [on NPM](https://www.npmjs.com/package/@actual-app/sync-server).
All NPM packages should be automatically released and pushed to the NPM registry. Check them here:
Docker images should be automatically released and pushed to Docker Hub; confirm [on the Docker tags page](https://hub.docker.com/r/actualbudget/actual-server/tags).
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api)
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web)
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server)
For the Windows Store desktop app, a submission will be automatically uploaded and submitted for certification. The certification process can take up to 3 business days; once complete the app will be in the Store. You can check the update status [on the partner dashboard](https://partner.microsoft.com/en-us/dashboard) if you have permission. Note that the Store UI will not correctly reflect the submission status for about 30 minutes after submission.
For the Windows Store desktop app, a submission will be automatically uploaded and submitted for certification. The certification process can take up to 3 business days; once complete the app will be in the Store. You can check the update status [here](https://partner.microsoft.com/en-us/dashboard) if you have permission. Note that the Store UI will not correctly reflect the submission status for about 30 minutes after submission.
Finally, a draft GitHub release should be automatically created; confirm [on the releases page](https://github.com/actualbudget/actual/releases).
Finally, a draft GitHub release should be automatically created [here](https://github.com/actualbudget/actual/releases).
### Verify the release
Once the GitHub release is published, the Flathub publish workflow will trigger for the Linux desktop app. A PR will be created against the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls) and the core maintainers will be assigned as reviewers. The Core team will review the PR and merge it to `master`, which will kick off a production release to the Flathub Store. It can take anywhere from hours to a few days before the app will be available in the Flathub Store.
- [ ] Deploy the new server Docker image and do a quick smoke test to verify things still work as expected.
- [ ] Perform the same smoke test on the desktop app corresponding to your platform (attached to the draft release).
## Finalize the release
### Finalize the release
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps and create a PR to the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls).
- [ ] Send an announcement on Discord and Twitter.
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master. After merge, it can take anywhere from hours to a few days before the app will be available in the Flathub Store.
## Cutting a patch release
Patch releases (e.g. `v26.5.1`) ship a small, targeted set of fixes on top of an existing release. Unlike monthly releases, the release branch is built by cherry-picking specific commits from `master` onto the previous release tag, so unrelated in-progress work on `master` is not pulled in.
### Build the release branch
- [ ] Identify the commits on `master` that should be included in the patch release and note their commit hashes.
- [ ] Check out the previous release tag and create a new release branch from it:
```bash
git checkout v26.5.0
git checkout -b release/v26.5.1
```
- [ ] Cherry-pick each commit onto the new branch, in the same order they were merged to `master`:
```bash
git cherry-pick <commit-sha>
```
- [ ] Push the release branch. This is the branch that will be tagged later — **do not tag it yet**:
```bash
git push -u {remote} release/v26.5.1
```
### Open the release PR against master
The release branch is what gets tagged, but the version bump, release notes cleanup, and blog post still need to land on `master` so future releases pick them up.
- [ ] Check out `master` and create a new branch from it (e.g. `release-notes/v26.5.1`).
- [ ] In this branch:
- Bump the version in the relevant `package.json` files.
- Delete the `upcoming-release-notes/*.md` files that correspond to the cherry-picked commits.
- Add a new blog post under `packages/docs/blog/` (see [`2026-02-22-release-26-2-1.md`](https://github.com/actualbudget/actual/blob/master/packages/docs/blog/2026-02-22-release-26-2-1.md) for an example).
- [ ] Commit the changes and open a PR against `master`. Include a link to the previously pushed release branch (e.g. `release/v26.5.1`) in the PR description so reviewers can see exactly what is shipping.
### Tag the release
- [ ] Once the PR has been approved and merged, tag the **release branch** (not `master`) and push the tag:
```bash
git checkout release/v26.5.1
git tag v26.5.1
git push {remote} v26.5.1
```
From here the rest of the release pipeline (NPM, Docker, Desktop, GitHub draft release) runs automatically. Follow the [Verify the release](#verify-the-release) and [Finalize the release](#finalize-the-release) steps above to complete the rollout.
- [ ] After the Docker image for the release is ready and pushed to Docker Hub, remember to deploy it and do a quick smoke test to verify things still work as expected.
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps.
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master.
- [ ] Wrap up by sending an announcement on Discord and Twitter.
- [ ] Wait one to two days to see if any new bugs show up that need a patch release.

Some files were not shown because too many files have changed in this diff Show More