Compare commits

...

55 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
c2f6ecf3f4 Enhance ThemeSettings and UI components by adding maxWidth styling for better responsiveness. This change ensures that buttons and columns adapt to the full width of their containers. 2026-03-10 22:46:11 +00:00
Matiss Janis Aboltins
0f6d1be4c0 Merge branch 'ai/custom-theme-dual-prefs' of github.com:actualbudget/actual into ai/custom-theme-dual-prefs 2026-03-10 22:29:53 +00:00
Matiss Janis Aboltins
aae8335fcf Merge branch 'master' into ai/custom-theme-dual-prefs 2026-03-10 22:29:33 +00:00
Matiss Janis Aboltins
bf1d220ced [AI] Lint: fix typescript/unbound-method problems (#7163)
* [AI] Refactor modal components and server modules

Made-with: Cursor

* Release notes
2026-03-10 18:06:50 +00:00
Vicente Cruz
94dd8f73c0 Add "Notion Dark Mode" custom theme (#7151)
* Update customThemeCatalog.json

Added custom theme inspired by Notion's Dark Mode

* [autofix.ci] apply automated fixes

* Create 7151.md release notes file

* Add Notion Dark Mode custom theme

Pushed a no-op change to get the tests to re-run

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-10 17:00:12 +00:00
Matiss Janis Aboltins
85e08b2e9e [AI] Fix privilege escalation in sync-server /change-password and getLoginMethod (#7155)
* [AI] Fix privilege escalation in sync-server /change-password and getLoginMethod

Made-with: Cursor

* Update upcoming-release-notes/7155.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix privilege escalation issue in change-password endpoint

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-09 20:33:20 +00:00
Dustin Brewer
60e2665fcc MVP for Payee Locations (#6157)
* Phase 1: Add payee locations database schema/types

 * Add migration to create payee_locations table with proper indexes
 * Add PayeeLocationEntity type definition
 * Update database schema to include payee_locations table
 * Export PayeeLocationEntity from models index

* Phase 2: Add payee location API/services

  * Add constants for default location behavior
  * Implement location service with geolocation adapters
  * Add new API handlers for payee location operations

* Phase 3: Add location-aware UI components/hooks

 * Update mobile transaction editing with location integration
 * Enhance PayeeAutocomplete with nearby payee suggestions and forget
   functionality
 * Implement location permission and placeholder unit of measurement hooks

* Phase 4: Add YNAB5 payee location import support

 * Extend YNAB5 types to include location data from payees
 * Implement location import logic in YNAB5 importer

* Phase 5: Add unit of measurement support

 * Add unit of measurement preference setting in Format.tsx
 * Implement distance formatting utilities for imperial/metric units
 * Add useUnitOfMeasurementFormat hook for accessing preferences

* Required release note about the PR

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6157

* Actually get syncing working

This was not obvious to me, esp. with 13 years of data, but the
locations I've been inserting were local only.

Everything appeared to work.

What I failed to notice is that the locations did not sync
across devices. Of course all the location data that was imported worked
fine, but nothing new unless it was created on device.

This changes the schema and uses the proper insert/delete methods such
that syncing works.

* Remove unit of measurement preference

Display feet and meters automatically, and don't bother to format based on miles/kilometers.

* Add payeeLocations feature flag

Place the location permissions check and thus user-facing functionality behind the feature flag

* Missed adding tombstone to payee location query

* Adjust migration name to pass CI

Adjust the indexes as well

* Unify location.ts

If CodeRabbit complains again, reply that we are actively choosing a unified file

* Add bounds testing

The validation is straightforward range-checking — if it's wrong, it'll be obvious quickly. Unless there's a plan to start adding broader test coverage for that file, I'd leave it untested for now

* Prefer camelCase for the method params

* Fix the nested interactive containers

* Fix the majority of CodeRabbit nits

The remainder seem to not be related to my code change (just a lint), outdated (sql migration comment), or infeasible (sql haversine query)

* More CodeRabbit nits

* Revert unnecessary YNAB5 zip import

Turns out the payee_locations were inside the exported budget all along!

* Additional guards and other CR fixes

* Match the pattern used elsewhere in file

* YNAB5.Budget -> Budget

Missed in the merge conflict

* ci: trigger rerun

* Change import from fetch to connection module

* Correct invalid border property

Ah. I never noticed this property wasn't working. I guess the button
looked OK to me!

* Only hide the button on success

* Update packages/loot-core/src/shared/location-utils.ts

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

* Update packages/loot-core/src/server/payees/app.ts

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

* Fully fix typo

Guess I shouldn't commit a suggestion after waking up

* Attempting to address feedback

Manual select nearby payee and save payee location buttons to make the UX more obvi

* Remove stale file that was moved

* Additional cleanup of remnant change

Removed the references to location from a few existing entities

* Additional cleanup of remnant change

* Show the Nearby payees button even when the field is disabled

If there are nearby payees, there's not a payee already selected, and the save button isn't needed

* runQuery is not async

* Add mockNearbyPayeesResult to test

Trying to utilize the real type in the mock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-09 20:26:48 +00:00
Matiss Janis Aboltins
102be1c54d Fix shared error array in SimpleFin batch sync error handling (#7125)
* Add failing tests for SimpleFin batch sync shared error array bug

Tests prove two bugs in simpleFinBatchSync() catch block (app.ts:1100-1115):
1. All accounts share the same errors array reference
2. Errors accumulate across accounts instead of being isolated

Related: #6623, #6651, #7114

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Fix shared error array in SimpleFin batch sync catch block

When simpleFinBatchSync() threw an error, all accounts received the
same errors array by reference and errors accumulated across accounts.
Each account now gets its own isolated errors array with a single error
specific to that account, matching the pattern used by accountsBankSync().

Fixes #6623

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Remove @ts-strict-ignore from bank sync tests

Use proper non-null assertions instead of disabling strict mode.

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Add release notes for PR #7125

* [AI] Replace test() with it() to follow repo convention

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-03-09 19:50:18 +00:00
Julian Dominguez-Schatz
448da13cf5 Move migrations script to typescript (#7075)
* Move migrations script to typescript

* Add release notes

* Setup

* Fix

* PR feedback

* Make imports work as expected

* Rabbit
2026-03-09 07:58:03 +00:00
LeviBorodenko
41679235be [Mobile] Fix preview running balances not displaying on toggle (#7041)
* refactor(usePreviewTransactions): Move running balances to useMemo

* docs(relnotes): Add note for mobile running balance fix

* refactor(hooks): Remove unnecessary options ref
2026-03-08 22:04:52 +00:00
Matiss Janis Aboltins
73fa068fe9 [AI] Establish AI agent commit and PR guidelines (#7153)
* [AI] Extract PR/commit rules into shared agent skill

Deduplicate PR and commit instructions from AGENTS.md into a standalone
skill file at .github/agents/pr-and-commit-rules.md. This single source
of truth is consumed by both Claude Code (via CLAUDE.md @-import) and
Cursor (via .cursor/rules/pr-and-commit.mdc with alwaysApply: true).

AGENTS.md now references the shared file instead of repeating the rules
in three separate sections.

https://claude.ai/code/session_01KkHg7MYXrTyDkTw6u98Vam

* Add release notes for PR #7153

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 18:16:46 +00:00
Juulz
1fe588c143 🐞 Fix mobile transactions colors - fixes #7042 (#7047)
* Update amount styling with theme colors

* Clean up imports in TransactionListItem.tsx

Removed unused import of makeBalanceAmountStyle.

* Add release notes for bugfix in color variables

Fix color variables for mobile transaction list items.

* Change positiveColor in amount to use theme.tableText

* Change negative color style for running balance

* Fix negative color style in TransactionListItem

* Update color styles for transaction amount display

* Update upcoming-release-notes/7047.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7047

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 15:04:30 +00:00
mibragimov
edce092ae8 fix(csv-import): trim whitespace from amount strings before parsing (#7149)
* fix(csv-import): trim whitespace from amount strings before parsing

looselyParseAmount relies on a regex anchored at $ to detect decimal
markers. Trailing whitespace (e.g. from Excel-saved CSVs) shifts the
pattern match so a thousands separator is misidentified as a decimal
point, producing wildly wrong values.

Adding trim() at the top of the function eliminates trailing/leading
whitespace before any regex logic runs.

Fixes actualbudget/actual#7121

* chore: add release notes for #7149
2026-03-07 20:41:47 +00:00
github-actions[bot]
b2267b8b0d Update VRT screenshots
Auto-generated by VRT workflow

PR: #7145
2026-03-07 20:34:14 +00:00
dependabot[bot]
77411394f6 Bump dompurify from 3.3.0 to 3.3.2 (#7143)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.0 to 3.3.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.0...3.3.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-07 20:24:41 +00:00
dependabot[bot]
235d94478f Bump express-rate-limit from 8.2.1 to 8.2.2 (#7140)
* Bump express-rate-limit from 8.2.1 to 8.2.2

Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.2.1...v8.2.2)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.2.2
  dependency-type: direct:production
...

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

* [AI] Update express-rate-limit to 8.3.0 to fix GHSA-46wh-pxpv-q5gq vulnerability

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Add release notes for PR #7140

* [AI] Update release notes to reflect version 8.3.0

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-07 19:48:56 +00:00
Matiss Janis Aboltins
7e0edd43ec Sort theme catalog items alphabetically by name (#7144)
* [AI] Sort custom theme catalog options alphabetically in the UI

Sort catalog themes by name using localeCompare before rendering,
without modifying the underlying JSON data file.

https://claude.ai/code/session_01Y5SGaVYqsVWVsvXV8ZFXj3

* Add release notes for PR #7144

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 23:01:07 +00:00
Matiss Janis Aboltins
fdf5c8d0a9 [AI] Move window typings import to globals.ts (#7142)
* [AI] Remove parent path import of window.ts from desktop-client tsconfig

Replace the `../../packages/loot-core/typings/window.ts` include in
desktop-client's tsconfig.json with a proper package import. This adds
a `./typings/*` export to loot-core's package.json and creates a
globals.ts file in desktop-client that imports the window types via
the package name.

https://claude.ai/code/session_01GrgAzjWd3XvqwBTfXLerxc

* [autofix.ci] apply automated fixes

* Add release notes for PR #7142

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 23:00:50 +00:00
Matt Fiddaman
a8ec84ceac stop font size fluctuations showing in summary cards (#7092)
* stop summary cards from showing until font size settled

* note
2026-03-06 23:00:41 +00:00
Michael Clark
b727124603 Fix docker images (#7146)
* fix docker images

* release notes
2026-03-06 22:55:21 +00:00
Matiss Janis Aboltins
13fcc408fa Change category from Features to Enhancement
Custom Themes: separate light and dark theme options when selecting 'system default' theme.
2026-03-06 22:03:50 +00:00
github-actions[bot]
dd6f27607e Add release notes for PR #7145 2026-03-06 21:59:52 +00:00
dependabot[bot]
8bb7f207f2 Bump svgo from 3.3.2 to 3.3.3 (#7130)
Bumps [svgo](https://github.com/svg/svgo) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v3.3.2...v3.3.3)

---
updated-dependencies:
- dependency-name: svgo
  dependency-version: 3.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 21:56:01 +00:00
dependabot[bot]
6e0c15eb12 Bump immutable from 5.1.3 to 5.1.5 (#7129)
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.3 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.3...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-06 21:51:14 +00:00
Matiss Janis Aboltins
95badf1608 [AI] Consolidate custom theme prefs and improve auto-mode UX
- Merge `installedCustomTheme` into `installedCustomLightTheme` so only
  two prefs exist (light + dark). The legacy asyncStorage key
  `installed-custom-theme` is preserved for backwards compatibility.
- In auto (System default) mode, the main Theme dropdown no longer
  surfaces the installed custom-light theme as an option; custom themes
  for light/dark are managed exclusively via the sub-selectors.
- Selecting "System default" resets both light and dark custom themes.
- Installing a custom theme from the main dropdown while in auto mode
  switches the base theme to "Light" so it applies directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 21:47:14 +00:00
Michael Clark
4e2cec2c7a 🧪 Improving docker image test resiliency (#7141)
* improving test resiliency

* release notes
2026-03-06 21:02:31 +00:00
Matiss Janis Aboltins
078603cadf [AI] Implement sync recovery (#7111)
* [AI] Fix iOS/Safari sync recovery (fixes #7026): useOnVisible hook, re-fetch server version on visible, improved network-failure message

Made-with: Cursor

* Feedback: coderabbitai

* Refactor useOnVisible test: remove unnecessary resolve check and simplify callback definition
2026-03-04 23:27:15 +00:00
Joel Jeremy Marquez
b3a86b5392 Update remaining accounts hooks to return react query states (#7071)
* Update remaining accounts hooks to return react query states

* Add release notes for PR #7071

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 23:13:55 +00:00
Mats Nilsson
295a565e55 Fix budget analysis report padding (#7118)
* Fix budget analysis report padding

The padding on the report is too small when the value is large.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 22:08:30 +00:00
Matiss Janis Aboltins
387c8fce16 [AI] Enable TypeScript composite project references across monorepo (#7062)
* [AI] Enable TypeScript composite project references across monorepo

- Add composite and declaration emit to all package tsconfigs
- Wire root and per-package project references in dependency order
- Replace cross-package include-based typing with referenced outputs
- Fix api TS5055 by emitting declarations to decl-output
- Add desktop-client alias for tests; fix oxlint import order in vite.config
- Add UsersState.data null type and openDatabase return type for strict emit

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove obsolete TypeScript configuration for API and update build script to emit declarations directly to the output directory. This streamlines the build process and ensures compatibility with the new project structure.

* Refactor TypeScript configuration in API package to remove obsolete decl-output directory and update build scripts. The changes streamline the build process by directing declaration outputs to the @types directory, ensuring better organization and compatibility with the new project structure.

* Add TypeScript declaration emission for loot-core in desktop-electron build process

* Refactor TypeScript configuration in API package to utilize composite references and streamline build scripts. Update include and exclude patterns for improved file management, ensuring better organization of declaration outputs and migration SQL files.

* Refactor TypeScript configuration in loot-core and desktop-client packages to streamline path management and remove obsolete dependencies. Update paths in tsconfig.json files for better organization and compatibility, and adjust yarn.lock to reflect changes in workspace dependencies.

* Update desktop-electron package to utilize loot-core as a workspace dependency. Adjust TypeScript import paths and tsconfig references for improved organization and compatibility across packages.

* Enhance Vite configuration for desktop-client to support Electron-specific conditions and update loot-core package.json to include Electron as a platform for client connection. This improves compatibility for Electron builds.

* Refactor TypeScript configuration across multiple packages to streamline path management. Update tsconfig.json files in root, api, and loot-core packages to improve import paths and maintain compatibility with internal typings.

* Update package dependencies and Vite configuration across component-library and desktop-client. Add vite-tsconfig-paths to component-library and remove it from desktop-client. Refactor Storybook preview file to include a TODO for future refactoring.

* Remove Node-specific path from loot-core package.json for client connection, streamlining platform configuration for Electron.

* Remove loot-core as a workspace dependency from desktop-electron package.json

* Update tsconfig.json to remove reference to desktop-client

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 20:57:06 +00:00
YHC
c7ebfd8ad4 Add TWD Currency (#7095)
* Add New Taiwan Dollar to currency list

* Add New Taiwan Dollar to currency list

* Fix decimalPlaces for New Taiwan Dollar

Updated decimalPlaces for New Taiwan Dollar from 0 to 2, as suggested by coderabbitai, "Line 62 introduces TWD as zero-decimal, but this codebase currently has an unresolved zero-decimal conversion/storage issue. This risks incorrect persisted amounts for TWD transactions."

* Add upcoming-releass-notes

* Add New Taiwan Dollar (NT$) to available currency

* Add New Taiwan Dollar (NT$) to available currency
2026-03-04 18:46:20 +00:00
Matiss Janis Aboltins
e1f834371b [AI] Refactor YNAB importers to use server-side send() and handler API (#7050)
* [AI] Refactor YNAB importers to use server-side send() and handler API

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename 7049.md to 7050.md

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:33 +00:00
Matiss Janis Aboltins
4caee99955 [AI] Strengthen schedule rule types in loot-core (remove ts-expect-error) (#7051)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:20 +00:00
Matiss Janis Aboltins
286d05d187 [AI] Move ImportTransactionsOpts from api package to loot-core (#7053)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:09 +00:00
Matiss Janis Aboltins
cf05a7ea01 [AI] Typescript: low hanging fruit (#7091)
* [AI] Use loot-core workspace in desktop-electron and fix related types

- Add loot-core as workspace dependency in desktop-electron
- Import GlobalPrefsJson from loot-core package in desktop-electron
- Allow null in usersSlice data type (UsersState)
- Add explicit SQL.Database return type to openDatabase in sqlite electron

Made-with: Cursor

* Re-add loot-core as a workspace dependency in desktop-electron
2026-03-04 18:32:01 +00:00
Michael Clark
b373b612a4 :electron: Electron backups converted to zip for easy importing (#7069)
* e:electron: electron backups converted to zip for easy importing

* release notes

* fix lint

* suggestion from rabbit

* Change category from Maintenance to Enhancements
2026-03-04 18:00:07 +00:00
Copilot
3797cff716 [AI] Skip AI-generated release notes for release/ branch PRs (#7107)
* Initial plan

* [AI] Make AI-generated release notes not run on release/ branches

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>

* [AI] Use explicit startsWith(github.head_ref) if conditions instead of JS changes

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>

* Delete extra condition

* Add release notes for PR #7107

* [autofix.ci] apply automated fixes

* FIx note

* Add head branch information to PR details

* Refactor conditions for release notes generation

* Sec

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 17:40:32 +00:00
Michael Clark
9e2793d413 :electron: Flathub pr on publish of release assets (#7117)
* flathub pr on publish of release

* docs

* release notes

* rabbit feedback
2026-03-04 17:05:47 +00:00
Julian Dominguez-Schatz
3201819df9 Display/save limit/refill templates (#6693)
* Handle refill templates in automations modal

* Add release notes

* Fix typecheck

* Fix icon style

* Style fixes

* Fix migration

* Fix typecheck

* Rabbit

* Add tests
2026-03-04 16:41:07 +00:00
Josh Woodward
eca50f28b0 Fix skipping schedules that move before weekend (#7057)
* Pushing before weekend to Monday

* release notes

* lint

* Guard against null

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fixing AI commit and addressing second comment

* addressing nitpicks

* first attempt at a test

* [autofix.ci] apply automated fixes

* refactor to use condition and fix tests

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 16:40:41 +00:00
dependabot[bot]
c82ee91b12 bump bn.js from 4.12.2 to 4.12.3 (#7073)
* Bump bn.js from 4.12.2 to 4.12.3

Bumps [bn.js](https://github.com/indutny/bn.js) from 4.12.2 to 4.12.3.
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v4.12.2...v4.12.3)

---
updated-dependencies:
- dependency-name: bn.js
  dependency-version: 4.12.3
  dependency-type: indirect
...

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

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-03-04 16:40:25 +00:00
Julian Dominguez-Schatz
cb8ff337dc Migrate sync server general utils to typescript (#7074)
* Migrate sync server general utils to typescript

* Add release notes

* Rabbit

* Fix
2026-03-04 16:40:24 +00:00
Matiss Janis Aboltins
c37a5a02aa [AI] Add CLAUDE.md with reference to AGENTS.md (#7105)
* [AI] Add CLAUDE.md with reference to AGENTS.md

Made-with: Cursor

* Add release notes for PR #7105

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 16:40:14 +00:00
Matiss Janis Aboltins
f9e09ca59b 🔖 (26.3.0) (#7097)
* 🔖 (26.3.0)

* Remove used release notes

* Add release notes for PR #7097

* Remove used release notes

* Remove used release notes

* Add release notes for version 26.3.0

* Add new terms to spelling expectation list

* Fix spelling and capitalization in release notes

Corrected spelling of 'reorganisation' to 'reorganization' and updated 'coderabbit' to 'CodeRabbit' for consistency.

* Update patterns.txt to allowlist 'CodeRabbit'

Add 'CodeRabbit' to allowlist of proper nouns.

* Clarify chart theming support in release notes

Updated the release notes to specify bar/pie chart theming support and added details about theme variables for customization.

* Remove 'CodeRabbit' from spelling expectations

* Refactor release notes and improve formatting

Reorganize release notes for clarity and update content.

* Create 2026-03-02-release-26-3-0.md

* Change release date to 2026-03-02

Updated the release date for version 26.3.0.

* Update release notes for version 26.3.0

---------

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2026-03-03 01:23:12 +00:00
Matiss Janis Aboltins
8081b8829e [AI] Make merge-freeze-unfreeze workflow work on fork PRs (#7104)
* [AI] Make merge-freeze-unfreeze workflow work on fork PRs via pull_request_target

Made-with: Cursor

* Add release notes for the "unfreeze" workflow functionality in fork PRs

* Add empty permissions block to unfreeze job in merge-freeze-unfreeze workflow
2026-03-01 20:46:58 +00:00
Matt Fiddaman
f2f79d378c fix bugfix categorisation in contributor points counting script (#7103)
* s/bugfix/bugfixes

* note

* add alias functionality

* note update
2026-03-01 16:33:58 +00:00
Julian Dominguez-Schatz
c5cca67399 Revert "feat(currency): Add Vietnamese Dong (VND) currency" (#7100)
* Revert "feat(currency): Add Vietnamese Dong (VND) currency (#6902)"

This reverts commit 7fa9fa900b.

* Add release notes for PR #7100

* Delete 7100.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-01 07:31:29 +00:00
Matiss Janis Aboltins
eabf09587f [AI] Github action for unfreezing PRs (#7094)
* [AI] Add GitHub Action to add PR to Merge Freeze unblocked list when unfreeze label is added

Made-with: Cursor

* Rename 7093.md to 7094.md

* Add concurrency control to unfreeze job in merge-freeze-unfreeze workflow

- Introduced concurrency settings to prevent overlapping executions of the unfreeze job based on labels.
- Updated error handling to abort the process if fetching the current merge freeze status fails, ensuring unblocked PRs are not overwritten.

* Refactor Merge Freeze workflow to simplify PR unblocking process

- Updated the workflow to directly post the PR to the unblocked list without fetching the current freeze status.
- Improved error handling by ensuring the access token is set before proceeding with the API call.
2026-02-28 21:15:11 +00:00
Matiss Janis Aboltins
6022929551 [Cursor] Development environment setup (#7088)
* [AI] Add Cursor Cloud specific instructions to AGENTS.md

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* [autofix.ci] apply automated fixes

* Add release notes for PR #7088

* [AI] Fix Node.js minimum version requirement in AGENTS.md (#7089)

* [AI] Fix outdated Node.js version requirement in Build Failures section

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* [AI] Add test budget tip to Cursor Cloud instructions

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-02-27 20:38:37 +00:00
Copilot
e65429497d [AI] Remove 'suspect ai generated' label and associated workflow (#7087)
* Initial plan

* [AI] Remove 'suspect ai generated' label and associated workflow

Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>

* [AI] Remove 'suspect ai generated' label from coderabbit config

Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>

* Add release notes for PR #7087

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-26 20:51:47 +00:00
Matiss Janis Aboltins
3758d72b65 Mobile rules item alignment (#7081)
* [AI] Fix mobile rules list items to be full width and left-aligned

- Override Button's default justifyContent/alignItems centering in
  ActionableGridListItem to use flex-start alignment
- Add width: 100% to RulesListItem's SpaceBetween to fill the item width

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Add release notes for PR #7081

* Change category from Enhancements to Bugfix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-26 20:22:29 +00:00
Matiss Janis Aboltins
032d10ac42 [AI] Fix API build output path (dist/index.js instead of dist/api/index.js) (#7084)
* [AI] Fix API build output path (dist/index.js instead of dist/api/index.js)

- Set rootDir in packages/api/tsconfig.json so output is under dist/ not dist/api/
- Remove loot-core pegjs.ts from include; add local typings/pegjs.d.ts
- Use mkdir -p in build:migrations for idempotent build
- Exclude **/@types/** so declaration output does not conflict with input

Made-with: Cursor

* Update TypeScript configuration in api package to refine exclude patterns
2026-02-26 20:20:38 +00:00
Michael Clark
f97a89dc28 🐛 Fix file path on windows (#7076)
* fix file path on windows

* file path in migrations

* release notes
2026-02-25 15:00:10 +00:00
Juulz
a4bd301ec6 🐞 Midnight theme: Change menuAutoCompleteTextHover color - Fixes #7029 (#7048)
* Change menuAutoCompleteTextHover color to green400

* Change menuAutoCompleteTextHover color to green400 in Midnight theme.

Change menuAutoCompleteTextHover color to green400 in Midnight theme.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-24 17:29:09 +00:00
Julian Dominguez-Schatz
18072e1d8b Validate file IDs for correctness (#7067)
* Validate file IDs for correctness

* Add release notes
2026-02-24 15:32:50 +00:00
353 changed files with 4559 additions and 1460 deletions

View File

@@ -13,8 +13,6 @@ reviews:
mode: off
enabled: false
labeling_instructions:
- label: 'suspect ai generated'
instructions: 'This issue or PR is suspected to be generated by AI. Add this only if "AI generated" label is not present. Add it always if the commit or PR title is prefixed with "[AI]".'
- label: 'API'
instructions: 'This issue or PR updates the API in `packages/api`.'
- label: 'documentation'

View File

@@ -0,0 +1,74 @@
---
description: Rules for AI-generated commits and pull requests
globs:
alwaysApply: true
---
# PR and Commit Rules for AI Agents
Canonical source: `.github/agents/pr-and-commit-rules.md`
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
## Quick-Reference Workflow
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -37,12 +37,14 @@ async function getPRDetails() {
console.log('- PR Author:', pr.user.login);
console.log('- PR Title:', pr.title);
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
const result = {
number: pr.number,
author: pr.user.login,
title: pr.title,
baseBranch: pr.base.ref,
headBranch: pr.head.ref,
};
setOutput('result', JSON.stringify(result));

View File

@@ -31,6 +31,7 @@ CAGLPTPL
Caixa
CAMT
cashflow
Catppuccin
Cetelem
cimode
Citi
@@ -109,6 +110,7 @@ KBCBE
Keycloak
Khurozov
KORT
KRW
Kreditbank
lage
LHV

View File

@@ -79,3 +79,6 @@
# allowlist specific non-English words with non-ASCII characters
\b(Länsförsäkringar|München|Złoty)\b
# allowlist specific proper nouns
\b(CodeRabbit)\b

70
.github/agents/pr-and-commit-rules.md vendored Normal file
View File

@@ -0,0 +1,70 @@
# PR and Commit Rules for AI Agents
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Quick-Reference Workflow
Follow these steps when committing and creating PRs:
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -8,13 +8,13 @@ const CONFIG = {
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
PR_CONTRIBUTION_POINTS: {
Features: 2,
Enhancements: 2,
Bugfix: 3,
Maintenance: 2,
Unknown: 2,
},
PR_CONTRIBUTION_POINTS: [
{ categories: ['Features'], points: 2 },
{ categories: ['Enhancements'], points: 2 },
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
{ categories: ['Maintenance'], points: 2 },
{ categories: ['Unknown'], points: 2 },
],
// Point tiers for code changes (non-docs)
CODE_PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
@@ -130,11 +130,14 @@ async function getPRCategoryAndPoints(
'utf-8',
);
const category = parseReleaseNotesCategory(content);
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes(category),
);
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
if (tier) {
return {
category,
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
points: tier.points,
};
}
}
@@ -142,9 +145,12 @@ async function getPRCategoryAndPoints(
// Do nothing
}
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes('Unknown'),
);
return {
category: 'Unknown',
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
points: unknownTier.points,
};
}

View File

@@ -41,21 +41,12 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if PR targets master branch
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
id: check-base-branch
run: |
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
echo "Base branch: $BASE_BRANCH"
if [ "$BASE_BRANCH" = "master" ]; then
echo "targets_master=true" >> $GITHUB_OUTPUT
else
echo "targets_master=false" >> $GITHUB_OUTPUT
echo "PR does not target master branch, skipping release notes generation"
fi
- name: Check if release notes file already exists
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
if: >-
steps.check-first-comment.outputs.result == 'true' &&
steps.pr-details.outputs.result != 'null' &&
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:

View File

@@ -60,8 +60,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
node-version: 22
download-translations: 'false'
- name: Check migrations
run: node ./.github/actions/check-migrations.js
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts

View File

@@ -87,8 +87,8 @@ jobs:
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/

View File

@@ -156,53 +156,3 @@ jobs:
-NoStatus `
-AutoCommit `
-Force
publish-flathub:
needs: build
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-ubuntu-22.04
- name: Calculate AppImage SHA256
id: appimage_sha256
run: |
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new SHA256
run: |
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
branch: 'release/${{ needs.build.outputs.version }}'
draft: true
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo

View File

@@ -0,0 +1,37 @@
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
name: Merge Freeze add PR to unblocked list
on:
pull_request_target:
types: [labeled]
jobs:
unfreeze:
if: ${{ github.event.label.name == 'unfreeze' }}
runs-on: ubuntu-latest
permissions: {}
concurrency:
group: merge-freeze-unfreeze-${{ github.ref }}-labels
cancel-in-progress: false
steps:
- name: POST to Merge Freeze add PR to unblocked list
env:
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
USER_NAME: ${{ github.actor }}
run: |
set -e
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
exit 1
fi
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."

View File

@@ -1,25 +0,0 @@
name: Remove 'suspect ai generated' label when 'AI generated' is present
on:
pull_request_target:
types: [labeled]
permissions:
pull-requests: write
jobs:
remove-suspect-label:
if: >-
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'suspect ai generated'
});

126
.github/workflows/publish-flathub.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
name: Publish Flathub
defaults:
run:
shell: bash
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v25.3.0)'
required: true
type: string
concurrency:
group: publish-flathub
cancel-in-progress: false
jobs:
publish-flathub:
runs-on: ubuntu-22.04
steps:
- name: Resolve version
id: resolve_version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
TAG="$RELEASE_TAG"
else
TAG="$INPUT_TAG"
fi
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided"
exit 1
fi
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved tag=$TAG version=$VERSION"
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.resolve_version.outputs.tag }}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
echo "Found assets:"
echo "$ASSETS"
if ! echo "$ASSETS" | grep -qx "Actual-linux-x86_64.AppImage"; then
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
exit 1
fi
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
exit 1
fi
echo "All required AppImage assets found."
- name: Calculate AppImage SHA256 (streamed)
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
echo "Streaming x86_64 AppImage to compute SHA256..."
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
echo "Streaming arm64 AppImage to compute SHA256..."
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new version
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
echo "Updated manifest:"
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
branch: 'release/${{ steps.resolve_version.outputs.version }}'
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw'

2
.gitignore vendored
View File

@@ -33,7 +33,9 @@ packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
packages/component-library/dist
packages/loot-core/lib-dist
**/.tsbuildinfo
packages/sync-server/coverage
bundle.desktop.js
bundle.desktop.js.map

View File

@@ -103,6 +103,10 @@
"typescript/no-duplicate-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "warn", // TODO: covert to error
"typescript/require-array-sort-compare": "warn", // TODO: covert to error
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "warn", // TODO: covert to error
"typescript/restrict-template-expressions": "warn", // TODO: covert to error
// Import rules
"import/consistent-type-specifier-style": "error",

View File

@@ -44,25 +44,9 @@ yarn start:desktop
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
- **ALL commit messages MUST be prefixed with `[AI]`**
- **ALL pull request titles MUST be prefixed with `[AI]`**
**Examples:**
-`[AI] Fix type error in account validation`
-`[AI] Add support for new transaction categories`
-`Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
-`Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
**This requirement applies to:**
- Every single commit message created by AI agents
- Every single pull request title created by AI agents
- No exceptions are permitted
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
### Task Orchestration with Lage
@@ -314,6 +298,7 @@ Always run `yarn typecheck` before committing.
**React Patterns:**
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
@@ -360,13 +345,7 @@ Always maintain newlines between import groups.
**Git Commands:**
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
## File Structure Patterns
@@ -529,7 +508,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
2. Reinstall dependencies: `yarn install`
3. Check Node.js version (requires >=20)
3. Check Node.js version (requires >=22)
4. Check Yarn version (requires ^4.9.1)
## Testing Patterns
@@ -565,7 +544,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -578,17 +557,7 @@ Before committing changes, ensure:
## Pull Request Guidelines
When creating pull requests:
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
- ✅ Correct: `[AI] Fix type error in account validation`
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
## Code Review Guidelines
@@ -619,7 +588,7 @@ yarn install:server
## Environment Requirements
- **Node.js**: >=20
- **Node.js**: >=22
- **Yarn**: ^4.9.1 (managed by packageManager field)
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
@@ -632,3 +601,40 @@ The codebase is actively being migrated:
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
When working with older code, follow the newer patterns described in this guide.
## Cursor Cloud specific instructions
### Services overview
| Service | Command | Port | Required |
| ------------------- | ----------------------- | ---- | ----------------------------- |
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
### Running the app
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
### Lint, test, typecheck
Standard commands documented in `package.json` scripts and the Quick Start section above:
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsc + lage typecheck)
### Testing and previewing the app
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
### Gotchas
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
- All yarn commands must be run from the repository root, never from child workspaces.

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
@AGENTS.md
@.github/agents/pr-and-commit-rules.md

View File

@@ -59,6 +59,9 @@ yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace loot-core exec tsc -p tsconfig.json
yarn workspace desktop-electron update-client
(

View File

@@ -54,10 +54,10 @@
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware",
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
"lint": "yarn workspace @actual-app/api clean && oxfmt --check . && oxlint --type-aware",
"lint:fix": "yarn workspace @actual-app/api clean && oxfmt . && oxlint --fix --type-aware",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "yarn workspace @actual-app/api clean && tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},

View File

@@ -8,6 +8,7 @@ import type {
APITagEntity,
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
@@ -126,11 +127,6 @@ export function addTransactions(
});
}
export type ImportTransactionsOpts = {
defaultCleared?: boolean;
dryRun?: boolean;
};
export function importTransactions(
accountId: APIAccountEntity['id'],
transactions: ImportTransactionEntity[],

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.2.1",
"version": "26.3.0",
"description": "An API for Actual",
"license": "MIT",
"files": [
@@ -13,7 +13,7 @@
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "mkdir dist/migrations && cp migrations/*.sql dist/migrations",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",

View File

@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
@@ -8,13 +9,18 @@
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
// TEMPORARY
"loot-core/*": ["../loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"include": [".", "../../packages/loot-core/typings/pegjs.ts"],
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

1
packages/api/typings/pegjs.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.pegjs';

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node
// overview:
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
const { spawnSync } = require('child_process');
const path = require('path');
import { spawnSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const migrationsDir = path.join(
__dirname,
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'packages',
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
'migrations',
);
function readMigrations(ref) {
function readMigrations(ref: string) {
const { stdout } = spawnSync('git', [
'ls-tree',
'--name-only',

View File

@@ -3,9 +3,18 @@
"private": true,
"type": "module",
"scripts": {
"test": "vitest --run"
"tsx": "node --import=extensionless/register --experimental-strip-types",
"test": "vitest --run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"extensionless": "^2.0.6",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"extensionless": {
"lookFor": [
"ts"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"strict": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -3,6 +3,7 @@ import { type ReactNode } from 'react';
import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
import * as lightTheme from '../../desktop-client/src/style/themes/light';

View File

@@ -58,6 +58,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18"
},
"peerDependencies": {

View File

@@ -1,9 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"composite": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"strict": true
"strict": true,
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]

View File

@@ -121,12 +121,12 @@ describe('Timestamp', function () {
it('should fail with counter overflow', function () {
now = 40;
for (let i = 0; i < 65536; i++) Timestamp.send();
expect(Timestamp.send).toThrow(Timestamp.OverflowError);
expect(() => Timestamp.send()).toThrow(Timestamp.OverflowError);
});
it('should fail with clock drift', function () {
now = -(5 * 60 * 1000 + 1);
expect(Timestamp.send).toThrow(Timestamp.ClockDriftError);
expect(() => Timestamp.send()).toThrow(Timestamp.ClockDriftError);
});
});

View File

@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
@@ -8,8 +9,10 @@
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"strict": true,
"outDir": "dist"
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["."],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "26.2.1",
"version": "26.3.0",
"license": "MIT",
"files": [
"build"
@@ -97,7 +97,6 @@
"uuid": "^13.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18",
"xml2js": "^0.6.2"
}

View File

@@ -34,6 +34,7 @@ import {
import { handleGlobalEvents } from '@desktop-client/global-events';
import { useIsTestEnv } from '@desktop-client/hooks/useIsTestEnv';
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
@@ -179,6 +180,11 @@ export function App() {
);
const dispatch = useDispatch();
useOnVisible(async () => {
console.debug('triggering sync because of visibility change');
await dispatch(sync());
});
useEffect(() => {
function checkScrollbars() {
if (hiddenScrollbars !== hasHiddenScrollbars()) {
@@ -186,25 +192,9 @@ export function App() {
}
}
let isSyncing = false;
async function onVisibilityChange() {
if (!isSyncing) {
console.debug('triggering sync because of visibility change');
isSyncing = true;
await dispatch(sync());
isSyncing = false;
}
}
window.addEventListener('focus', checkScrollbars);
window.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.removeEventListener('focus', checkScrollbars);
window.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [dispatch, hiddenScrollbars]);
return () => window.removeEventListener('focus', checkScrollbars);
}, [hiddenScrollbars]);
const [theme] = useTheme();

View File

@@ -32,6 +32,7 @@ import { accountQueries } from '@desktop-client/accounts';
import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice';
import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute';
import { Permissions } from '@desktop-client/auth/types';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
@@ -91,10 +92,7 @@ export function FinancesApp() {
const dispatch = useDispatch();
const { t } = useTranslation();
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results.
const { data: accounts, isFetching: isAccountsFetching } = useQuery(
accountQueries.list(),
);
const { data: accounts, isFetching: isAccountsFetching } = useAccounts();
const versionInfo = useSelector(state => state.app.versionInfo);
const [notifyWhenUpdateIsAvailable] = useGlobalPref(

View File

@@ -12,6 +12,7 @@ import { t } from 'i18next';
import { send } from 'loot-core/platform/client/connection';
import type { Handlers } from 'loot-core/types/handlers';
import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -110,6 +111,16 @@ export function ServerProvider({ children }: { children: ReactNode }) {
void run();
}, []);
useOnVisible(
async () => {
const version = await getServerVersion();
setVersion(version);
},
{
isEnabled: !!serverURL,
},
);
const refreshLoginMethods = useCallback(async () => {
if (serverURL) {
const data: Awaited<ReturnType<Handlers['subscribe-get-login-methods']>> =

View File

@@ -15,6 +15,7 @@ type AlertProps = {
color?: string;
backgroundColor?: string;
style?: CSSProperties;
iconStyle?: CSSProperties;
children?: ReactNode;
};
@@ -23,6 +24,7 @@ const Alert = ({
color,
backgroundColor,
style,
iconStyle,
children,
}: AlertProps) => {
return (
@@ -48,21 +50,29 @@ const Alert = ({
alignSelf: 'stretch',
flexShrink: 0,
marginRight: 5,
...iconStyle,
}}
>
<Icon width={13} style={{ marginTop: 2 }} />
</View>
<Text style={{ zIndex: 1, lineHeight: 1.5 }}>{children}</Text>
<Text style={{ width: '100%', zIndex: 1, lineHeight: 1.5 }}>
{children}
</Text>
</View>
);
};
type ScopedAlertProps = {
style?: CSSProperties;
iconStyle?: CSSProperties;
children?: ReactNode;
};
export const Information = ({ style, children }: ScopedAlertProps) => {
export const Information = ({
style,
iconStyle,
children,
}: ScopedAlertProps) => {
return (
<Alert
icon={SvgInformationOutline}
@@ -73,32 +83,35 @@ export const Information = ({ style, children }: ScopedAlertProps) => {
padding: 5,
...style,
}}
iconStyle={iconStyle}
>
{children}
</Alert>
);
};
export const Warning = ({ style, children }: ScopedAlertProps) => {
export const Warning = ({ style, iconStyle, children }: ScopedAlertProps) => {
return (
<Alert
icon={SvgExclamationOutline}
color={theme.warningText}
backgroundColor={theme.warningBackground}
style={style}
iconStyle={iconStyle}
>
{children}
</Alert>
);
};
export const Error = ({ style, children }: ScopedAlertProps) => {
export const Error = ({ style, iconStyle, children }: ScopedAlertProps) => {
return (
<Alert
icon={SvgExclamationOutline}
color={theme.errorTextDarker}
backgroundColor={theme.errorBackground}
style={style}
iconStyle={iconStyle}
>
{children}
</Alert>

View File

@@ -1,20 +1,27 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import type { Screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { generateAccount } from 'loot-core/mocks';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import { PayeeAutocomplete } from './PayeeAutocomplete';
import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees';
const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
const ALL_PAYEE_ITEMS_SELECTOR = '[data-testid$="-payee-item"]';
const payees = [
makePayee('Bob', { favorite: true }),
@@ -41,7 +48,30 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
};
}
function extractPayeesAndHeaderNames(screen: Screen) {
function makeNearbyPayee(name: string, distance: number): NearbyPayeeEntity {
const id = name.toLowerCase() + '-id';
return {
payee: {
id,
name,
favorite: false,
transfer_acct: undefined,
},
location: {
id: id + '-loc',
payee_id: id,
latitude: 0,
longitude: 0,
created_at: 0,
distance,
},
};
}
function extractPayeesAndHeaderNames(
screen: Screen,
itemSelector: string = PAYEE_SELECTOR,
) {
const autocompleteElement = screen.getByTestId('autocomplete');
// Get all elements that match either selector, but query them separately
@@ -49,7 +79,7 @@ function extractPayeesAndHeaderNames(screen: Screen) {
const headers = [
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
];
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
const items = [...autocompleteElement.querySelectorAll(itemSelector)];
// Combine all elements and sort by their position in the DOM
const allElements = [...headers, ...items];
@@ -78,14 +108,52 @@ async function clickAutocomplete(autocomplete: HTMLElement) {
await waitForAutocomplete();
}
vi.mock('@desktop-client/hooks/useNearbyPayees', () => ({
useNearbyPayees: vi.fn(),
}));
function firstOrIncorrect(id: string | null): string {
return id?.split('-', 1)[0] || 'incorrect';
}
function mockNearbyPayeesResult(
data: NearbyPayeeEntity[],
): UseQueryResult<NearbyPayeeEntity[], Error> {
return {
data,
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
errorUpdateCount: 0,
failureCount: 0,
failureReason: null,
fetchStatus: 'idle',
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isLoading: false,
isLoadingError: false,
isPaused: false,
isPending: false,
isPlaceholderData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
isEnabled: true,
promise: Promise.resolve(data),
refetch: vi.fn(),
status: 'success',
};
}
describe('PayeeAutocomplete.getPayeeSuggestions', () => {
const queryClient = createTestQueryClient();
beforeEach(() => {
vi.mocked(useNearbyPayees).mockReturnValue(mockNearbyPayeesResult([]));
queryClient.setQueryData(payeeQueries.listCommon().queryKey, []);
});
@@ -207,6 +275,108 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
);
});
test('nearby payees appear in their own section before other payees', async () => {
const nearbyPayees = [
makeNearbyPayee('Coffee Shop', 0.3),
makeNearbyPayee('Grocery Store', 1.2),
];
const payees = [makePayee('Alice'), makePayee('Bob')];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Coffee Shop',
'Grocery Store',
'Payees',
'Alice',
'Bob',
]);
});
test('nearby payees are filtered by search input', async () => {
const nearbyPayees = [
makeNearbyPayee('Coffee Shop', 0.3),
makeNearbyPayee('Grocery Store', 1.2),
];
const payees = [makePayee('Alice'), makePayee('Bob')];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
const autocomplete = renderPayeeAutocomplete({ payees });
await clickAutocomplete(autocomplete);
const input = autocomplete.querySelector('input')!;
await userEvent.type(input, 'Coffee');
await waitForAutocomplete();
const names = extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR);
expect(names).toContain('Nearby Payees');
expect(names).toContain('Coffee Shop');
expect(names).not.toContain('Grocery Store');
expect(names).not.toContain('Alice');
expect(names).not.toContain('Bob');
});
test('nearby payees coexist with favorites and common payees', async () => {
const nearbyPayees = [makeNearbyPayee('Coffee Shop', 0.3)];
const payees = [
makePayee('Alice'),
makePayee('Bob'),
makePayee('Eve', { favorite: true }),
makePayee('Carol'),
];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
queryClient.setQueryData(payeeQueries.listCommon().queryKey, [
makePayee('Bob'),
makePayee('Carol'),
]);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Coffee Shop',
'Suggested Payees',
'Eve',
'Bob',
'Carol',
'Payees',
'Alice',
]);
});
test('a payee appearing in both nearby and favorites shows in both sections', async () => {
const nearbyPayees = [makeNearbyPayee('Eve', 0.5)];
const payees = [makePayee('Alice'), makePayee('Eve', { favorite: true })];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Eve',
'Suggested Payees',
'Eve',
'Payees',
'Alice',
]);
});
test('list with no favorites shows just the payees list', async () => {
//Note that the payees list assumes the payees are already sorted
const payees = [

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { Fragment, useMemo, useState } from 'react';
import React, { Fragment, useCallback, useMemo, useState } from 'react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
@@ -13,15 +13,24 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { SvgAdd, SvgBookmark } from '@actual-app/components/icons/v1';
import {
SvgAdd,
SvgBookmark,
SvgLocation,
} from '@actual-app/components/icons/v1';
import { styles } from '@actual-app/components/styles';
import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import { formatDistance } from 'loot-core/shared/location-utils';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import {
Autocomplete,
@@ -32,13 +41,19 @@ import { ItemHeader } from './ItemHeader';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
getActivePayees,
useCreatePayeeMutation,
useDeletePayeeLocationMutation,
} from '@desktop-client/payees';
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
type PayeeAutocompleteItem = PayeeEntity &
PayeeItemType & {
nearbyLocationId?: string;
distance?: number;
};
const MAX_AUTO_SUGGESTIONS = 5;
@@ -130,17 +145,25 @@ type PayeeListProps = {
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode;
footer: ReactNode;
onForgetLocation?: (locationId: string) => void;
};
type ItemTypes = 'account' | 'payee' | 'common_payee';
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
function determineItemType(
item: PayeeEntity,
isCommon: boolean,
isNearby: boolean = false,
): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isNearby) {
return 'nearby_payee';
}
if (isCommon) {
return 'common_payee';
} else {
@@ -158,6 +181,7 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem,
footer,
onForgetLocation,
}: PayeeListProps) {
const { t } = useTranslation();
@@ -165,56 +189,66 @@ function PayeeList({
// with the value of the input so it always shows whatever the user
// entered
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
if (item.id === 'new') {
acc.newPayee = { ...item };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem | null,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
},
);
const { newPayee, suggestedPayees, payees, transferPayees, nearbyPayees } =
useMemo(() => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
if (item.id === 'new') {
acc.newPayee = { ...item };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item });
} else if (item.itemType === 'nearby_payee') {
acc.nearbyPayees.push({ ...item });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem | null,
nearbyPayees: [] as Array<PayeeAutocompleteItem>,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
},
);
// assign indexes in render order
const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null;
// assign indexes in render order
const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null;
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const nearbyPayeesWithIndex = result.nearbyPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
nearbyPayees: nearbyPayeesWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
// We limit the number of payees shown to 100.
// So we show a hint that more are available via search.
@@ -237,6 +271,20 @@ function PayeeList({
embedded,
})}
{nearbyPayees.length > 0 &&
renderPayeeItemGroupHeader({ title: t('Nearby Payees') })}
{nearbyPayees.map(item => (
<Fragment key={item.id}>
<NearbyPayeeItem
{...(getItemProps ? getItemProps({ item }) : {})}
item={item}
highlighted={highlightedIndex === item.highlightedIndex}
embedded={embedded}
onForgetLocation={onForgetLocation}
/>
</Fragment>
))}
{suggestedPayees.length > 0 &&
renderPayeeItemGroupHeader({ title: t('Suggested Payees') })}
{suggestedPayees.map(item => (
@@ -324,6 +372,7 @@ export type PayeeAutocompleteProps = ComponentProps<
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeEntity[];
nearbyPayees?: NearbyPayeeEntity[];
};
export function PayeeAutocomplete({
@@ -343,16 +392,22 @@ export function PayeeAutocomplete({
renderPayeeItem = defaultRenderPayeeItem,
accounts,
payees,
nearbyPayees,
...props
}: PayeeAutocompleteProps) {
const { t } = useTranslation();
const { data: commonPayees } = useCommonPayees();
const { data: retrievedPayees = [] } = usePayees();
const { data: retrievedNearbyPayees = [] } = useNearbyPayees();
if (!payees) {
payees = retrievedPayees;
}
const createPayeeMutation = useCreatePayeeMutation();
const deletePayeeLocationMutation = useDeletePayeeLocationMutation();
if (!nearbyPayees) {
nearbyPayees = retrievedNearbyPayees;
}
const { data: cachedAccounts = [] } = useAccounts();
if (!accounts) {
@@ -392,6 +447,43 @@ export function PayeeAutocomplete({
showInactivePayees,
]);
// Process nearby payees separately from suggestions
const nearbyPayeesWithType: PayeeAutocompleteItem[] = useMemo(() => {
if (!nearbyPayees?.length) {
return [];
}
const processed: PayeeAutocompleteItem[] = nearbyPayees.map(result => ({
...result.payee,
itemType: 'nearby_payee' as const,
nearbyLocationId: result.location.id,
distance: result.location.distance,
}));
return processed;
}, [nearbyPayees]);
// Filter nearby payees based on input value (similar to regular payees)
const filteredNearbyPayees = useMemo(() => {
if (!nearbyPayeesWithType.length || !rawPayee) {
return nearbyPayeesWithType;
}
return nearbyPayeesWithType.filter(payee => {
return defaultFilterSuggestion(payee, rawPayee);
});
}, [nearbyPayeesWithType, rawPayee]);
const handleForgetLocation = useCallback(
async (locationId: string) => {
try {
await deletePayeeLocationMutation.mutateAsync(locationId);
} catch (error) {
console.error('Failed to delete payee location', { error });
}
},
[deletePayeeLocationMutation],
);
async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
@@ -480,6 +572,12 @@ export function PayeeAutocomplete({
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
getHighlightedIndex={suggestions => {
// If we have nearby payees, highlight the first nearby payee
if (filteredNearbyPayees.length > 0) {
return 0;
}
// Otherwise use original logic for suggestions
if (suggestions.length === 0) {
return null;
} else if (suggestions[0].id === 'new') {
@@ -491,7 +589,7 @@ export function PayeeAutocomplete({
filterSuggestions={filterSuggestions}
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={items}
items={[...filteredNearbyPayees, ...items]}
commonPayees={commonPayees}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -521,6 +619,7 @@ export function PayeeAutocomplete({
)}
</AutocompleteFooter>
}
onForgetLocation={handleForgetLocation}
/>
)}
{...props}
@@ -698,3 +797,126 @@ function defaultRenderPayeeItem(
): ReactElement<typeof PayeeItem> {
return <PayeeItem {...props} />;
}
type NearbyPayeeItemProps = PayeeItemProps & {
onForgetLocation?: (locationId: string) => void;
};
function NearbyPayeeItem({
item,
className,
highlighted,
embedded,
onForgetLocation,
...props
}: NearbyPayeeItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
const iconSize = isNarrowWidth ? 14 : 8;
let paddingLeftOverFromIcon = 20;
let itemIcon = undefined;
if (item.favorite) {
itemIcon = (
<SvgBookmark
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
);
paddingLeftOverFromIcon -= iconSize + 5;
}
// Extract location ID and distance from the nearby payee item
const locationId = item.nearbyLocationId;
const distance = item.distance;
const distanceText = distance !== undefined ? formatDistance(distance) : '';
const handleForgetClick = () => {
if (locationId && onForgetLocation) {
onForgetLocation(locationId);
}
};
return (
<div
className={cx(
className,
css({
backgroundColor: highlighted
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
color: highlighted
? theme.menuAutoCompleteItemTextHover
: theme.menuAutoCompleteItemText,
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: paddingLeftOverFromIcon,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
...narrowStyle,
}),
)}
data-testid={`${item.name}-payee-item`}
data-highlighted={highlighted || undefined}
>
<button
type="button"
className={css({
display: 'flex',
flexDirection: 'column',
flex: 1,
background: 'none',
border: 'none',
font: 'inherit',
color: 'inherit',
textAlign: 'left',
padding: 0,
cursor: 'pointer',
})}
{...props}
>
<TextOneLine>
{itemIcon}
{item.name}
</TextOneLine>
{distanceText && (
<div
style={{
fontSize: '10px',
color: highlighted
? theme.menuAutoCompleteItemTextHover
: theme.pageTextSubdued,
marginLeft: itemIcon ? iconSize + 5 : 0,
}}
>
{distanceText}
</div>
)}
</button>
{locationId && (
<Button
variant="menu"
onPress={handleForgetClick}
style={{
backgroundColor: theme.errorBackground,
border: `1px solid ${theme.errorBorder}`,
color: theme.pageText,
fontSize: '11px',
padding: '2px 6px',
borderRadius: 3,
}}
>
<Trans i18nKey="forget">Forget</Trans>
<SvgLocation width={10} height={10} style={{ marginLeft: 4 }} />
</Button>
)}
</div>
);
}

View File

@@ -195,13 +195,13 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
name="synced-account-edit"
containerProps={{ style: { width: 800 } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('{{accountName}} bank sync settings', {
accountName: potentiallyTruncatedAccountName,
})}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<Text style={{ fontSize: 15 }}>
@@ -246,20 +246,20 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
<Button
style={{ color: theme.errorText }}
onPress={() => {
void onUnlink(close);
void onUnlink(() => state.close());
}}
>
<Trans>Unlink account</Trans>
</Button>
<SpaceBetween gap={10}>
<Button onPress={close}>
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={() => {
void onSave(close);
void onSave(() => state.close());
}}
>
<Trans>Save</Trans>

View File

@@ -2,7 +2,6 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Warning } from '@desktop-client/components/alerts';
@@ -19,13 +18,17 @@ export function RefillAutomation({
const { t } = useTranslation();
return (
<SpaceBetween direction="vertical" gap={10} style={{ marginTop: 10 }}>
<Text>
<Trans>Uses the balance limit automation for this category.</Trans>
</Text>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
{!hasLimitAutomation && (
<Warning>
<SpaceBetween gap={10} align="center" style={{ flexWrap: 'wrap' }}>
<Warning
style={{ width: '100%', alignItems: 'center' }}
iconStyle={{ alignSelf: 'unset', paddingTop: 0, marginTop: -2 }}
>
<SpaceBetween
gap={10}
align="center"
style={{ width: '100%', justifyContent: 'space-between' }}
>
<View>
<Trans>
Add a balance limit automation to set the refill target.

View File

@@ -313,7 +313,7 @@ export function ConfigServer() {
switch (error) {
case 'network-failure':
return t(
'Server is not running at this URL. Make sure you have HTTPS set up properly.',
'Connection failed. If you use a self-signed certificate or were recently offline, try refreshing the page. Otherwise ensure you have HTTPS set up properly.',
);
default:
return t(

View File

@@ -119,6 +119,8 @@ export function ActionableGridListItem<T extends object>({
padding: 16,
textAlign: 'left',
borderRadius: 0,
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
onClick={handleAction}
>

View File

@@ -79,6 +79,7 @@ InputField.displayName = 'InputField';
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
rightContent?: ReactNode;
alwaysShowRightContent?: boolean;
textStyle?: CSSProperties;
};
@@ -105,6 +106,7 @@ export function TapField({
children,
className,
rightContent,
alwaysShowRightContent,
textStyle,
ref,
...props
@@ -135,7 +137,7 @@ export function TapField({
{value}
</Text>
)}
{!props.isDisabled && rightContent}
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
</Button>
);
}

View File

@@ -49,7 +49,7 @@ function TransactionListWithPreviews() {
} = useTransactions({
query: transactionsQuery,
});
const offBudgetAccounts = useOffBudgetAccounts();
const { data: offBudgetAccounts = [] } = useOffBudgetAccounts();
const offBudgetAccountsFilter = useCallback(
(schedule: ScheduleEntity) =>
offBudgetAccounts.some(a => a.id === schedule._account),

View File

@@ -49,7 +49,7 @@ function TransactionListWithPreviews() {
} = useTransactions({
query: transactionsQuery,
});
const onBudgetAccounts = useOnBudgetAccounts();
const { data: onBudgetAccounts = [] } = useOnBudgetAccounts();
const onBudgetAccountsFilter = useCallback(
(schedule: ScheduleEntity) =>
onBudgetAccounts.some(a => a.id === schedule._account),

View File

@@ -52,7 +52,10 @@ export function RulesListItem({
}
{...props}
>
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
<SpaceBetween
gap={12}
style={{ alignItems: 'flex-start', width: '100%' }}
>
{/* Column 1: PRE/POST pill */}
<View
style={{

View File

@@ -14,6 +14,7 @@ import { Button } from '@actual-app/components/button';
import { SvgSplit } from '@actual-app/components/icons/v0';
import {
SvgAdd,
SvgLocation,
SvgPiggyBank,
SvgTrash,
} from '@actual-app/components/icons/v1';
@@ -31,6 +32,8 @@ import {
} from 'date-fns';
import { send } from 'loot-core/platform/client/connection';
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import { calculateDistance } from 'loot-core/shared/location-utils';
import * as monthUtils from 'loot-core/shared/months';
import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query';
@@ -79,7 +82,9 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useLocationPermission } from '@desktop-client/hooks/useLocationPermission';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
SingleActiveEditFormProvider,
@@ -88,6 +93,8 @@ import {
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useSavePayeeLocationMutation } from '@desktop-client/payees';
import { locationService } from '@desktop-client/payees/location';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
@@ -554,6 +561,10 @@ type TransactionEditInnerProps = {
onDelete: (id: TransactionEntity['id']) => void;
onSplit: (id: TransactionEntity['id']) => void;
onAddSplit: (id: TransactionEntity['id']) => void;
shouldShowSaveLocation?: boolean;
onSaveLocation?: () => void;
onSelectNearestPayee?: () => void;
nearestPayee?: PayeeEntity | null;
};
const TransactionEditInner = memo<TransactionEditInnerProps>(
@@ -569,6 +580,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
onDelete,
onSplit,
onAddSplit,
shouldShowSaveLocation,
onSaveLocation,
onSelectNearestPayee,
nearestPayee,
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -1090,6 +1105,56 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
}
onPress={() => onEditFieldInner(transaction.id, 'payee')}
data-testid="payee-field"
alwaysShowRightContent={
!!nearestPayee && !transaction.payee && !shouldShowSaveLocation
}
rightContent={
shouldShowSaveLocation ? (
<Button
variant="bare"
onPress={onSaveLocation}
style={{
backgroundColor: theme.buttonNormalBackground,
border: `1px solid ${theme.buttonNormalBorder}`,
color: theme.buttonNormalText,
fontSize: '11px',
padding: '4px 8px',
borderRadius: 3,
height: 'auto',
minHeight: 'auto',
}}
>
<Trans>Save</Trans>
<SvgLocation
width={10}
height={10}
style={{ marginLeft: 4 }}
/>
</Button>
) : nearestPayee && !transaction.payee ? (
<Button
variant="bare"
onPress={onSelectNearestPayee}
style={{
backgroundColor: theme.buttonNormalBackground,
border: `1px solid ${theme.buttonNormalBorder}`,
color: theme.buttonNormalText,
fontSize: '11px',
padding: '4px 8px',
borderRadius: 3,
height: 'auto',
minHeight: 'auto',
}}
>
<Trans>Nearby</Trans>
<SvgLocation
width={10}
height={10}
style={{ marginLeft: 4 }}
/>
</Button>
) : undefined
}
/>
</View>
@@ -1312,6 +1377,7 @@ function TransactionEditUnconnected({
const { state: locationState } = useLocation();
const [searchParams] = useSearchParams();
const dispatch = useDispatch();
const updatePayeeLocationMutation = useSavePayeeLocationMutation();
const navigate = useNavigate();
const [transactions, setTransactions] = useState<TransactionEntity[]>([]);
const [fetchedTransactions, setFetchedTransactions] = useState<
@@ -1333,6 +1399,11 @@ function TransactionEditUnconnected({
[payees, searchParams],
);
const locationAccess = useLocationPermission();
const [shouldShowSaveLocation, setShouldShowSaveLocation] = useState(false);
const { data: nearbyPayees = [] } = useNearbyPayees();
const nearestPayee = nearbyPayees[0]?.payee ?? null;
useEffect(() => {
let unmounted = false;
@@ -1370,6 +1441,12 @@ function TransactionEditUnconnected({
};
}, [transactionId]);
useEffect(() => {
if (!locationAccess) {
setShouldShowSaveLocation(false);
}
}, [locationAccess]);
useEffect(() => {
if (isAdding.current) {
setTransactions([
@@ -1430,11 +1507,15 @@ function TransactionEditUnconnected({
if (diff) {
Object.keys(diff).forEach(key => {
const field = key as keyof TransactionEntity;
// Update "empty" fields in general
// Or update all fields if the payee changes (assists location-based entry by
// applying rules to prefill category, notes, etc. based on the selected payee)
if (
newTransaction[field] == null ||
newTransaction[field] === '' ||
newTransaction[field] === 0 ||
newTransaction[field] === false
newTransaction[field] === false ||
updatedField === 'payee'
) {
(newTransaction as Record<string, unknown>)[field] = diff[field];
}
@@ -1463,8 +1544,33 @@ function TransactionEditUnconnected({
newTransaction,
);
setTransactions(newTransactions);
if (updatedField === 'payee') {
setShouldShowSaveLocation(false);
if (newTransaction.payee && locationAccess) {
const payeeLocations = await locationService.getPayeeLocations(
newTransaction.payee,
);
if (payeeLocations.length === 0) {
setShouldShowSaveLocation(true);
} else {
const currentPosition = await locationService.getCurrentPosition();
const hasNearby = payeeLocations.some(
loc =>
calculateDistance(currentPosition, {
latitude: loc.latitude,
longitude: loc.longitude,
}) <= DEFAULT_MAX_DISTANCE_METERS,
);
if (!hasNearby) {
setShouldShowSaveLocation(true);
}
}
}
}
},
[dateFormat, transactions],
[dateFormat, transactions, locationAccess],
);
const onSave = useCallback(
@@ -1544,6 +1650,39 @@ function TransactionEditUnconnected({
[transactions],
);
const onSaveLocation = useCallback(async () => {
try {
const [transaction] = transactions;
if (transaction.payee) {
await updatePayeeLocationMutation.mutateAsync(transaction.payee);
setShouldShowSaveLocation(false);
}
} catch (error) {
console.error('Failed to save location', { error });
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to save location'),
},
}),
);
}
}, [t, transactions, dispatch, updatePayeeLocationMutation]);
const onSelectNearestPayee = useCallback(() => {
const transaction = transactions[0];
if (!nearestPayee || !transaction || transaction.payee) {
return;
}
const updated = {
...serializeTransaction(transaction, dateFormat),
payee: nearestPayee.id,
};
onUpdate(updated, 'payee');
}, [transactions, nearestPayee, onUpdate, dateFormat]);
if (accounts.length === 0) {
return (
<Page
@@ -1669,6 +1808,10 @@ function TransactionEditUnconnected({
onDelete={onDelete}
onSplit={onSplit}
onAddSplit={onAddSplit}
shouldShowSaveLocation={shouldShowSaveLocation}
onSaveLocation={onSaveLocation}
onSelectNearestPayee={onSelectNearestPayee}
nearestPayee={locationAccess ? nearestPayee : null}
/>
</View>
);

View File

@@ -34,10 +34,7 @@ import type { AccountEntity, TransactionEntity } from 'loot-core/types/models';
import { lookupName, Status } from './TransactionEdit';
import {
makeAmountFullStyle,
makeBalanceAmountStyle,
} from '@desktop-client/components/budget/util';
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
import { useAccount } from '@desktop-client/hooks/useAccount';
import { useCachedSchedules } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -283,7 +280,11 @@ export function TransactionListItem({
<Text
style={{
...styles.tnum,
...makeAmountFullStyle(amount),
...makeAmountFullStyle(amount, {
positiveColor: theme.tableText,
negativeColor: theme.tableText,
zeroColor: theme.numberNeutral,
}),
...textStyle,
}}
>
@@ -295,7 +296,11 @@ export function TransactionListItem({
fontSize: 11,
fontWeight: '400',
...styles.tnum,
...makeBalanceAmountStyle(runningBalance),
...makeAmountFullStyle(runningBalance, {
positiveColor: theme.numberPositive,
negativeColor: theme.numberNegative,
zeroColor: theme.numberNeutral,
}),
}}
>
{integerToCurrency(runningBalance)}

View File

@@ -46,7 +46,7 @@ export function AccountAutocompleteModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
{isNarrowWidth && (
<ModalHeader
@@ -58,7 +58,7 @@ export function AccountAutocompleteModal({
}
rightContent={
<ModalCloseButton
onPress={close}
onPress={() => state.close()}
style={{ color: theme.menuAutoCompleteText }}
/>
}
@@ -80,7 +80,7 @@ export function AccountAutocompleteModal({
focused
embedded
closeOnBlur={false}
onClose={close}
onClose={() => state.close()}
{...defaultAutocompleteProps}
onSelect={onSelect}
includeClosedAccounts={includeClosedAccounts}

View File

@@ -114,7 +114,7 @@ export function AccountMenuModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
leftContent={
@@ -140,7 +140,7 @@ export function AccountMenuModal({
)}
</Fragment>
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{

View File

@@ -0,0 +1,125 @@
import type { Template } from 'loot-core/types/models/templates';
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
describe('migrateTemplatesToAutomations', () => {
it('preserves simple templates that have no limit and no monthly amount', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
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', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 7,
limit: {
amount: 120,
hold: true,
period: 'monthly',
start: '2026-02-01',
},
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(2);
expect(result[0].displayType).toBe('limit');
expect(result[0].template).toEqual({
type: 'limit',
amount: 120,
hold: true,
period: 'monthly',
start: '2026-02-01',
directive: 'template',
priority: null,
});
expect(result[1].displayType).toBe('refill');
expect(result[1].template).toEqual({
type: 'refill',
directive: 'template',
priority: 7,
});
});
it('expands a simple template with monthly amount into one periodic entry', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 3,
monthly: 45,
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('week');
expect(result[0].template).toMatchObject({
type: 'periodic',
amount: 45,
period: {
period: 'month',
amount: 1,
},
directive: 'template',
priority: 3,
});
expect(result[0].template).toMatchObject({
starting: expect.any(String),
});
});
it('expands a simple template with both limit and monthly into three entries in order', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 11,
monthly: 20,
limit: {
amount: 200,
hold: false,
period: 'weekly',
},
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
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',
priority: 11,
});
});
it('creates a single entry for non-simple templates', () => {
const scheduleTemplate = {
type: 'schedule',
directive: 'template',
priority: 1,
name: 'rent',
} satisfies Template;
const result = migrateTemplatesToAutomations([scheduleTemplate]);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('schedule');
expect(result[0].template).toEqual(scheduleTemplate);
});
});

View File

@@ -10,6 +10,7 @@ import { View } from '@actual-app/components/view';
import uniqueId from 'lodash/uniqueId';
import { send } from 'loot-core/platform/client/connection';
import { dayFromDate, firstDayOfMonth } from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type {
CategoryGroupEntity,
@@ -19,6 +20,7 @@ import type { Template } from 'loot-core/types/models/templates';
import { Warning } from '@desktop-client/components/alerts';
import { BudgetAutomation } from '@desktop-client/components/budget/goals/BudgetAutomation';
import type { DisplayTemplateType } from '@desktop-client/components/budget/goals/constants';
import { DEFAULT_PRIORITY } from '@desktop-client/components/budget/goals/reducer';
import { useBudgetAutomationCategories } from '@desktop-client/components/budget/goals/useBudgetAutomationCategories';
import { Link } from '@desktop-client/components/common/Link';
@@ -34,6 +36,120 @@ import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/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,
@@ -41,49 +157,69 @@ function BudgetAutomationList({
categories,
style,
}: {
automations: Template[];
setAutomations: (fn: (prev: Template[]) => Template[]) => void;
automations: AutomationEntry[];
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
style?: CSSProperties;
}) {
const [automationIds, setAutomationIds] = useState(() => {
// automations don't have ids, so we need to generate them
return automations.map(() => uniqueId('automation-'));
});
const onAdd = () => {
const newId = uniqueId('automation-');
setAutomationIds(prevIds => [...prevIds, newId]);
setAutomations(prev => [
...prev,
{
type: 'simple',
monthly: 5,
directive: 'template',
priority: DEFAULT_PRIORITY,
},
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)]);
setAutomationIds(prev => [
...prev.slice(0, index),
...prev.slice(index + 1),
]);
};
const onSave = useCallback(
(index: number) => (template: Template) => {
setAutomations(prev =>
prev.map((oldAutomation, mapIndex) =>
mapIndex === index ? template : oldAutomation,
),
);
},
(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"
@@ -97,12 +233,16 @@ function BudgetAutomationList({
>
{automations.map((automation, index) => (
<BudgetAutomation
key={automationIds[index]}
key={automation.id}
onSave={onSave(index)}
onDelete={onDelete(index)}
template={automation}
template={automation.template}
categories={categories}
schedules={schedules}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={
automation.displayType === 'refill' ? onAddLimit : undefined
}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
@@ -181,12 +321,20 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const [automations, setAutomations] = useState<Record<string, Template[]>>(
{},
);
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: setAutomations,
onLoaded,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
@@ -205,11 +353,12 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
return;
}
const templates = automations[categoryId].map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{
id: categoryId,
templates: automations[categoryId],
templates,
},
],
source: 'ui',
@@ -224,7 +373,7 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
style: { width: 850, height: 650, paddingBottom: 20 },
}}
>
{({ state: { close } }) => (
{({ state }) => (
<SpaceBetween
direction="vertical"
wrap={false}
@@ -235,7 +384,7 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
title={t('Budget automations: {{category}}', {
category: currentCategory?.name,
})}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
{loading ? (
<View
@@ -252,13 +401,18 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : (
<SpaceBetween align="stretch" direction="vertical">
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
{needsMigration && (
<BudgetAutomationMigrationWarning categoryId={categoryId} />
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0 }}
/>
)}
<BudgetAutomationList
automations={automations[categoryId] || []}
setAutomations={(cb: (prev: Template[]) => Template[]) => {
setAutomations={(
cb: (prev: AutomationEntry[]) => AutomationEntry[],
) => {
setAutomations(prev => ({
...prev,
[categoryId]: cb(prev[categoryId] || []),
@@ -286,7 +440,10 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: { categoryId, templates },
options: {
categoryId,
templates: templates.map(({ template }) => template),
},
},
}),
);
@@ -296,10 +453,13 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
</Link>
)}
{/* <View style={{ flex: 1 }} /> */}
<Button onPress={close}>
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" onPress={() => onSave(close)}>
<Button
variant="primary"
onPress={() => onSave(() => state.close())}
>
<Trans>Save</Trans>
</Button>
</SpaceBetween>

View File

@@ -33,11 +33,11 @@ export function BudgetPageMenuModal({
return (
<Modal name="budget-page-menu">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
showLogo
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<BudgetPageMenu
getItemStyle={() => defaultMenuItemStyle}

View File

@@ -54,7 +54,7 @@ export function CategoryAutocompleteModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
{isNarrowWidth && (
<ModalHeader
@@ -66,7 +66,7 @@ export function CategoryAutocompleteModal({
}
rightContent={
<ModalCloseButton
onPress={close}
onPress={() => state.close()}
style={{ color: theme.menuAutoCompleteText }}
/>
}
@@ -94,7 +94,7 @@ export function CategoryAutocompleteModal({
closeOnSelect={closeOnSelect}
clearOnSelect={clearOnSelect}
showSplitOption={false}
onClose={close}
onClose={() => state.close()}
{...defaultAutocompleteProps}
onSelect={onSelect}
categoryGroups={categoryGroups}

View File

@@ -54,7 +54,7 @@ export function CategoryGroupAutocompleteModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
{isNarrowWidth && (
<ModalHeader
@@ -66,7 +66,7 @@ export function CategoryGroupAutocompleteModal({
}
rightContent={
<ModalCloseButton
onPress={close}
onPress={() => state.close()}
style={{ color: theme.menuAutoCompleteText }}
/>
}
@@ -93,7 +93,7 @@ export function CategoryGroupAutocompleteModal({
closeOnBlur={false}
closeOnSelect={closeOnSelect}
clearOnSelect={clearOnSelect}
onClose={close}
onClose={() => state.close()}
{...defaultAutocompleteProps}
onSelect={onSelect}
categoryGroups={categoryGroups}

View File

@@ -133,7 +133,7 @@ export function CategoryGroupMenuModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
leftContent={
@@ -150,7 +150,7 @@ export function CategoryGroupMenuModal({
onTitleUpdate={onRename}
/>
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{
@@ -237,7 +237,7 @@ export function CategoryGroupMenuModal({
getItemStyle={() => defaultMenuItemStyle}
onApplyBudgetTemplatesInGroup={() => {
_onApplyBudgetTemplatesInGroup();
close();
state.close();
showUndoNotification({
message: t('budget templates have been applied.'),
});

View File

@@ -86,7 +86,7 @@ export function CategoryMenuModal({
style: { height: '45vh' },
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
leftContent={
@@ -104,7 +104,7 @@ export function CategoryMenuModal({
onTitleUpdate={onRename}
/>
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{

View File

@@ -130,11 +130,11 @@ export function CloseAccountModal({
isLoading={loading}
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Close Account')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<Paragraph>
@@ -164,7 +164,7 @@ export function CloseAccountModal({
<Form
onSubmit={e => {
if (onSubmit(e)) {
close();
state.close();
}
}}
>
@@ -286,7 +286,7 @@ export function CloseAccountModal({
id: account.id,
forced: true,
});
close();
state.close();
}}
style={{ color: theme.errorText }}
>
@@ -311,7 +311,7 @@ export function CloseAccountModal({
marginRight: 10,
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
}}
onPress={close}
onPress={() => state.close()}
>
<Trans>Cancel</Trans>
</Button>

View File

@@ -71,11 +71,11 @@ export function ConfirmCategoryDeleteModal({
name="confirm-category-delete"
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Confirm Delete')} // Use translation for title
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
{group ? (
@@ -177,7 +177,7 @@ export function ConfirmCategoryDeleteModal({
setError('required-transfer');
} else {
onDelete(transferCategory);
close();
state.close();
}
}}
>

View File

@@ -34,11 +34,11 @@ export function ConfirmDeleteModal({
return (
<Modal name="confirm-delete">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Confirm Delete')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>{message}</Paragraph>
@@ -53,7 +53,7 @@ export function ConfirmDeleteModal({
marginRight: 10,
...narrowButtonStyle,
}}
onPress={close}
onPress={() => state.close()}
>
<Trans>Cancel</Trans>
</Button>
@@ -63,7 +63,7 @@ export function ConfirmDeleteModal({
style={narrowButtonStyle}
onPress={() => {
onConfirm();
close();
state.close();
}}
>
<Trans>Delete</Trans>

View File

@@ -40,11 +40,11 @@ export function ConfirmTransactionEditModal({
name="confirm-transaction-edit"
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Reconciled Transaction')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
{confirmReason === 'batchDeleteWithReconciled' ? (
@@ -109,7 +109,7 @@ export function ConfirmTransactionEditModal({
...narrowButtonStyle,
}}
onPress={() => {
close();
state.close();
onCancel();
}}
>
@@ -124,7 +124,7 @@ export function ConfirmTransactionEditModal({
...narrowButtonStyle,
}}
onPress={() => {
close();
state.close();
onConfirm();
}}
>

View File

@@ -30,11 +30,11 @@ export function ConfirmUnlinkAccountModal({
name="confirm-unlink-account"
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Confirm Unlink')} // Use translation for title
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>
@@ -59,7 +59,7 @@ export function ConfirmUnlinkAccountModal({
justifyContent: 'flex-end',
}}
>
<Button style={{ marginRight: 10 }} onPress={close}>
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<InitialFocus>
@@ -67,7 +67,7 @@ export function ConfirmUnlinkAccountModal({
variant="primary"
onPress={() => {
onUnlink();
close();
state.close();
}}
>
<Trans>Unlink</Trans>

View File

@@ -42,11 +42,11 @@ export function ConvertToScheduleModal({
name="convert-to-schedule"
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Convert to Schedule')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Block>
@@ -95,7 +95,7 @@ export function ConvertToScheduleModal({
...(isNarrowWidth && { flex: 1 }),
}}
onPress={() => {
close();
state.close();
onCancel?.();
}}
>
@@ -110,7 +110,7 @@ export function ConvertToScheduleModal({
...(isNarrowWidth && { flex: 1 }),
}}
onPress={() => {
close();
state.close();
onConfirm();
}}
>

View File

@@ -32,11 +32,11 @@ export function CopyWidgetToDashboardModal({
return (
<Modal name="copy-widget-to-dashboard">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Copy to dashboard')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ lineHeight: 1.5 }}>
@@ -45,7 +45,7 @@ export function CopyWidgetToDashboardModal({
items={items}
onMenuSelect={item => {
onSelect(item);
close();
state.close();
}}
/>
) : (
@@ -61,7 +61,7 @@ export function CopyWidgetToDashboardModal({
marginTop: 15,
}}
>
<Button onPress={close}>
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
</View>

View File

@@ -98,11 +98,11 @@ export function CoverModal({
return (
<Modal name="cover">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={title}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<FieldLabel title={t('Cover this amount:')} />
@@ -148,7 +148,7 @@ export function CoverModal({
}}
onPress={() => {
_onSubmit();
close();
state.close();
}}
>
<Trans>Transfer</Trans>

View File

@@ -335,11 +335,11 @@ export function CreateAccountModal({
return (
<Modal name="add-account">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={title}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
{upgradingAccountId == null && (

View File

@@ -71,13 +71,13 @@ export function CreateEncryptionKeyModal({
return (
<Modal name="create-encryption-key">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
isRecreating ? t('Generate new key') : t('Enable encryption')
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{
@@ -174,7 +174,7 @@ export function CreateEncryptionKeyModal({
<Form
onSubmit={e => {
e.preventDefault();
void onCreateKey(close);
void onCreateKey(() => state.close());
}}
>
<View style={{ alignItems: 'center' }}>

View File

@@ -83,13 +83,13 @@ export function CreateLocalAccountModal() {
};
return (
<Modal name="add-local-account">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
<ModalTitle title={t('Create Local Account')} shrinkOnOverflow />
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<Form onSubmit={onSubmit}>
@@ -191,7 +191,7 @@ export function CreateLocalAccountModal() {
)}
<ModalButtons>
<Button onPress={close}>
<Button onPress={() => state.close()}>
<Trans>Back</Trans>
</Button>
<Button

View File

@@ -272,12 +272,12 @@ export function EditFieldModal({
},
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
{isNarrowWidth && (
<ModalHeader
title={label}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
)}
<View>
@@ -291,7 +291,9 @@ export function EditFieldModal({
}}
/>
)}
<View style={{ flex: 1 }}>{editor({ close })}</View>
<View style={{ flex: 1 }}>
{editor({ close: () => state.close() })}
</View>
</View>
</>
)}

View File

@@ -25,19 +25,19 @@ export function EditRuleModal({
return (
<Modal name="edit-rule">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Rule')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<RuleEditor
rule={defaultRule}
onSave={rule => {
originalOnSave?.(rule);
close();
state.close();
}}
onCancel={close}
onCancel={() => state.close()}
style={{
maxWidth: '100%',
width: 900,

View File

@@ -133,7 +133,7 @@ export function EditUserFinanceApp({
const isExistingUser = 'id' in defaultUser && !!defaultUser.id;
return (
<Modal name="edit-user">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
@@ -143,14 +143,14 @@ export function EditUserFinanceApp({
})
: t('Add user')
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<EditUser
defaultUser={defaultUser}
onSave={async (method, user, setError) => {
if (await saveUser(method, user, setError)) {
originalOnSave(user);
close();
state.close();
}
}}
/>

View File

@@ -49,11 +49,11 @@ export function EnvelopeBalanceMenuModal({
return (
<Modal name="envelope-balance-menu">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{

View File

@@ -66,11 +66,11 @@ export function EnvelopeBudgetMenuModal({
return (
<Modal name="envelope-budget-menu">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{
@@ -92,7 +92,7 @@ export function EnvelopeBudgetMenuModal({
focused={amountFocused}
onFocus={() => setAmountFocused(true)}
onBlur={() => setAmountFocused(false)}
onEnter={close}
onEnter={() => state.close()}
zeroSign="+"
focusedStyle={{
width: 'auto',

View File

@@ -77,11 +77,11 @@ export function EnvelopeBudgetMonthMenuModal({
style: { height: '50vh' },
}}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={displayMonth}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{
@@ -168,7 +168,7 @@ export function EnvelopeBudgetMonthMenuModal({
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthBudget={() => {
onBudgetAction(month, 'copy-last');
close();
state.close();
showUndoNotification({
message: t(
"{{displayMonth}} budgets have all been set to last month's budgeted amounts.",
@@ -178,7 +178,7 @@ export function EnvelopeBudgetMonthMenuModal({
}}
onSetBudgetsToZero={() => {
onBudgetAction(month, 'set-zero');
close();
state.close();
showUndoNotification({
message: t(
'{{displayMonth}} budgets have all been set to zero.',
@@ -188,18 +188,18 @@ export function EnvelopeBudgetMonthMenuModal({
}}
onSetMonthsAverage={numberOfMonths => {
onBudgetAction(month, `set-${numberOfMonths}-avg`);
close();
state.close();
showUndoNotification({
message: `${displayMonth} budgets have all been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
}}
onCheckTemplates={() => {
onBudgetAction(month, 'check-templates');
close();
state.close();
}}
onApplyBudgetTemplates={() => {
onBudgetAction(month, 'apply-goal-template');
close();
state.close();
showUndoNotification({
message: t(
'{{displayMonth}} budget templates have been applied.',
@@ -209,7 +209,7 @@ export function EnvelopeBudgetMonthMenuModal({
}}
onOverwriteWithBudgetTemplates={() => {
onBudgetAction(month, 'overwrite-goal-template');
close();
state.close();
showUndoNotification({
message: t(
'{{displayMonth}} budget templates have been overwritten.',
@@ -219,7 +219,7 @@ export function EnvelopeBudgetMonthMenuModal({
}}
onEndOfMonthCleanup={() => {
onBudgetAction(month, 'cleanup-goal-template');
close();
state.close();
showUndoNotification({
message: t(
'{{displayMonth}} end-of-month cleanup templates have been applied.',

View File

@@ -158,11 +158,11 @@ export function EnvelopeBudgetSummaryModal({
return (
<Modal name="envelope-budget-summary">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Budget Summary')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<SheetNameProvider name={sheetForMonth(month)}>
<TotalsList
@@ -180,7 +180,7 @@ export function EnvelopeBudgetSummaryModal({
amountStyle={{
...styles.underlinedText,
}}
onClick={() => onClick({ close })}
onClick={() => onClick({ close: () => state.close() })}
isTotalsListTooltipDisabled
/>
</SheetNameProvider>

View File

@@ -54,11 +54,11 @@ export function EnvelopeIncomeBalanceMenuModal({
return (
<Modal name="envelope-income-balance-menu">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{

View File

@@ -34,11 +34,11 @@ export function EnvelopeToBudgetMenuModal({
return (
<Modal name="envelope-summary-to-budget-menu">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
showLogo
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<ToBudgetMenu
getItemStyle={() => defaultMenuItemStyle}

View File

@@ -64,7 +64,7 @@ export function FixEncryptionKeyModal({
return (
<Modal name="fix-encryption-key">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
@@ -72,7 +72,7 @@ export function FixEncryptionKeyModal({
? t('Decrypt budget file')
: t('This file is encrypted')
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{
@@ -111,7 +111,7 @@ export function FixEncryptionKeyModal({
<Form
onSubmit={e => {
e.preventDefault();
void onUpdateKey(close);
void onUpdateKey(() => state.close());
}}
>
<View
@@ -164,7 +164,7 @@ export function FixEncryptionKeyModal({
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
marginRight: 10,
}}
onPress={close}
onPress={() => state.close()}
>
<Trans>Back</Trans>
</Button>

View File

@@ -273,11 +273,11 @@ export function GoCardlessExternalMsgModal({
onClose={onClose}
containerProps={{ style: { width: '30vw' } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Link Your Bank')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<Paragraph style={{ fontSize: 15 }}>

View File

@@ -83,11 +83,11 @@ export const GoCardlessInitialiseModal = ({
return (
<Modal name="gocardless-init" containerProps={{ style: { width: '30vw' } }}>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Set up GoCardless')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
@@ -142,7 +142,7 @@ export const GoCardlessInitialiseModal = ({
variant="primary"
isLoading={isLoading}
onPress={() => {
void onSubmit(close);
void onSubmit(() => state.close());
}}
>
<Trans>Save and continue</Trans>

View File

@@ -17,11 +17,11 @@ export function GoalTemplateModal() {
return (
<Modal name="goal-templates" containerProps={{ style: { width: 850 } }}>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Goal Templates')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<TableHeader>

View File

@@ -41,11 +41,11 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
return (
<Modal name="hold-buffer">
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Hold for next month')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<FieldLabel title={t('Hold this amount:')} />{' '}
@@ -64,7 +64,7 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
onUpdate={setAmount}
onEnter={() => {
_onSubmit(amount);
close();
state.close();
}}
/>
</InitialFocus>

View File

@@ -794,14 +794,14 @@ export function ImportTransactionsModal({
isLoading={loadingState === 'parsing'}
containerProps={{ style: { width: 800 } }}
>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
t('Import transactions') +
(filetype ? ` (${filetype.toUpperCase()})` : '')
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
{error && !error.parsed && (
<View style={{ alignItems: 'center', marginBottom: 15 }}>
@@ -1133,7 +1133,7 @@ export function ImportTransactionsModal({
isDisabled={count === 0}
isLoading={loadingState === 'importing'}
onPress={() => {
void onImport(close);
void onImport(() => state.close());
}}
>
<Trans count={count}>Import {{ count }} transactions</Trans>

View File

@@ -464,7 +464,7 @@ export function KeyboardShortcutModal() {
return (
<Modal name="keyboard-shortcuts" containerProps={{ style: { width: 700 } }}>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={
@@ -495,7 +495,7 @@ export function KeyboardShortcutModal() {
</Button>
) : null
}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
style={{

View File

@@ -89,11 +89,11 @@ export function LoadBackupModal({
return (
<Modal name="load-backup" containerProps={{ style: { maxWidth: '30vw' } }}>
{({ state: { close } }) => (
{({ state }) => (
<>
<ModalHeader
title={t('Load Backup')}
rightContent={<ModalCloseButton onPress={close} />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ marginBottom: 30 }}>
<View

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