Compare commits

..

32 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
7c3b5dabf3 Add promise-retry dependency to loot-core package and update yarn.lock 2026-03-05 22:08:03 +00:00
Matiss Janis Aboltins
47f3f41f7d Update API tests to mock file system paths for migration handling and change Vite configuration to target Node 20 for improved compatibility. 2026-03-05 22:07:03 +00:00
Matiss Janis Aboltins
2b3d8ef8c2 Refactor rootPath determination in Electron file system module by removing legacy case for 'bundle.api.js'. This simplifies the path management for the Electron app. 2026-03-05 21:55:49 +00:00
Matiss Janis Aboltins
e9d9226a81 Enhance migration handling by allowing both .sql and .js files to be copied during the migration process. Refactor file system operations in loot-core to improve error handling and streamline file management, including new methods for reading, writing, and removing files and directories. 2026-03-05 21:52:23 +00:00
Matiss Janis Aboltins
0d9d45e6fe Enhance inline-loot-core-types script to improve TypeScript declaration handling by separating source and typings directories. Update the copy process to include emitted typings, ensuring no declarations are dropped and maintaining better organization of loot-core types. 2026-03-05 21:13:12 +00:00
Matiss Janis Aboltins
6f91c4cede Update Vite configuration in API package to target Node 18, enhancing compatibility with the latest Node features. 2026-03-04 23:37:20 +00:00
Matiss Janis Aboltins
6a042b0c62 Add internal export to API and enhance Vite configuration for migration handling 2026-03-04 23:33:07 +00:00
Matiss Janis Aboltins
da1ab9e85d Refactor inline-loot-core-types script to streamline TypeScript declaration handling and improve output organization. Remove legacy code and directly copy loot-core declaration tree, updating index.d.ts to reference local imports. 2026-03-04 23:29:39 +00:00
Matiss Janis Aboltins
f1dc0b4a6e Update yarn.lock and API package to enhance TypeScript build process and add new dependencies 2026-03-04 22:22:06 +00:00
Matiss Janis Aboltins
a41d0b3323 Update TypeScript configuration in API package to use ES2022 module and bundler resolution. This change enhances compatibility with modern JavaScript features and improves the build process. 2026-03-04 21:13:40 +00:00
Matiss Janis Aboltins
6ce931ca20 Merge branch 'master' into matiss/fix-6804 2026-03-04 21:08:35 +00:00
Matiss Janis Aboltins
01c94453b7 Merge branch 'master' into matiss/fix-6804 2026-03-04 18:55:49 +00:00
Matiss Janis Aboltins
ac86c89851 Revert to solution without types 2026-02-22 21:34:08 +00:00
Matiss Janis Aboltins
1cb862c74f Refactor api package configuration to update type declaration paths and enhance build process. Changed type definitions reference in package.json, streamlined tsconfig.json exclusions, and added functionality to copy inlined types during the build. Removed obsolete vitest setup file for improved test isolation. 2026-02-22 21:14:36 +00:00
Matiss Janis Aboltins
b4cc0baef5 Update TypeScript configurations in desktop-client and desktop-electron packages to enable noEmit option, allowing for output file generation during compilation. Additionally, add ts-strict-ignore comments in YNAB importers to suppress strict type checking, improving compatibility with embedded API usage. 2026-02-22 15:49:55 +00:00
Matiss Janis Aboltins
34273d4faa Update api package configuration to streamline build process and enhance type safety. Removed unnecessary build scripts, integrated vite-plugin-dts for type declaration generation, and added migration and default database copying functionality. Adjusted vitest setup to comment out CRDT proto file import for improved test isolation. 2026-02-22 15:43:52 +00:00
Matiss Janis Aboltins
dd7521d416 Update TypeScript configuration in sync-server package to enable noEmit option. This change allows for the generation of output files during compilation, facilitating the build process. 2026-02-22 15:32:48 +00:00
Matiss Janis Aboltins
134d546716 Update TypeScript configurations across multiple packages to enable noEmit option. This change enhances build processes by preventing unnecessary output files during compilation. Additionally, remove the obsolete tsconfig.api.json file from loot-core to streamline project structure. 2026-02-22 15:29:17 +00:00
Matiss Janis Aboltins
f096cb01b8 Update TypeScript configuration in api package to reposition the typescript-strict-plugin entry. This change improves the organization of the tsconfig.json file while maintaining the existing path mapping for loot-core, ensuring consistent type checking across the project. 2026-02-22 15:17:44 +00:00
Matiss Janis Aboltins
6488ca1ca9 Update TypeScript configuration in api package to include path mapping for loot-core. This change enhances module resolution and improves type safety by allowing direct imports from the loot-core source directory. 2026-02-22 15:14:51 +00:00
Matiss Janis Aboltins
40a74197db Refactor schedule configuration in loot-core to enhance type safety by introducing a new ScheduleRuleOptions type. This change improves the clarity of the recurring schedule configuration and ensures better type checking for frequency and interval properties. 2026-02-22 15:10:16 +00:00
Matiss Janis Aboltins
ab972b7e36 Refactor handler invocation in YNAB importers to use the new send function from main-app. This change improves code consistency and readability by standardizing the method of invoking handlers across different modules. 2026-02-22 15:06:38 +00:00
Matiss Janis Aboltins
73739eb91a Refactor imports and enhance code readability across multiple files in loot-core. Simplified import statements in the API and adjusted formatting in YNAB importers for consistency. Updated type annotations to improve type safety and maintainability. 2026-02-22 14:54:33 +00:00
Matiss Janis Aboltins
cd7663ecfd Refactor API integration in loot-core by removing api-helpers and directly invoking handlers. Update typecheck script in api package to include strict checks, and refine TypeScript configurations across multiple packages for improved type safety and build processes. 2026-02-21 22:22:40 +00:00
Matiss Janis Aboltins
0619198f50 Refactor typecheck script in api package and enhance api-helpers with new schedule and rule update functions. The typecheck command was simplified by removing the strict check, and new API methods for creating schedules and updating rules were added to improve functionality. 2026-02-21 22:12:26 +00:00
Matiss Janis Aboltins
7098a5fad7 Merge remote-tracking branch 'origin' into matiss/fix-6804 2026-02-21 22:02:01 +00:00
Matiss Janis Aboltins
4ff3b35168 feat(api): enhance build scripts and add file system utilities
- Update build scripts in package.json to include separate commands for building node, migrations, and default database.
- Introduce a new file system utility module in loot-core to handle file operations such as reading, writing, and directory management.
- Implement error handling and logging for file operations to improve robustness.
2026-02-03 09:42:19 +00:00
Matiss Janis Aboltins
6f9dab9aab refactor(api): streamline Vite configuration and remove vitest.config.ts
- Remove vitest.config.ts as its configuration is now integrated into vite.config.ts.
- Update vite.config.ts to include sourcemap generation and adjust CRDT path resolution.
- Modify vitest.setup.ts to correct the import path for the CRDT proto file.
2026-01-28 23:06:30 +00:00
Matiss Janis Aboltins
6d1cc5cd23 fix(api): update visualizer output path in vite configuration
- Change the output filename for the visualizer plugin from 'dist/stats.json' to 'app/stats.json' to align with the new directory structure.
2026-01-28 23:03:03 +00:00
Matiss Janis Aboltins
549f269511 chore(api): update dependencies and build configuration
- Replace tsc-alias with rollup-plugin-visualizer in package.json.
- Update build script to use vite for building the API package.
- Add vite configuration file for improved build process and visualization.
- Adjust tsconfig.dist.json to exclude additional configuration files from the build.
2026-01-28 23:01:46 +00:00
Matiss Janis Aboltins
68c0b35d05 refactor(api): update package structure and build scripts
- Change main entry point and types definition paths in package.json to reflect new structure.
- Simplify build script by removing migration and default database copy commands.
- Adjust tsconfig.dist.json to maintain declaration directory.
- Add typings for external modules in a new typings.ts file.
- Update comments in schedules.ts to improve clarity and maintainability.
2026-01-28 22:41:15 +00:00
Matiss Janis Aboltins
ae763af100 refactor(api): defineConfig vitest, api-helpers, drop vite.api build
- Wrap api vitest.config with defineConfig for typing/IDE
- Add loot-core api-helpers, use in YNAB4/YNAB5 importers
- Remove vite.api.config, build-api, injected.js; simplify api package
2026-01-28 22:06:18 +00:00
115 changed files with 1066 additions and 2814 deletions

View File

@@ -1,74 +0,0 @@
---
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

