Compare commits
32 Commits
master
...
matiss/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3b5dabf3 | ||
|
|
47f3f41f7d | ||
|
|
2b3d8ef8c2 | ||
|
|
e9d9226a81 | ||
|
|
0d9d45e6fe | ||
|
|
6f91c4cede | ||
|
|
6a042b0c62 | ||
|
|
da1ab9e85d | ||
|
|
f1dc0b4a6e | ||
|
|
a41d0b3323 | ||
|
|
6ce931ca20 | ||
|
|
01c94453b7 | ||
|
|
ac86c89851 | ||
|
|
1cb862c74f | ||
|
|
b4cc0baef5 | ||
|
|
34273d4faa | ||
|
|
dd7521d416 | ||
|
|
134d546716 | ||
|
|
f096cb01b8 | ||
|
|
6488ca1ca9 | ||
|
|
40a74197db | ||
|
|
ab972b7e36 | ||
|
|
73739eb91a | ||
|
|
cd7663ecfd | ||
|
|
0619198f50 | ||
|
|
7098a5fad7 | ||
|
|
4ff3b35168 | ||
|
|
6f9dab9aab | ||
|
|
6d1cc5cd23 | ||
|
|
549f269511 | ||
|
|
68c0b35d05 | ||
|
|
ae763af100 |
@@ -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)
|
||||
@@ -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',
|
||||
70
.github/agents/pr-and-commit-rules.md
vendored
@@ -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)
|
||||
7
.github/workflows/check.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/docker-edge.yml
vendored
@@ -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/
|
||||
|
||||
43
AGENTS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// TODO: comment on why it works this way
|
||||
|
||||
export let send;
|
||||
|
||||
export function override(sendImplementation) {
|
||||
send = sendImplementation;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
60
packages/api/scripts/inline-loot-core-types.mjs
Normal 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();
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
declare module 'hyperformula/i18n/languages/enUS';
|
||||
declare module '*.pegjs';
|
||||
@@ -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;
|
||||
|
||||
99
packages/api/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']>> =
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import 'loot-core/typings/window';
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || [];
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import {
|
||||
BrowserGeolocationAdapter,
|
||||
SendApiLocationClient,
|
||||
} from './location-adapters';
|
||||
import { LocationService } from './location-service';
|
||||
|
||||
export const locationService = new LocationService(
|
||||
new BrowserGeolocationAdapter(),
|
||||
new SendApiLocationClient(),
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { PayeeEntity } from './payee';
|
||||
import type { PayeeLocationEntity } from './payee-location';
|
||||
|
||||
export type NearbyPayeeEntity = {
|
||||
payee: PayeeEntity;
|
||||
location: PayeeLocationEntity;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export type PayeeLocationEntity = {
|
||||
id: string;
|
||||
payee_id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
created_at: number;
|
||||
distance?: number;
|
||||
};
|
||||
@@ -6,8 +6,7 @@ export type FeatureFlag =
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport'
|
||||
| 'payeeLocations';
|
||||
| 'budgetAnalysisReport';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
@@ -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` }),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [mannkind]
|
||||
---
|
||||
|
||||
MVP for payee locations support, including YNAB5 import
|
||||
6
upcoming-release-notes/6809.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove cyclic dependency between API and loot-core
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [LeviBorodenko]
|
||||
---
|
||||
|
||||
[Mobile] Show running balance on upcoming transactions when respective setting is toggled
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Make mobile account page colors more consistent
|
||||