mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-10 16:26:43 -05:00
Compare commits
5 Commits
matiss/crd
...
cursor/res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31967e36a4 | ||
|
|
c2150e5888 | ||
|
|
6b0242fa49 | ||
|
|
d8eba18a72 | ||
|
|
c91eea5439 |
@@ -1,6 +1,6 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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.
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/tech-support.yml
vendored
17
.github/ISSUE_TEMPLATE/tech-support.yml
vendored
@@ -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
|
||||
17
.github/actions/docs-spelling/excludes.txt
vendored
17
.github/actions/docs-spelling/excludes.txt
vendored
@@ -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$
|
||||
|
||||
11
.github/actions/docs-spelling/expect.txt
vendored
11
.github/actions/docs-spelling/expect.txt
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/cut-release-branch.yml
vendored
1
.github/workflows/cut-release-branch.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/docker-edge.yml
vendored
1
.github/workflows/docker-edge.yml
vendored
@@ -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]
|
||||
|
||||
1
.github/workflows/docker-release.yml
vendored
1
.github/workflows/docker-release.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
2
.github/workflows/electron-master.yml
vendored
@@ -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
|
||||
|
||||
23
.github/workflows/issues-close-tech-support.yml
vendored
23
.github/workflows/issues-close-tech-support.yml
vendored
@@ -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 }}
|
||||
1
.github/workflows/netlify-release.yml
vendored
1
.github/workflows/netlify-release.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/publish-flathub.yml
vendored
1
.github/workflows/publish-flathub.yml
vendored
@@ -21,7 +21,6 @@ concurrency:
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
environment: release
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: resolve_version
|
||||
|
||||
@@ -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
|
||||
|
||||
1
.github/workflows/publish-npm-packages.yml
vendored
1
.github/workflows/publish-npm-packages.yml
vendored
@@ -87,7 +87,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish npm packages
|
||||
needs: build-and-pack
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
7
.github/workflows/vrt-update-generate.yml
vendored
7
.github/workflows/vrt-update-generate.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/cli",
|
||||
"version": "26.5.2",
|
||||
"version": "26.4.0",
|
||||
"description": "CLI for Actual Budget",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 “{{ name: error.name }}”</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’t run until it’s linked to a schedule.
|
||||
</Trans>
|
||||
);
|
||||
case 'refill-no-cap':
|
||||
return (
|
||||
<Trans>
|
||||
Refill automations must have a “Balance cap” 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 “{{ source: error.source }}” 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’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 “Whatever is left”.
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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: '' }
|
||||
: {}),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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’s
|
||||
notes couldn’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’t handle yet. Keep editing it as
|
||||
text in the category’s notes.
|
||||
</Trans>
|
||||
) : hasGoalTemplate && hasCleanupDirective ? (
|
||||
<Trans>
|
||||
This category’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’s notes.
|
||||
</Trans>
|
||||
) : hasGoalTemplate ? (
|
||||
<Trans>
|
||||
This category uses a <code>#goal</code> directive, which the budget
|
||||
automations UI doesn’t handle yet. Keep editing it as text in
|
||||
the category’s notes.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This category uses a <code>#cleanup</code> directive, which the
|
||||
budget automations UI doesn’t handle yet. Keep editing it as
|
||||
text in the category’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));
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BudgetAutomationsModal } from './BudgetAutomationsModal';
|
||||
export { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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. ' +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user