@@ -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`.
import { spawnSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const { spawnSync } = require('child_process');
const path = require('path');
const migrationsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
__dirname,
'..',
'..',
'packages',
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
'migrations',
);
function readMigrations(ref: string) {
function readMigrations(ref) {
const { stdout } = spawnSync('git', [
'ls-tree',
'--name-only',

View File

@@ -1,70 +0,0 @@
# 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

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

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 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --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

@@ -44,9 +44,25 @@ yarn start:desktop
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
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.
- **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.**
### Task Orchestration with Lage
@@ -298,7 +314,6 @@ 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
@@ -345,7 +360,13 @@ Always maintain newlines between import groups.
**Git Commands:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
- **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
## File Structure Patterns
@@ -544,7 +565,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -557,7 +578,17 @@ Before committing changes, ensure:
## Pull Request Guidelines
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.
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.
## Code Review Guidelines

View File

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

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": "yarn workspace @actual-app/api clean && oxfmt --check . && oxlint --type-aware",
"lint:fix": "yarn workspace @actual-app/api clean && oxfmt . && oxlint --fix --type-aware",
"lint": "oxfmt --check . && oxlint --type-aware",
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "yarn workspace @actual-app/api clean && tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},

View File

@@ -3,26 +3,17 @@ import type {
RequestInit as FetchInit,
} from 'node-fetch';
// loot-core types
import { init as initLootCore, lib } from 'loot-core/server/main';
import type { InitConfig } from 'loot-core/server/main';
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import * as injected from './injected';
import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
export * from './methods';
export * as utils from './utils';
export async function init(config: InitConfig = {}) {
if (actualApp) {
return;
}
export const internal = lib;
export async function init(config: InitConfig = {}) {
validateNodeVersion();
if (!globalThis.fetch) {
@@ -33,21 +24,15 @@ export async function init(config: InitConfig = {}) {
};
}
await bundle.init(config);
actualApp = bundle.lib;
injected.override(bundle.lib.send);
return bundle.lib;
return initLootCore(config);
}
export async function shutdown() {
if (actualApp) {
try {
await actualApp.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await actualApp.send('close-budget');
actualApp = null;
try {
await lib.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await lib.send('close-budget');
}

View File

@@ -1,7 +0,0 @@
// TODO: comment on why it works this way
export let send;
export function override(sendImplementation) {
send = sendImplementation;
}

View File

@@ -1,10 +1,29 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from 'loot-core/types/models';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;

View File

@@ -7,6 +7,7 @@ import type {
APIScheduleEntity,
APITagEntity,
} from 'loot-core/server/api-models';
import { lib } from 'loot-core/server/main';
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';
@@ -16,15 +17,13 @@ import type {
TransactionEntity,
} from 'loot-core/types/models';
import * as injected from './injected';
export { q } from './app/query';
function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return injected.send(name, args);
return lib.send(name, args);
}
export async function runImport(

View File

@@ -10,27 +10,25 @@
"main": "dist/index.js",
"types": "@types/index.d.ts",
"scripts": {
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"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",
"clean": "rm -rf dist @types",
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
"build": "vite build && yarn workspace loot-core exec tsc && node scripts/inline-loot-core-types.mjs",
"test": "vitest --run",
"typecheck": "tsc --noEmit && tsc-strict"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1",
"loot-core": "workspace:^",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"rollup-plugin-visualizer": "^6.0.5",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.0.18"
},
"engines": {

View File

@@ -0,0 +1,60 @@
/**
* Post-build script: copies loot-core declaration tree into @types/loot-core
* and rewrites index.d.ts to reference it so the published package is self-contained.
* Run after vite build; requires loot-core declarations (yarn workspace loot-core exec tsc).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(__dirname, '..');
const typesDir = path.join(apiRoot, '@types');
const indexDts = path.join(typesDir, 'index.d.ts');
const lootCoreDeclRoot = path.resolve(apiRoot, '../loot-core/lib-dist/decl');
const lootCoreDeclSrc = path.join(lootCoreDeclRoot, 'src');
const lootCoreDeclTypings = path.join(lootCoreDeclRoot, 'typings');
const lootCoreTypesDir = path.join(typesDir, 'loot-core');
function main() {
if (!fs.existsSync(indexDts)) {
console.error('Missing @types/index.d.ts; run vite build first.');
process.exit(1);
}
if (!fs.existsSync(lootCoreDeclSrc)) {
console.error(
'Missing loot-core declarations; run: yarn workspace loot-core exec tsc',
);
process.exit(1);
}
// Remove existing loot-core output (dir or legacy single file)
if (fs.existsSync(lootCoreTypesDir)) {
fs.rmSync(lootCoreTypesDir, { recursive: true });
}
const legacyDts = path.join(typesDir, 'loot-core.d.ts');
if (fs.existsSync(legacyDts)) {
fs.rmSync(legacyDts);
}
// Copy declaration tree: src (main exports) plus emitted typings so no declarations are dropped
fs.cpSync(lootCoreDeclSrc, lootCoreTypesDir, { recursive: true });
if (fs.existsSync(lootCoreDeclTypings)) {
fs.cpSync(lootCoreDeclTypings, path.join(lootCoreTypesDir, 'typings'), {
recursive: true,
});
}
// Rewrite index.d.ts: remove reference, point imports at local ./loot-core/
let indexContent = fs.readFileSync(indexDts, 'utf8');
indexContent = indexContent.replace(
/\/\/\/ <reference path="\.\/loot-core\.d\.ts" \/>\n?/,
'',
);
indexContent = indexContent
.replace(/'loot-core\//g, "'./loot-core/")
.replace(/"loot-core\//g, '"./loot-core/');
fs.writeFileSync(indexDts, indexContent, 'utf8');
}
main();

View File

@@ -5,8 +5,8 @@
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node10",
"module": "es2022",
"moduleResolution": "bundler",
"noEmit": false,
"declaration": true,
"declarationMap": true,
@@ -14,13 +14,9 @@
"rootDir": ".",
"declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
// TEMPORARY
"loot-core/*": ["../loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
}

2
packages/api/typings.ts Normal file
View File

@@ -0,0 +1,2 @@
declare module 'hyperformula/i18n/languages/enUS';
declare module '*.pegjs';

View File

@@ -1,6 +1,4 @@
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import { lib } from 'loot-core/server/main';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;
export const amountToInteger = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;

View File

@@ -0,0 +1,99 @@
import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import peggyLoader from 'vite-plugin-peggy-loader';
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const distDir = path.resolve(__dirname, 'dist');
const typesDir = path.resolve(__dirname, '@types');
function cleanOutputDirs() {
return {
name: 'clean-output-dirs',
buildStart() {
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
},
};
}
function copyMigrationsAndDefaultDb() {
return {
name: 'copy-migrations-and-default-db',
closeBundle() {
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
if (!fs.existsSync(migrationsSrc)) {
throw new Error(`migrations directory not found at ${migrationsSrc}`);
}
const migrationsStat = fs.statSync(migrationsSrc);
if (!migrationsStat.isDirectory()) {
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
}
const migrationsDest = path.join(distDir, 'migrations');
fs.mkdirSync(migrationsDest, { recursive: true });
for (const name of fs.readdirSync(migrationsSrc)) {
if (name.endsWith('.sql') || name.endsWith('.js')) {
fs.copyFileSync(
path.join(migrationsSrc, name),
path.join(migrationsDest, name),
);
}
}
if (!fs.existsSync(defaultDbPath)) {
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
}
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
},
};
}
export default defineConfig({
ssr: { noExternal: true, external: ['better-sqlite3'] },
build: {
ssr: true,
target: 'node20',
outDir: distDir,
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
},
plugins: [
cleanOutputDirs(),
peggyLoader(),
dts({
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
outDir: path.resolve(__dirname, '@types'),
rollupTypes: true,
}),
copyMigrationsAndDefaultDb(),
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
],
resolve: {
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(__dirname, '../crdt/src') + '$1',
},
],
},
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

@@ -1,10 +0,0 @@
export default {
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
};

View File

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

View File

@@ -1,15 +0,0 @@
{
"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

@@ -34,7 +34,6 @@ 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';
@@ -180,11 +179,6 @@ 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()) {
@@ -192,9 +186,25 @@ 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);
return () => window.removeEventListener('focus', checkScrollbars);
}, [hiddenScrollbars]);
window.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.removeEventListener('focus', checkScrollbars);
window.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [dispatch, hiddenScrollbars]);
const [theme] = useTheme();

View File

@@ -32,7 +32,6 @@ 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';
@@ -92,7 +91,10 @@ export function FinancesApp() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: accounts, isFetching: isAccountsFetching } = useAccounts();
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results.
const { data: accounts, isFetching: isAccountsFetching } = useQuery(
accountQueries.list(),
);
const versionInfo = useSelector(state => state.app.versionInfo);
const [notifyWhenUpdateIsAvailable] = useGlobalPref(

View File

@@ -12,7 +12,6 @@ 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';
@@ -111,16 +110,6 @@ 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

@@ -1,27 +1,20 @@
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,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import type { AccountEntity, 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 }),
@@ -48,30 +41,7 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
};
}
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,
) {
function extractPayeesAndHeaderNames(screen: Screen) {
const autocompleteElement = screen.getByTestId('autocomplete');
// Get all elements that match either selector, but query them separately
@@ -79,7 +49,7 @@ function extractPayeesAndHeaderNames(
const headers = [
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
];
const items = [...autocompleteElement.querySelectorAll(itemSelector)];
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
// Combine all elements and sort by their position in the DOM
const allElements = [...headers, ...items];
@@ -108,52 +78,14 @@ 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, []);
});
@@ -275,108 +207,6 @@ 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, useCallback, useMemo, useState } from 'react';
import React, { Fragment, useMemo, useState } from 'react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
@@ -13,24 +13,15 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import {
SvgAdd,
SvgBookmark,
SvgLocation,
} from '@actual-app/components/icons/v1';
import { SvgAdd, SvgBookmark } 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,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import {
Autocomplete,
@@ -41,19 +32,13 @@ 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 & {
nearbyLocationId?: string;
distance?: number;
};
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
const MAX_AUTO_SUGGESTIONS = 5;
@@ -145,25 +130,17 @@ type PayeeListProps = {
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode;
footer: ReactNode;
onForgetLocation?: (locationId: string) => void;
};
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
type ItemTypes = 'account' | 'payee' | 'common_payee';
type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(
item: PayeeEntity,
isCommon: boolean,
isNearby: boolean = false,
): ItemTypes {
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isNearby) {
return 'nearby_payee';
}
if (isCommon) {
return 'common_payee';
} else {
@@ -181,7 +158,6 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem,
footer,
onForgetLocation,
}: PayeeListProps) {
const { t } = useTranslation();
@@ -189,66 +165,56 @@ function PayeeList({
// with the value of the input so it always shows whatever the user
// entered
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>,
},
);
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>,
},
);
// 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 nearbyPayeesWithIndex = result.nearbyPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
nearbyPayees: nearbyPayeesWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
return {
newPayee: newPayeeWithIndex,
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.
@@ -271,20 +237,6 @@ 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 => (
@@ -372,7 +324,6 @@ export type PayeeAutocompleteProps = ComponentProps<
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeEntity[];
nearbyPayees?: NearbyPayeeEntity[];
};
export function PayeeAutocomplete({
@@ -392,22 +343,16 @@ 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) {
@@ -447,43 +392,6 @@ 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);
@@ -572,12 +480,6 @@ 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') {
@@ -589,7 +491,7 @@ export function PayeeAutocomplete({
filterSuggestions={filterSuggestions}
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={[...filteredNearbyPayees, ...items]}
items={items}
commonPayees={commonPayees}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -619,7 +521,6 @@ export function PayeeAutocomplete({
)}
</AutocompleteFooter>
}
onForgetLocation={handleForgetLocation}
/>
)}
{...props}
@@ -797,126 +698,3 @@ 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

@@ -313,7 +313,7 @@ export function ConfigServer() {
switch (error) {
case 'network-failure':
return t(
'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.',
'Server is not running at this URL. Make sure you have HTTPS set up properly.',
);
default:
return t(

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ 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';
@@ -32,8 +31,6 @@ 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';
@@ -82,9 +79,7 @@ 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,
@@ -93,8 +88,6 @@ 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';
@@ -561,10 +554,6 @@ 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>(
@@ -580,10 +569,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
onDelete,
onSplit,
onAddSplit,
shouldShowSaveLocation,
onSaveLocation,
onSelectNearestPayee,
nearestPayee,
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -1105,56 +1090,6 @@ 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>
@@ -1377,7 +1312,6 @@ 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<
@@ -1399,11 +1333,6 @@ 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;
@@ -1441,12 +1370,6 @@ function TransactionEditUnconnected({
};
}, [transactionId]);
useEffect(() => {
if (!locationAccess) {
setShouldShowSaveLocation(false);
}
}, [locationAccess]);
useEffect(() => {
if (isAdding.current) {
setTransactions([
@@ -1507,15 +1430,11 @@ 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 ||
updatedField === 'payee'
newTransaction[field] === false
) {
(newTransaction as Record<string, unknown>)[field] = diff[field];
}
@@ -1544,33 +1463,8 @@ 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, locationAccess],
[dateFormat, transactions],
);
const onSave = useCallback(
@@ -1650,39 +1544,6 @@ 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
@@ -1808,10 +1669,6 @@ function TransactionEditUnconnected({
onDelete={onDelete}
onSplit={onSplit}
onAddSplit={onAddSplit}
shouldShowSaveLocation={shouldShowSaveLocation}
onSaveLocation={onSaveLocation}
onSelectNearestPayee={onSelectNearestPayee}
nearestPayee={locationAccess ? nearestPayee : null}
/>
</View>
);

View File

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

View File

@@ -49,7 +49,6 @@ export function FormulaResult({
containerRef,
}: FormulaResultProps) {
const [fontSize, setFontSize] = useState<number>(initialFontSize);
const [hasSized, setHasSized] = useState(false);
const refDiv = useRef<HTMLDivElement>(null);
const previousFontSizeRef = useRef<number>(initialFontSize);
const format = useFormat();
@@ -90,10 +89,7 @@ export function FormulaResult({
height, // Ensure the text fits vertically by using the height as the limiting factor
);
if (calculatedFontSize > 0) {
setFontSize(calculatedFontSize);
setHasSized(true);
}
setFontSize(calculatedFontSize);
// Only call fontSizeChanged if the font size actually changed
if (
@@ -147,7 +143,6 @@ export function FormulaResult({
useEffect(() => {
if (fontSizeMode === 'static') {
setFontSize(staticFontSize);
setHasSized(true);
}
}, [fontSizeMode, staticFontSize]);
@@ -158,8 +153,6 @@ export function FormulaResult({
? theme.errorText
: theme.pageText;
const showContent = hasSized || fontSizeMode === 'static';
return (
<View style={{ flex: 1 }}>
{loading && <LoadingIndicator />}
@@ -182,13 +175,9 @@ export function FormulaResult({
color,
}}
>
{!showContent ? (
<LoadingIndicator />
) : (
<span aria-hidden="true">
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
)}
<span aria-hidden="true">
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
</View>
)}
</View>

View File

@@ -38,7 +38,6 @@ export function SummaryNumber({
}: SummaryNumberProps) {
const { t } = useTranslation();
const [fontSize, setFontSize] = useState<number>(initialFontSize);
const [hasSized, setHasSized] = useState(false);
const refDiv = useRef<HTMLDivElement>(null);
const format = useFormat();
const isNumericValue = Number.isFinite(value);
@@ -62,10 +61,7 @@ export function SummaryNumber({
height, // Ensure the text fits vertically by using the height as the limiting factor
);
if (calculatedFontSize > 0) {
setFontSize(calculatedFontSize);
setHasSized(true);
}
setFontSize(calculatedFontSize);
if (calculatedFontSize !== initialFontSize && fontSizeChanged) {
fontSizeChanged(calculatedFontSize);
@@ -111,13 +107,9 @@ export function SummaryNumber({
: theme.reportsNumberPositive,
}}
>
{!hasSized ? (
<LoadingIndicator />
) : (
<FinancialText aria-hidden="true">
<PrivacyFilter>{displayAmount}</PrivacyFilter>
</FinancialText>
)}
<FinancialText aria-hidden="true">
<PrivacyFilter>{displayAmount}</PrivacyFilter>
</FinancialText>
</View>
)}
</>

View File

@@ -18,8 +18,6 @@ import {
import * as monthUtils from 'loot-core/shared/months';
import { computePadding } from './util/computePadding';
import { FinancialText } from '@desktop-client/components/FinancialText';
import { Container } from '@desktop-client/components/reports/Container';
import { useFormat } from '@desktop-client/hooks/useFormat';
@@ -222,22 +220,11 @@ export function BudgetAnalysisGraph({
return monthUtils.format(date, 'MMM d', locale);
};
const allValues = graphData.flatMap(item => [
item.budgeted,
item.spent,
item.balance,
item.overspendingAdjustment,
]);
const leftPadding = computePadding(allValues, value =>
format(value, 'financial-no-decimals'),
);
const commonProps = {
width: 0,
height: 0,
data: graphData,
margin: { top: 5, right: 5, left: 5 + leftPadding, bottom: 5 },
margin: { top: 5, right: 5, left: 5, bottom: 5 },
};
return (

View File

@@ -215,12 +215,6 @@ export function ExperimentalFeatures() {
>
<Trans>Budget Analysis Report</Trans>
</FeatureToggle>
<FeatureToggle
flag="payeeLocations"
feedbackLink="https://github.com/actualbudget/actual/issues/6706"
>
<Trans>Payee Locations</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"

View File

@@ -246,9 +246,7 @@ export function ThemeInstaller({
return null;
}
const catalogItems = [...(catalog ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
);
const catalogItems = catalog ?? [];
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {

View File

@@ -28,9 +28,9 @@ export function Accounts() {
const { data: accounts = [] } = useAccounts();
const failedAccounts = useFailedAccounts();
const updatedAccounts = useUpdatedAccounts();
const { data: offbudgetAccounts = [] } = useOffBudgetAccounts();
const { data: onBudgetAccounts = [] } = useOnBudgetAccounts();
const { data: closedAccounts = [] } = useClosedAccounts();
const offbudgetAccounts = useOffBudgetAccounts();
const onBudgetAccounts = useOnBudgetAccounts();
const closedAccounts = useClosedAccounts();
const syncingAccountIds = useSelector(state => state.account.accountsSyncing);
const getAccountPath = (account: AccountEntity) => `/accounts/${account.id}`;

View File

@@ -1 +0,0 @@
import 'loot-core/typings/window';

View File

@@ -3,5 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { accountQueries } from '@desktop-client/accounts';
export function useClosedAccounts() {
return useQuery(accountQueries.listClosed());
const query = useQuery(accountQueries.listClosed());
// TODO: Update to return query states (e.g. isFetching, isError, etc)
// so clients can handle loading and error states appropriately.
return query.data ?? [];
}

View File

@@ -11,7 +11,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
crossoverReport: false,
customThemes: false,
budgetAnalysisReport: false,
payeeLocations: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -1,99 +0,0 @@
import { useEffect, useState } from 'react';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { useFeatureFlag } from './useFeatureFlag';
import { locationService } from '@desktop-client/payees/location';
/**
* Custom hook to manage geolocation permission status
* Currently behind the payeeLocations feature flag
*
* @returns boolean indicating whether geolocation access is granted
*/
export function useLocationPermission(): boolean {
const payeeLocationsEnabled = useFeatureFlag('payeeLocations');
const { isNarrowWidth } = useResponsive();
const [locationAccess, setLocationAccess] = useState(false);
useEffect(() => {
if (!payeeLocationsEnabled || !isNarrowWidth) {
setLocationAccess(false);
return;
}
let permissionStatus: PermissionStatus | null = null;
let handleChange: (() => void) | null = null;
let isMounted = true;
// Check if Permissions API is available
if (
!navigator.permissions ||
typeof navigator.permissions.query !== 'function'
) {
setLocationAccess(false);
return;
}
try {
navigator.permissions
.query({ name: 'geolocation' })
.then(status => {
if (!isMounted) {
return;
}
permissionStatus = status;
// Set initial state
setLocationAccess(status.state === 'granted');
// Listen for permission changes
handleChange = () => {
setLocationAccess(status.state === 'granted');
};
status.addEventListener('change', handleChange);
if (status.state === 'prompt') {
locationService
.getCurrentPosition()
.then(() => {
if (isMounted) {
setLocationAccess(true);
}
})
.catch(() => {
if (isMounted) {
setLocationAccess(false);
}
});
}
})
.catch(() => {
if (!isMounted) {
return;
}
// Permission API not supported, assume no access
setLocationAccess(false);
});
} catch {
if (!isMounted) {
return;
}
// Synchronous error (e.g., TypeError), assume no access
setLocationAccess(false);
}
// Cleanup function
return () => {
isMounted = false;
if (permissionStatus && handleChange) {
permissionStatus.removeEventListener('change', handleChange);
}
};
}, [payeeLocationsEnabled, isNarrowWidth]);
return locationAccess;
}

View File

@@ -1,14 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useLocationPermission } from './useLocationPermission';
import { payeeQueries } from '@desktop-client/payees';
export function useNearbyPayees() {
const locationAccess = useLocationPermission();
return useQuery({
...payeeQueries.listNearby(),
enabled: !!locationAccess,
});
}

View File

@@ -3,5 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import { accountQueries } from '@desktop-client/accounts';
export function useOffBudgetAccounts() {
return useQuery(accountQueries.listOffBudget());
const query = useQuery(accountQueries.listOffBudget());
return query.data ?? [];
}

View File

@@ -3,5 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import { accountQueries } from '@desktop-client/accounts';
export function useOnBudgetAccounts() {
return useQuery(accountQueries.listOnBudget());
const query = useQuery(accountQueries.listOnBudget());
return query.data ?? [];
}

View File

@@ -1,108 +0,0 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useOnVisible } from './useOnVisible';
function setVisibilityState(value: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', {
value,
configurable: true,
writable: true,
});
}
function dispatchVisibilityChange() {
document.dispatchEvent(new Event('visibilitychange'));
}
describe('useOnVisible', () => {
const originalVisibilityState = document.visibilityState;
beforeEach(() => {
setVisibilityState('visible');
});
afterEach(() => {
setVisibilityState(originalVisibilityState);
vi.clearAllMocks();
});
it('invokes callback when document becomes visible', () => {
const callback = vi.fn();
renderHook(() => useOnVisible(callback));
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not invoke callback when visibilityState is hidden', () => {
const callback = vi.fn();
renderHook(() => useOnVisible(callback));
setVisibilityState('hidden');
dispatchVisibilityChange();
expect(callback).not.toHaveBeenCalled();
});
it('does not attach listener when isEnabled is false', () => {
const callback = vi.fn();
renderHook(() => useOnVisible(callback, { isEnabled: false }));
dispatchVisibilityChange();
expect(callback).not.toHaveBeenCalled();
});
it('stops invoking callback after unmount', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => useOnVisible(callback));
unmount();
dispatchVisibilityChange();
expect(callback).not.toHaveBeenCalled();
});
it('invokes callback on every visibilitychange when visibilityState is visible', async () => {
const callback = vi.fn();
renderHook(() => useOnVisible(callback));
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(1);
await Promise.resolve();
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(2);
});
it('does not invoke callback again until previous async callback completes', async () => {
let resolve: () => void;
const callback = vi.fn().mockImplementation(
() =>
new Promise<void>(r => {
resolve = r;
}),
);
renderHook(() => useOnVisible(callback));
dispatchVisibilityChange();
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(1);
resolve();
await Promise.resolve();
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(2);
});
it('invokes callback when isEnabled is true by default', () => {
const callback = vi.fn();
renderHook(() => useOnVisible(callback));
dispatchVisibilityChange();
expect(callback).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,47 +0,0 @@
import { useEffect, useEffectEvent, useRef } from 'react';
type UseOnVisibleOptions = {
/** When false, the visibility listener is not attached. Default true. */
isEnabled?: boolean;
};
/**
* Runs the given callback when the document becomes visible (e.g. user
* switches back to the tab). Uses a guard so the callback is not invoked
* again until the previous invocation has finished (handles async callbacks).
*/
export function useOnVisible(
callback: () => void | Promise<void>,
options: UseOnVisibleOptions = {},
) {
const { isEnabled = true } = options;
const inProgress = useRef(false);
const runCallback = useEffectEvent(async () => {
if (inProgress.current) {
return;
}
inProgress.current = true;
try {
await callback();
} finally {
inProgress.current = false;
}
});
useEffect(() => {
if (!isEnabled) {
return;
}
function onVisibilityChange() {
if (document.visibilityState !== 'visible') {
return;
}
void runCallback();
}
document.addEventListener('visibilitychange', onVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', onVisibilityChange);
}, [isEnabled]);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { send } from 'loot-core/platform/client/connection';
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
@@ -47,9 +47,18 @@ export function usePreviewTransactions({
} = useCachedSchedules();
const [isLoading, setIsLoading] = useState(isSchedulesLoading);
const [error, setError] = useState<Error | undefined>(undefined);
const [runningBalances, setRunningBalances] = useState<
Map<TransactionEntity['id'], IntegerAmount>
>(new Map());
const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength');
// We don't want to re-render if options changes.
// Putting options in a ref will prevent that and
// allow us to use the latest options on next render.
const optionsRef = useRef(options);
optionsRef.current = options;
const scheduleTransactions = useMemo(() => {
if (isSchedulesLoading) {
return [];
@@ -100,6 +109,21 @@ export function usePreviewTransactions({
const ungroupedTransactions = ungroupTransactions(withDefaults);
setPreviewTransactions(ungroupedTransactions);
if (optionsRef.current?.calculateRunningBalances) {
setRunningBalances(
// We always use the bottom up calculation for preview transactions
// because the hook controls the order of the transactions. We don't
// need to provide a custom way for consumers to calculate the running
// balances, at least as of writing.
calculateRunningBalancesBottomUp(
ungroupedTransactions,
// Preview transactions are behaves like 'all' splits
'all',
optionsRef.current?.startingBalance,
),
);
}
setIsLoading(false);
}
})
@@ -115,24 +139,6 @@ export function usePreviewTransactions({
};
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
const runningBalances = useMemo(() => {
if (!options?.calculateRunningBalances) {
return new Map<TransactionEntity['id'], IntegerAmount>();
}
// We always use the bottom up calculation for preview transactions
// because the hook controls the order of the transactions.
return calculateRunningBalancesBottomUp(
previewTransactions,
'all',
options?.startingBalance,
);
}, [
previewTransactions,
options?.calculateRunningBalances,
options?.startingBalance,
]);
const returnError = error || scheduleQueryError;
return {
previewTransactions,

View File

@@ -1,97 +0,0 @@
import { send } from 'loot-core/platform/client/connection';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
/**
* Abstraction for geolocation functionality
*/
export type GeolocationAdapter = {
getCurrentPosition(options?: PositionOptions): Promise<LocationCoordinates>;
};
/**
* Abstraction for location-related API calls
*/
export type LocationApiClient = {
saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string>;
getLocations(payeeId: string): Promise<PayeeLocationEntity[]>;
deleteLocation(locationId: string): Promise<void>;
getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]>;
};
/**
* Browser implementation of geolocation using the Web Geolocation API
*/
export class BrowserGeolocationAdapter implements GeolocationAdapter {
async getCurrentPosition(
options: PositionOptions = {},
): Promise<LocationCoordinates> {
if (!navigator.geolocation) {
throw new Error('Geolocation is not supported by this browser');
}
const defaultOptions: PositionOptions = {
enableHighAccuracy: true,
timeout: 15000, // 15 second timeout
maximumAge: 60000, // Accept 1-minute-old cached position
};
const position = await new Promise<GeolocationPosition>(
(resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
...defaultOptions,
...options,
});
},
);
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
}
}
/**
* Implementation using the existing send function for API calls
*/
export class SendApiLocationClient implements LocationApiClient {
async saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
return await send('payee-location-create', {
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
});
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
return await send('payee-locations-get', { payeeId });
}
async deleteLocation(locationId: string): Promise<void> {
await send('payee-location-delete', { id: locationId });
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]> {
const result = await send('payees-get-nearby', {
latitude: coordinates.latitude,
longitude: coordinates.longitude,
maxDistance,
});
return result || [];
}
}

View File

@@ -1,206 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
import type {
GeolocationAdapter,
LocationApiClient,
} from './location-adapters';
import { LocationService } from './location-service';
// Clean test implementations - no complex mocking needed
class TestGeolocationAdapter implements GeolocationAdapter {
private callCount = 0;
constructor(
private mockPosition: LocationCoordinates,
private shouldThrow = false,
) {}
async getCurrentPosition(): Promise<LocationCoordinates> {
this.callCount++;
if (this.shouldThrow) {
throw new Error('Geolocation denied');
}
return { ...this.mockPosition }; // Return copy to avoid mutation
}
getCallCount(): number {
return this.callCount;
}
}
class TestApiClient implements LocationApiClient {
public saveLocationCalls: Array<{
payeeId: string;
coordinates: LocationCoordinates;
}> = [];
public deleteLocationCalls: string[] = [];
public getLocationsCalls: string[] = [];
constructor(
private mockLocations: PayeeLocationEntity[] = [],
private mockNearbyPayees: NearbyPayeeEntity[] = [],
private mockLocationId = 'test-location-id',
private shouldThrowOnSave = false,
) {}
async saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
this.saveLocationCalls.push({ payeeId, coordinates });
if (this.shouldThrowOnSave) {
throw new Error('Save failed');
}
return this.mockLocationId;
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
this.getLocationsCalls.push(payeeId);
return this.mockLocations.filter(loc => loc.payee_id === payeeId);
}
async deleteLocation(locationId: string): Promise<void> {
this.deleteLocationCalls.push(locationId);
}
async getNearbyPayees(): Promise<NearbyPayeeEntity[]> {
return this.mockNearbyPayees;
}
}
describe('LocationService Integration Tests', () => {
let testGeolocation: TestGeolocationAdapter;
let testApiClient: TestApiClient;
let locationService: LocationService;
const defaultPosition = { latitude: 40.7128, longitude: -74.006 }; // NYC
beforeEach(() => {
testGeolocation = new TestGeolocationAdapter(defaultPosition);
testApiClient = new TestApiClient();
locationService = new LocationService(testGeolocation, testApiClient);
});
describe('Position Caching', () => {
it('caches position to avoid repeated geolocation calls', async () => {
const position1 = await locationService.getCurrentPosition();
const position2 = await locationService.getCurrentPosition();
expect(position1).toEqual(defaultPosition);
expect(position2).toEqual(defaultPosition);
expect(testGeolocation.getCallCount()).toBe(1); // Only called once due to caching
});
it('refreshes position after calling reset()', async () => {
await locationService.getCurrentPosition();
expect(testGeolocation.getCallCount()).toBe(1);
locationService.reset();
await locationService.getCurrentPosition();
expect(testGeolocation.getCallCount()).toBe(2); // Called again after reset
});
});
describe('Error Handling', () => {
it('propagates geolocation errors with meaningful messages', async () => {
const errorGeolocation = new TestGeolocationAdapter(
defaultPosition,
true,
);
const serviceWithError = new LocationService(
errorGeolocation,
testApiClient,
);
await expect(serviceWithError.getCurrentPosition()).rejects.toThrow(
'Geolocation denied',
);
});
it('propagates API save errors', async () => {
const errorApiClient = new TestApiClient([], [], 'id', true);
const serviceWithError = new LocationService(
testGeolocation,
errorApiClient,
);
await expect(
serviceWithError.savePayeeLocation('payee-123', defaultPosition),
).rejects.toThrow('Save failed');
});
});
describe('API Integration', () => {
it('calls save location with correct parameters', async () => {
const payeeId = 'payee-456';
const coordinates = { latitude: 41.8781, longitude: -87.6298 }; // Chicago
const result = await locationService.savePayeeLocation(
payeeId,
coordinates,
);
expect(result).toBe('test-location-id');
expect(testApiClient.saveLocationCalls).toEqual([
{ payeeId, coordinates },
]);
});
it('retrieves payee locations correctly', async () => {
const payeeId = 'payee-789';
const mockLocations: PayeeLocationEntity[] = [
{
id: 'loc-1',
payee_id: payeeId,
latitude: 40.7128,
longitude: -74.006,
created_at: Date.now() - 1000,
},
{
id: 'loc-2',
payee_id: 'other-payee',
latitude: 40.75,
longitude: -74.0,
created_at: Date.now(),
},
];
testApiClient = new TestApiClient(mockLocations);
locationService = new LocationService(testGeolocation, testApiClient);
const result = await locationService.getPayeeLocations(payeeId);
expect(result).toHaveLength(1);
expect(result[0].payee_id).toBe(payeeId);
expect(testApiClient.getLocationsCalls).toEqual([payeeId]);
});
it('deletes location correctly', async () => {
const locationId = 'location-to-delete';
await locationService.deletePayeeLocation(locationId);
expect(testApiClient.deleteLocationCalls).toEqual([locationId]);
});
});
describe('Edge Cases', () => {
it('handles zero coordinates', async () => {
testGeolocation = new TestGeolocationAdapter({
latitude: 0,
longitude: 0,
});
locationService = new LocationService(testGeolocation, testApiClient);
const position = await locationService.getCurrentPosition();
expect(position).toEqual({ latitude: 0, longitude: 0 });
});
});
});

View File

@@ -1,92 +0,0 @@
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
import type {
GeolocationAdapter,
LocationApiClient,
} from './location-adapters';
export class LocationService {
private currentPosition: LocationCoordinates | null = null;
private lastLocationTime: number = 0;
private readonly CACHE_DURATION = 60000; // 1 minute cache
constructor(
private geolocation: GeolocationAdapter,
private apiClient: LocationApiClient,
) {}
async getCurrentPosition(): Promise<LocationCoordinates> {
// Return cached position if it's recent
if (
this.currentPosition &&
Date.now() - this.lastLocationTime < this.CACHE_DURATION
) {
return this.currentPosition;
}
try {
this.currentPosition = await this.geolocation.getCurrentPosition();
this.lastLocationTime = Date.now();
return this.currentPosition;
} catch (error) {
console.warn('Geolocation error:', error);
throw error;
}
}
async savePayeeLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
try {
return await this.apiClient.saveLocation(payeeId, coordinates);
} catch (error) {
console.error('Failed to save payee location:', error);
throw error;
}
}
async getPayeeLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
try {
return await this.apiClient.getLocations(payeeId);
} catch (error) {
console.error('Failed to get payee locations:', error);
throw error;
}
}
async deletePayeeLocation(locationId: string): Promise<void> {
try {
await this.apiClient.deleteLocation(locationId);
} catch (error) {
console.error('Failed to delete payee location:', error);
throw error;
}
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number = DEFAULT_MAX_DISTANCE_METERS,
): Promise<NearbyPayeeEntity[]> {
try {
return await this.apiClient.getNearbyPayees(coordinates, maxDistance);
} catch (error) {
console.error('Failed to get nearby payees:', error);
return [];
}
}
/**
* Reset the cached location data
* Useful for testing or when you want to force a fresh location request
*/
reset(): void {
this.currentPosition = null;
this.lastLocationTime = 0;
}
}

View File

@@ -1,10 +0,0 @@
import {
BrowserGeolocationAdapter,
SendApiLocationClient,
} from './location-adapters';
import { LocationService } from './location-service';
export const locationService = new LocationService(
new BrowserGeolocationAdapter(),
new SendApiLocationClient(),
);

View File

@@ -7,7 +7,6 @@ import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { locationService } from './location';
import { payeeQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
@@ -41,57 +40,6 @@ type CreatePayeePayload = {
name: PayeeEntity['name'];
};
export function useDeletePayeeLocationMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (locationId: string) => {
await locationService.deletePayeeLocation(locationId);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: payeeQueries.listNearby().queryKey,
});
},
onError: error => {
console.error('Error deleting payee location:', error);
dispatchErrorNotification(
dispatch,
t('There was an error forgetting the location. Please try again.'),
error,
);
},
});
}
export function useSavePayeeLocationMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (payeeId: PayeeEntity['id']) => {
const coords = await locationService.getCurrentPosition();
await locationService.savePayeeLocation(payeeId, coords);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: payeeQueries.listNearby().queryKey,
});
},
onError: error => {
console.error('Error saving payee location:', error);
dispatchErrorNotification(
dispatch,
t('There was an error saving the location. Please try again.'),
error,
);
},
});
}
export function useCreatePayeeMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();

View File

@@ -4,13 +4,7 @@ import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/connection';
import { groupById } from 'loot-core/shared/util';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import { locationService } from './location';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
@@ -60,20 +54,6 @@ export const payeeQueries = {
},
placeholderData: new Map(),
}),
listNearby: () =>
queryOptions<NearbyPayeeEntity[]>({
queryKey: [...payeeQueries.all(), 'nearby'],
queryFn: async () => {
const position = await locationService.getCurrentPosition();
return locationService.getNearbyPayees({
latitude: position.latitude,
longitude: position.longitude,
});
},
placeholderData: [],
// Manually invalidated when payee locations change
staleTime: Infinity,
}),
};
export const getActivePayees = memoizeOne(

View File

@@ -19,7 +19,13 @@
{ "path": "../loot-core" },
{ "path": "../component-library" }
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js"],
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
// TODO: remove loot-core dependency
"../../packages/loot-core/typings/window.ts"
],
"exclude": [
"node_modules",
"build",

View File

@@ -1,11 +0,0 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.." || exit 1
ROOT="$(pwd -P)"
# Emit declarations to lib-dist/decl so api package (and tsc -b) can consume them
yarn tsc -p tsconfig.json
yarn vite build --config ./vite.api.config.ts
./bin/copy-migrations ../api

View File

@@ -1,21 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS payee_locations (
id TEXT PRIMARY KEY,
payee_id TEXT,
latitude REAL,
longitude REAL,
created_at INTEGER,
tombstone INTEGER DEFAULT 0
);
-- Create index on payee_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_payee_locations_payee_id ON payee_locations (payee_id);
-- Create index on created_at for time-based queries
CREATE INDEX IF NOT EXISTS idx_payee_locations_tombstone_payee_created ON payee_locations (tombstone, payee_id, created_at);
-- Create geospatial composite index with tombstone for location-based queries
CREATE INDEX IF NOT EXISTS idx_payee_locations_geo_tombstone ON payee_locations (tombstone, latitude, longitude);
COMMIT;

View File

@@ -49,13 +49,11 @@
"./shared/*": "./src/shared/*.ts",
"./types/models": "./src/types/models/index.ts",
"./types/*": "./src/types/*.ts",
"./typings/*": "./typings/*.ts",
"./lib-dist/electron/bundle.desktop.js": "./lib-dist/electron/bundle.desktop.js"
},
"scripts": {
"build:node": "cross-env NODE_ENV=production vite build --config ./vite.desktop.config.ts",
"watch:node": "cross-env NODE_ENV=development vite build --config ./vite.desktop.config.ts --watch",
"build:api": "cross-env NODE_ENV=development ./bin/build-api",
"build:browser": "cross-env NODE_ENV=production ./bin/build-browser",
"watch:browser": "cross-env NODE_ENV=development ./bin/build-browser",
"generate:i18n": "i18next",
@@ -82,6 +80,7 @@
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"mitt": "^3.0.1",
"promise-retry": "^2.0.1",
"slash": "5.1.0",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
@@ -89,7 +88,6 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@actual-app/api": "workspace:^",
"@actual-app/crdt": "workspace:^",
"@swc/core": "^1.15.11",
"@types/adm-zip": "^0.5.7",

View File

@@ -1,2 +1,197 @@
// oxlint-disable-next-line no-restricted-imports
export * from './index.electron';
// @ts-strict-ignore
import * as fs from 'fs';
import * as path from 'path';
import promiseRetry from 'promise-retry';
import { logger } from '../log';
import type * as T from './index';
export { getDocumentDir, getBudgetDir, _setDocumentDir } from './shared';
export const init: typeof T.init = async () => {
// Nothing to do
};
export const getDataDir: typeof T.getDataDir = () => {
if (!process.env.ACTUAL_DATA_DIR) {
throw new Error('ACTUAL_DATA_DIR env variable is required');
}
return process.env.ACTUAL_DATA_DIR;
};
export const bundledDatabasePath: typeof T.bundledDatabasePath = path.join(
__dirname,
'default-db.sqlite',
);
export const migrationsPath: typeof T.migrationsPath = path.join(
__dirname,
'migrations',
);
export const demoBudgetPath: typeof T.demoBudgetPath = path.join(
__dirname,
'demo-budget',
);
export const join: typeof T.join = path.join;
export const basename: typeof T.basename = filepath => path.basename(filepath);
export const listDir: typeof T.listDir = filepath =>
new Promise((resolve, reject) => {
fs.readdir(filepath, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
export const exists: typeof T.exists = filepath =>
new Promise(resolve => {
fs.access(filepath, fs.constants.F_OK, err => {
return resolve(!err);
});
});
export const mkdir: typeof T.mkdir = filepath =>
new Promise((resolve, reject) => {
fs.mkdir(filepath, err => {
if (err) {
reject(err);
} else {
resolve(undefined);
}
});
});
export const size: typeof T.size = filepath =>
new Promise((resolve, reject) => {
fs.stat(filepath, (err, stats) => {
if (err) {
reject(err);
} else {
resolve(stats.size);
}
});
});
export const copyFile: typeof T.copyFile = (frompath, topath) => {
return new Promise<boolean>((resolve, reject) => {
const readStream = fs.createReadStream(frompath);
const writeStream = fs.createWriteStream(topath);
readStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('open', () => readStream.pipe(writeStream));
writeStream.once('close', () => resolve(true));
});
};
export const readFile: typeof T.readFile = (
filepath: string,
encoding: 'utf8' | 'binary' | null = 'utf8',
) => {
if (encoding === 'binary') {
// `binary` is not actually a valid encoding, you pass `null` into node if
// you want a buffer
encoding = null;
}
// `any` as cannot refine return with two function overrides
// oxlint-disable-next-line typescript/no-explicit-any
return new Promise<any>((resolve, reject) => {
fs.readFile(filepath, encoding, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};
export const writeFile: typeof T.writeFile = async (filepath, contents) => {
try {
await promiseRetry(
(retry, attempt) => {
return new Promise((resolve, reject) => {
fs.writeFile(filepath, contents, 'utf8', err => {
if (err) {
logger.error(
`Failed to write to ${filepath}. Attempted ${attempt} times. Something is locking the file - potentially a virus scanner or backup software.`,
);
reject(err);
} else {
if (attempt > 1) {
logger.info(
`Successfully recovered from file lock. It took ${attempt} retries`,
);
}
resolve(undefined);
}
});
}).catch(retry);
},
{
retries: 20,
minTimeout: 100,
maxTimeout: 500,
factor: 1.5,
},
);
return undefined;
} catch (err) {
logger.error(`Unable to recover from file lock on file ${filepath}`);
throw err;
}
};
export const removeFile: typeof T.removeFile = filepath => {
return new Promise(function (resolve, reject) {
fs.unlink(filepath, err => {
return err ? reject(err) : resolve(undefined);
});
});
};
export const removeDir: typeof T.removeDir = dirpath => {
return new Promise(function (resolve, reject) {
fs.rmdir(dirpath, err => {
return err ? reject(err) : resolve(undefined);
});
});
};
export const removeDirRecursively: typeof T.removeDirRecursively =
async dirpath => {
if (await exists(dirpath)) {
for (const file of await listDir(dirpath)) {
const fullpath = join(dirpath, file);
if (fs.statSync(fullpath).isDirectory()) {
await removeDirRecursively(fullpath);
} else {
await removeFile(fullpath);
}
}
await removeDir(dirpath);
}
};
export const getModifiedTime: typeof T.getModifiedTime = filepath => {
return new Promise(function (resolve, reject) {
fs.stat(filepath, (err, stats) => {
if (err) {
reject(err);
} else {
resolve(new Date(stats.mtime));
}
});
});
};

View File

@@ -13,9 +13,6 @@ export { getDocumentDir, getBudgetDir, _setDocumentDir } from './shared';
let rootPath = path.join(__dirname, '..', '..', '..', '..');
switch (path.basename(__filename)) {
case 'bundle.api.js': // api bundle uses the electron bundle - account for its file structure
rootPath = path.join(__dirname, '..');
break;
case 'bundle.desktop.js': // electron app
rootPath = path.join(__dirname, '..', '..');
break;

View File

@@ -1,136 +0,0 @@
import * as db from '../db';
import { loadMappings } from '../db/mappings';
import { app } from './app';
import * as bankSync from './sync';
vi.mock('./sync', async () => ({
...(await vi.importActual('./sync')),
simpleFinBatchSync: vi.fn(),
syncAccount: vi.fn(),
}));
const simpleFinBatchSyncHandler = app.handlers['simplefin-batch-sync'];
function insertBank(bank: { id: string; bank_id: string; name: string }) {
db.runQuery(
'INSERT INTO banks (id, bank_id, name, tombstone) VALUES (?, ?, ?, 0)',
[bank.id, bank.bank_id, bank.name],
);
}
async function setupSimpleFinAccounts(
accounts: Array<{
id: string;
name: string;
accountId: string;
}>,
) {
insertBank({ id: 'bank1', bank_id: 'sfin-bank', name: 'SimpleFin' });
for (const acct of accounts) {
await db.insertAccount({
id: acct.id,
name: acct.name,
bank: 'bank1',
account_id: acct.accountId,
account_sync_source: 'simpleFin',
});
}
}
beforeEach(async () => {
vi.resetAllMocks();
await global.emptyDatabase()();
await loadMappings();
});
describe('simpleFinBatchSync', () => {
describe('when batch sync throws an error', () => {
it('each account gets its own isolated errors array', async () => {
await setupSimpleFinAccounts([
{ id: 'acct1', name: 'Checking', accountId: 'ext-1' },
{ id: 'acct2', name: 'Savings', accountId: 'ext-2' },
{ id: 'acct3', name: 'Credit Card', accountId: 'ext-3' },
]);
vi.mocked(bankSync.simpleFinBatchSync).mockRejectedValue(
new Error('connection timeout'),
);
const result = await simpleFinBatchSyncHandler({ ids: [] });
expect(result).toHaveLength(3);
// Each account must have its own errors array (not shared references)
expect(result[0].res.errors).not.toBe(result[1].res.errors);
expect(result[1].res.errors).not.toBe(result[2].res.errors);
expect(result[0].res.errors).not.toBe(result[2].res.errors);
// Each account must have exactly 1 error, not N errors
expect(result[0].res.errors).toHaveLength(1);
expect(result[1].res.errors).toHaveLength(1);
expect(result[2].res.errors).toHaveLength(1);
});
it('each error references its own account', async () => {
await setupSimpleFinAccounts([
{ id: 'acct1', name: 'Checking', accountId: 'ext-1' },
{ id: 'acct2', name: 'Savings', accountId: 'ext-2' },
]);
vi.mocked(bankSync.simpleFinBatchSync).mockRejectedValue(
new Error('server error'),
);
const result = await simpleFinBatchSyncHandler({ ids: [] });
expect(result).toHaveLength(2);
// Each error must reference only the account it belongs to
expect(result[0].res.errors).toHaveLength(1);
expect(result[0].res.errors[0].accountId).toBe('acct1');
expect(result[1].res.errors).toHaveLength(1);
expect(result[1].res.errors[0].accountId).toBe('acct2');
});
});
describe('when individual accounts have errors in the response', () => {
it('per-account error_code only affects that account', async () => {
await setupSimpleFinAccounts([
{ id: 'acct1', name: 'Checking', accountId: 'ext-1' },
{ id: 'acct2', name: 'Savings', accountId: 'ext-2' },
]);
vi.mocked(bankSync.simpleFinBatchSync).mockResolvedValue([
{
accountId: 'acct1',
res: {
error_code: 'ITEM_ERROR',
error_type: 'Connection',
},
},
{
accountId: 'acct2',
res: {
added: [],
updated: [],
},
},
]);
const result = await simpleFinBatchSyncHandler({ ids: [] });
expect(result).toHaveLength(2);
// Account 1 should have an error
const acct1Result = result.find(r => r.accountId === 'acct1');
expect(acct1Result!.res.errors).toHaveLength(1);
expect(acct1Result!.res.errors[0].accountId).toBe('acct1');
// Account 2 should have no errors
const acct2Result = result.find(r => r.accountId === 'acct2');
expect(acct2Result!.res.errors).toHaveLength(0);
});
});
});

View File

@@ -1098,17 +1098,19 @@ async function simpleFinBatchSync({
});
}
} catch (err) {
const errors = [];
for (const account of accounts) {
const error = err as Error;
retVal.push({
accountId: account.id,
res: {
errors: [handleSyncError(error, account)],
errors,
newTransactions: [],
matchedTransactions: [],
updatedAccounts: [],
},
});
const error = err as Error;
errors.push(handleSyncError(error, account));
}
}

View File

@@ -766,34 +766,6 @@ handlers['api/tag-delete'] = withMutation(async function ({ id }) {
await handlers['tags-delete']({ id });
});
handlers['api/payee-location-create'] = withMutation(async function ({
payeeId,
latitude,
longitude,
}) {
checkFileOpen();
return handlers['payee-location-create']({ payeeId, latitude, longitude });
});
handlers['api/payee-locations-get'] = async function ({ payeeId }) {
checkFileOpen();
return handlers['payee-locations-get']({ payeeId });
};
handlers['api/payee-location-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payee-location-delete']({ id });
});
handlers['api/payees-get-nearby'] = async function ({
latitude,
longitude,
maxDistance,
}) {
checkFileOpen();
return handlers['payees-get-nearby']({ latitude, longitude, maxDistance });
};
handlers['api/rules-get'] = async function () {
checkFileOpen();
return handlers['rules-get']();

View File

@@ -198,14 +198,6 @@ export const schema = {
meta: f('json'),
tombstone: f('boolean'),
},
payee_locations: {
id: f('id'),
payee_id: f('id', { ref: 'payees', required: true }),
latitude: f('float', { required: true }),
longitude: f('float', { required: true }),
created_at: f('integer', { required: true }),
tombstone: f('boolean'),
},
};
export const schemaConfig: SchemaConfig = {

View File

@@ -460,61 +460,6 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
);
}
async function importPayeeLocations(
data: Budget,
entityIdMap: Map<string, string>,
) {
// If no payee locations data provided, skip import
if (!data?.payee_locations) {
logger.log('No payee locations data provided, skipping...');
return;
}
const payeeLocations = data.payee_locations;
for (const location of payeeLocations) {
// Skip deleted locations
if (location.deleted) {
continue;
}
// Get the mapped payee ID
const actualPayeeId = entityIdMap.get(location.payee_id);
if (!actualPayeeId) {
logger.log(`Skipping location for unknown payee: ${location.payee_id}`);
continue;
}
// Validate latitude/longitude before attempting import
const latitude = parseFloat(location.latitude);
const longitude = parseFloat(location.longitude);
if (isNaN(latitude) || isNaN(longitude)) {
logger.log(
`Skipping location with invalid coordinates for payee ${actualPayeeId}: lat=${location.latitude}, lng=${location.longitude}`,
);
continue;
}
try {
// Create the payee location in Actual
await send('payee-location-create', {
payeeId: actualPayeeId,
latitude,
longitude,
});
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: String(error ?? 'Unknown error');
logger.error(
`Failed to import location for payee ${actualPayeeId} at (${latitude}, ${longitude}): ${errorMessage}`,
);
}
}
}
async function importFlagsAsTags(
data: Budget,
flagNameConflicts: Set<string>,
@@ -1174,9 +1119,6 @@ export async function doImport(data: Budget) {
logger.log('Importing Payees...');
await importPayees(data, entityIdMap);
logger.log('Importing Payee Locations...');
await importPayeeLocations(data, entityIdMap);
logger.log('Importing Tags...');
await importFlagsAsTags(data, flagNameConflicts);

View File

@@ -1,7 +1,5 @@
// @ts-strict-ignore
import './polyfills';
import * as injectAPI from '@actual-app/api/injected';
import * as asyncStorage from '../platform/server/asyncStorage';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
@@ -126,8 +124,6 @@ handlers['app-focused'] = async function () {
handlers = installAPI(handlers) as Handlers;
injectAPI.override((name, args) => runHandler(app.handlers[name], args));
// A hack for now until we clean up everything
app.handlers = handlers;
app.combine(

View File

@@ -147,15 +147,29 @@ function checkDatabaseValidity(
appliedIds: number[],
available: string[],
): void {
for (let i = 0; i < appliedIds.length; i++) {
if (
i >= available.length ||
appliedIds[i] !== getMigrationId(available[i])
) {
logger.error('Database is out of sync with migrations:', {
if (appliedIds.length > available.length) {
logger.error(
'Database is out of sync with migrations (index past available):',
{
appliedIds,
available,
});
},
);
throw new Error('out-of-sync-migrations');
}
for (let i = 0; i < appliedIds.length; i++) {
if (appliedIds[i] !== getMigrationId(available[i])) {
logger.error(
'Database is out of sync with migrations (migration id mismatch):',
{
appliedIds,
available,
missing: available.filter(
m => !appliedIds.includes(getMigrationId(m)),
),
},
);
throw new Error('out-of-sync-migrations');
}
}

View File

@@ -1,12 +1,5 @@
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import type { Diff } from '../../shared/util';
import type {
NearbyPayeeEntity,
PayeeEntity,
PayeeLocationEntity,
RuleEntity,
} from '../../types/models';
import type { PayeeEntity, RuleEntity } from '../../types/models';
import { createApp } from '../app';
import * as db from '../db';
import { payeeModel } from '../models';
@@ -25,10 +18,6 @@ export type PayeesHandlers = {
'payees-batch-change': typeof batchChangePayees;
'payees-check-orphaned': typeof checkOrphanedPayees;
'payees-get-rules': typeof getPayeeRules;
'payee-location-create': typeof createPayeeLocation;
'payee-locations-get': typeof getPayeeLocations;
'payee-location-delete': typeof deletePayeeLocation;
'payees-get-nearby': typeof getNearbyPayees;
};
export const app = createApp<PayeesHandlers>();
@@ -49,10 +38,6 @@ app.method(
app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
app.method('payees-check-orphaned', checkOrphanedPayees);
app.method('payees-get-rules', getPayeeRules);
app.method('payee-location-create', mutator(createPayeeLocation));
app.method('payee-locations-get', getPayeeLocations);
app.method('payee-location-delete', mutator(deletePayeeLocation));
app.method('payees-get-nearby', getNearbyPayees);
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
return db.insertPayee({ name });
@@ -139,214 +124,3 @@ async function getPayeeRules({
}): Promise<RuleEntity[]> {
return rules.getRulesForPayee(id).map(rule => rule.serialize());
}
async function createPayeeLocation({
payeeId,
latitude,
longitude,
}: {
payeeId: PayeeEntity['id'];
latitude: number;
longitude: number;
}): Promise<PayeeLocationEntity['id']> {
const created_at = Date.now();
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
throw new Error(
'Invalid coordinates: latitude must be between -90 and 90, longitude must be between -180 and 180',
);
}
return await db.insertWithUUID('payee_locations', {
payee_id: payeeId,
latitude,
longitude,
created_at,
});
}
async function getPayeeLocations({
payeeId,
}: {
payeeId?: PayeeEntity['id'];
} = {}): Promise<PayeeLocationEntity[]> {
let query = 'SELECT * FROM payee_locations WHERE tombstone IS NOT 1';
let params: string[] = [];
if (payeeId) {
query += ' AND payee_id = ?';
params = [payeeId];
}
query += ' ORDER BY created_at DESC';
return db.runQuery<PayeeLocationEntity>(query, params, true);
}
async function deletePayeeLocation({
id,
}: {
id: PayeeLocationEntity['id'];
}): Promise<void> {
await db.delete_('payee_locations', id);
}
// Type for the raw query result that combines PayeeEntity and PayeeLocationEntity fields
type NearbyPayeeQueryResult = Pick<
db.DbPayee,
| 'id'
| 'name'
| 'transfer_acct'
| 'favorite'
| 'learn_categories'
| 'tombstone'
> &
Omit<PayeeLocationEntity, 'id'> & {
// PayeeLocationEntity's id renamed to location_id
location_id: PayeeLocationEntity['id'];
// Calculated distance from SQL
distance: number;
};
async function getNearbyPayees({
latitude,
longitude,
maxDistance = DEFAULT_MAX_DISTANCE_METERS,
}: {
latitude: number;
longitude: number;
maxDistance?: number;
}): Promise<NearbyPayeeEntity[]> {
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
throw new Error(
'Invalid coordinates: latitude must be between -90 and 90, longitude must be between -180 and 180',
);
}
if (!Number.isFinite(maxDistance) || maxDistance <= 0) {
throw new Error(
'Invalid maxDistance: must be a finite positive number greater than 0',
);
}
// Get the closest location for each payee within maxDistance using window functions
const query = `
WITH payee_distances AS (
SELECT
pl.id as location_id,
pl.payee_id,
pl.latitude,
pl.longitude,
pl.created_at,
p.id,
p.name,
p.transfer_acct,
p.favorite,
p.learn_categories,
p.tombstone,
-- Haversine formula to calculate distance
((6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
))) * 1000 as distance,
-- Rank locations by distance for each payee
ROW_NUMBER() OVER (PARTITION BY pl.payee_id ORDER BY (
(6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
)) * 1000
)) as distance_rank
FROM payee_locations pl
JOIN payees p ON pl.payee_id = p.id
WHERE p.tombstone IS NOT 1
AND pl.tombstone IS NOT 1
-- Filter by distance using Haversine formula
AND (6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
)) * 1000 <= ?
)
SELECT
location_id,
payee_id,
latitude,
longitude,
created_at,
id,
name,
transfer_acct,
favorite,
learn_categories,
tombstone,
distance
FROM payee_distances
WHERE distance_rank = 1
ORDER BY distance ASC
LIMIT 10
`;
const results = db.runQuery<NearbyPayeeQueryResult>(
query,
[
latitude,
longitude,
latitude, // For first distance calculation in SELECT
latitude,
longitude,
latitude, // For ROW_NUMBER() ordering
latitude,
longitude,
latitude, // For WHERE distance filter
maxDistance,
],
true,
);
// Transform results to expected format
const nearbyPayees: NearbyPayeeEntity[] = results.map(row => {
const payee = payeeModel.fromDb({
id: row.id,
name: row.name,
transfer_acct: row.transfer_acct,
favorite: row.favorite,
learn_categories: row.learn_categories,
tombstone: row.tombstone,
});
return {
payee,
location: {
id: row.location_id,
payee_id: row.payee_id,
latitude: row.latitude,
longitude: row.longitude,
created_at: row.created_at,
distance: row.distance,
},
};
});
return nearbyPayees;
}

View File

@@ -1,5 +0,0 @@
/**
* Default maximum distance (in meters) for nearby payee lookups.
* Payees with locations beyond this distance are not considered "nearby".
*/
export const DEFAULT_MAX_DISTANCE_METERS = 500;

View File

@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import { calculateDistance, formatDistance } from './location-utils';
describe('Location Utils', () => {
describe('calculateDistance', () => {
it('calculates distance between same location as 0', () => {
const pos = { latitude: 40.7128, longitude: -74.006 };
const distance = calculateDistance(pos, pos);
expect(distance).toBe(0);
});
it('calculates distance between known coordinates accurately', () => {
// NYC to Philadelphia (approximately 129 km)
const nyc = { latitude: 40.7128, longitude: -74.006 };
const philly = { latitude: 39.9526, longitude: -75.1652 };
const distance = calculateDistance(nyc, philly);
// Should be approximately 129,000meters (allow 5% variance for rounding)
expect(distance).toBeGreaterThan(122000);
expect(distance).toBeLessThan(136000);
});
it('calculates short distances accurately', () => {
// Two points very close together (about 100m apart in NYC)
const pos1 = { latitude: 40.7128, longitude: -74.006 };
const pos2 = { latitude: 40.7137, longitude: -74.0068 }; // ~100m north
const distance = calculateDistance(pos1, pos2);
// Should be approximately 100meters (allow reasonable variance for coord precision)
expect(distance).toBeGreaterThan(90);
expect(distance).toBeLessThan(130);
});
it('handles cross-equator distances', () => {
const northPole = { latitude: 89.0, longitude: 0 };
const southPole = { latitude: -89.0, longitude: 0 };
const distance = calculateDistance(northPole, southPole);
// Should be very large (close to half Earth's circumference)
expect(distance).toBeGreaterThan(19000000); // > 19,000 km
});
it('handles cross-meridian distances', () => {
const nearAntimeridianEast = { latitude: 51.5074, longitude: 179 };
const nearAntimeridianWest = { latitude: 51.5074, longitude: -179 };
const distance = calculateDistance(
nearAntimeridianEast,
nearAntimeridianWest,
);
// Should be a reasonable distance, not the long way around
expect(distance).toBeGreaterThan(0);
expect(distance).toBeLessThan(1000000); // < 1000 km
});
});
describe('formatDistance', () => {
it('formats feet/meters correctly', () => {
expect(formatDistance(0)).toBe('0ft | 0m');
expect(formatDistance(0.9)).toBe('3ft | 1m');
expect(formatDistance(50)).toBe('164ft | 50m');
expect(formatDistance(500)).toBe('1640ft | 500m');
expect(formatDistance(1000)).toBe('3281ft | 1000m');
expect(formatDistance(1500)).toBe('4921ft | 1500m');
expect(formatDistance(2500)).toBe('8202ft | 2500m');
});
});
});

View File

@@ -1,49 +0,0 @@
/**
* Pure utility functions for location calculations and formatting.
* These functions have no side effects and can be easily tested.
*/
const metersToFeet = 3.28084;
export type LocationCoordinates = {
latitude: number;
longitude: number;
};
/**
* Calculate the distance between two geographic coordinates using the Haversine formula
* @param pos1 First position coordinates
* @param pos2 Second position coordinates
* @returns Distance in meters
*/
export function calculateDistance(
pos1: LocationCoordinates,
pos2: LocationCoordinates,
): number {
const R = 6371e3; // Earth's radius in meters
const phi1 = (pos1.latitude * Math.PI) / 180;
const phi2 = (pos2.latitude * Math.PI) / 180;
const deltaPhi = ((pos2.latitude - pos1.latitude) * Math.PI) / 180;
const deltaLambda = ((pos2.longitude - pos1.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) *
Math.cos(phi2) *
Math.sin(deltaLambda / 2) *
Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
/**
* Format a distance in meters to a human-readable string
* @param meters Distance in meters
* @returns Formatted distance string
*/
export function formatDistance(meters: number): string {
const distanceImperial = `${Math.round(meters * metersToFeet)}ft`;
const distanceMetric = `${Math.round(meters)}m`;
return `${distanceImperial} | ${distanceMetric}`;
}

View File

@@ -72,15 +72,6 @@ describe('utility functions', () => {
expect(looselyParseAmount('(1 500.99)')).toBe(-1500.99);
});
test('looseParseAmount handles trailing whitespace', () => {
expect(looselyParseAmount('1055 ')).toBe(1055);
expect(looselyParseAmount('$1,055 ')).toBe(1055);
expect(looselyParseAmount('$1,055.00 ')).toBe(1055);
expect(looselyParseAmount(' $1,055 ')).toBe(1055);
expect(looselyParseAmount('3.45 ')).toBe(3.45);
expect(looselyParseAmount(' 3.45 ')).toBe(3.45);
});
test('number formatting works with comma-dot format', () => {
setNumberFormat({ format: 'comma-dot', hideFraction: false });
let formatter = getNumberFormat().formatter;

View File

@@ -550,8 +550,6 @@ export function looselyParseAmount(amount: string) {
return v.replace(/[^0-9-]/g, '');
}
amount = amount.trim();
if (amount.startsWith('(') && amount.endsWith(')')) {
// Remove Unicode minus inside parentheses before converting to ASCII minus
amount = amount.replace(/\u2212/g, '');

View File

@@ -15,9 +15,7 @@ import type { QueryState } from '../shared/query';
import type {
ImportTransactionEntity,
NearbyPayeeEntity,
NewRuleEntity,
PayeeLocationEntity,
RuleEntity,
ScheduleEntity,
TransactionEntity,
@@ -234,24 +232,6 @@ export type ApiHandlers = {
'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise<void>;
'api/payee-location-create': (arg: {
payeeId: string;
latitude: number;
longitude: number;
}) => Promise<string>;
'api/payee-locations-get': (arg: {
payeeId: string;
}) => Promise<PayeeLocationEntity[]>;
'api/payee-location-delete': (arg: { id: string }) => Promise<void>;
'api/payees-get-nearby': (arg: {
latitude: number;
longitude: number;
maxDistance?: number;
}) => Promise<NearbyPayeeEntity[]>;
'api/rules-get': () => Promise<RuleEntity[]>;
'api/payee-rules-get': (arg: {

View File

@@ -6,11 +6,9 @@ export type * from './category-group';
export type * from './dashboard';
export type * from './gocardless';
export type * from './import-transaction';
export type * from './nearby-payee';
export type * from './note';
export type * from './openid';
export type * from './payee';
export type * from './payee-location';
export type * from './pluggyai';
export type * from './reports';
export type * from './rule';

View File

@@ -1,7 +0,0 @@
import type { PayeeEntity } from './payee';
import type { PayeeLocationEntity } from './payee-location';
export type NearbyPayeeEntity = {
payee: PayeeEntity;
location: PayeeLocationEntity;
};

View File

@@ -1,8 +0,0 @@
export type PayeeLocationEntity = {
id: string;
payee_id: string;
latitude: number;
longitude: number;
created_at: number;
distance?: number;
};

View File

@@ -6,8 +6,7 @@ export type FeatureFlag =
| 'currency'
| 'crossoverReport'
| 'customThemes'
| 'budgetAnalysisReport'
| 'payeeLocations';
| 'budgetAnalysisReport';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -1,58 +0,0 @@
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
export default defineConfig(({ mode }) => {
const outDir = path.resolve(__dirname, '../api/app');
const crdtDir = path.resolve(__dirname, '../crdt');
return {
mode,
ssr: { noExternal: true, external: ['better-sqlite3'] },
build: {
target: 'node18',
outDir,
emptyOutDir: false,
ssr: true,
lib: {
entry: path.resolve(__dirname, 'src/server/main.ts'),
formats: ['cjs'],
},
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'bundle.api.js',
format: 'cjs',
name: 'api',
},
},
},
resolve: {
extensions: [
'.api.js',
'.api.ts',
'.api.tsx',
'.js',
'.ts',
'.tsx',
'.json',
],
alias: [
{
find: 'handlebars',
replacement: require.resolve('handlebars/dist/handlebars.js'),
},
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(crdtDir, 'src') + '$1',
},
],
},
plugins: [
peggyLoader(),
visualizer({ template: 'raw-data', filename: `${outDir}/stats.json` }),
],
};
});

View File

@@ -38,7 +38,7 @@
"date-fns": "^4.1.0",
"debug": "^4.4.3",
"express": "^5.2.1",
"express-rate-limit": "^8.3.0",
"express-rate-limit": "^8.2.1",
"express-winston": "^4.2.0",
"ipaddr.js": "^2.3.0",
"jws": "^4.0.1",

View File

@@ -59,12 +59,7 @@ export function getLoginMethod(req) {
(req.body || { loginMethod: null }).loginMethod &&
config.get('allowedLoginMethods').includes(req.body.loginMethod)
) {
const accountDb = getAccountDb();
const activeRow = accountDb.first(
'SELECT method FROM auth WHERE method = ? AND active = 1',
[req.body.loginMethod],
);
if (activeRow) return req.body.loginMethod;
return req.body.loginMethod;
}
//BY-PASS ANY OTHER CONFIGURATION TO ENSURE HEADER AUTH

View File

@@ -121,15 +121,6 @@ app.post('/change-password', (req, res) => {
const session = validateSession(req, res);
if (!session) return;
if (getActiveLoginMethod() !== 'password') {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'password-auth-not-active',
});
return;
}
const { error } = changePassword(req.body.password);
if (error) {

View File

@@ -1,8 +1,7 @@
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import { getAccountDb, getLoginMethod, getServerPrefs } from './account-db';
import { bootstrapPassword } from './accounts/password';
import { getAccountDb, getServerPrefs } from './account-db';
import { handlers as app } from './app-account';
const ADMIN_ROLE = 'ADMIN';
@@ -34,119 +33,6 @@ const clearServerPrefs = () => {
getAccountDb().mutate('DELETE FROM server_prefs');
};
const insertAuthRow = (method, active, extraData = null) => {
getAccountDb().mutate(
'INSERT INTO auth (method, display_name, extra_data, active) VALUES (?, ?, ?, ?)',
[method, method, extraData, active],
);
};
const clearAuth = () => {
getAccountDb().mutate('DELETE FROM auth');
};
describe('/change-password', () => {
let userId, sessionToken;
beforeEach(() => {
userId = uuidv4();
sessionToken = generateSessionToken();
createUser(userId, 'testuser', ADMIN_ROLE);
createSession(userId, sessionToken);
});
afterEach(() => {
deleteUser(userId);
clearAuth();
});
it('should return 401 if no session token is provided', async () => {
const res = await request(app).post('/change-password').send({
password: 'newpassword',
});
expect(res.statusCode).toEqual(401);
expect(res.body).toHaveProperty('status', 'error');
expect(res.body).toHaveProperty('reason', 'unauthorized');
});
it('should return 403 when active auth method is openid', async () => {
insertAuthRow('openid', 1);
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.send({ password: 'newpassword' });
expect(res.statusCode).toEqual(403);
expect(res.body).toEqual({
status: 'error',
reason: 'forbidden',
details: 'password-auth-not-active',
});
});
it('should return 400 when active method is password but password is empty', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.send({ password: '' });
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({ status: 'error', reason: 'invalid-password' });
});
it('should return 200 when active method is password and new password is valid', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.send({ password: 'newpassword' });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({ status: 'ok', data: {} });
});
});
describe('getLoginMethod()', () => {
afterEach(() => {
clearAuth();
});
it('returns the active DB method when no req is provided', () => {
insertAuthRow('password', 1);
expect(getLoginMethod(undefined)).toBe('password');
});
it('honors a client-requested method when it is active in DB', () => {
insertAuthRow('openid', 1);
const req = { body: { loginMethod: 'openid' } };
expect(getLoginMethod(req)).toBe('openid');
});
it('ignores a client-requested method that is inactive in DB', () => {
insertAuthRow('openid', 1);
insertAuthRow('password', 0);
const req = { body: { loginMethod: 'password' } };
expect(getLoginMethod(req)).toBe('openid');
});
it('ignores a client-requested method that is not in DB', () => {
insertAuthRow('openid', 1);
const req = { body: { loginMethod: 'password' } };
expect(getLoginMethod(req)).toBe('openid');
});
it('falls back to config default when auth table is empty and no req', () => {
// auth table is empty — getActiveLoginMethod() returns undefined
// config default for loginMethod is 'password'
expect(getLoginMethod(undefined)).toBe('password');
});
});
describe('/server-prefs', () => {
describe('POST /server-prefs', () => {
let adminUserId, basicUserId, adminSessionToken, basicSessionToken;

View File

@@ -28,9 +28,7 @@ export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
> = {};
for (const f of files
.filter(
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
)
.filter(f => f.endsWith('.js') || f.endsWith('.ts'))
.sort()) {
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href

View File

@@ -1,6 +0,0 @@
---
category: Features
authors: [mannkind]
---
MVP for payee locations support, including YNAB5 import

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove cyclic dependency between API and loot-core

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [LeviBorodenko]
---
[Mobile] Show running balance on upcoming transactions when respective setting is toggled

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [Juulz]
---
Make mobile account page colors more consistent

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