Compare commits

..

3 Commits

Author SHA1 Message Date
Claude
5373ca9012 [AI] Reset loadingSimpleFinAccounts before early return in INVALID_ACCESS_TOKEN branch
The early return for INVALID_ACCESS_TOKEN skipped the
setLoadingSimpleFinAccounts(false) call at the end of the handler,
leaving the spinner active and clicks disabled.

https://claude.ai/code/session_01CmcFHWUhqo3eC2joAWrtWM
2026-03-13 20:21:07 +00:00
github-actions[bot]
42ce174fc9 Add release notes for PR #7192 2026-03-13 20:16:03 +00:00
Claude
512049376f [AI] Improve SimpleFIN error handling with specific error messages
The SimpleFIN error flow was swallowing useful error details at three
points: the sync server sent a generic message, the client core
mislabeled all errors as TIMED_OUT, and the frontend silently bounced
users to the init modal without showing any error.

- Classify errors in serverDown() (Forbidden, invalid key, parse error,
  network failure) instead of sending a generic message
- Handle Forbidden errors in /accounts handler same as /transactions
- Distinguish timeout from other PostError types in simpleFinAccounts()
- Show error notification to user instead of silently redirecting
- Only redirect to init modal for INVALID_ACCESS_TOKEN errors

https://claude.ai/code/session_01CmcFHWUhqo3eC2joAWrtWM
2026-03-13 19:47:48 +00:00
174 changed files with 1985 additions and 3390 deletions

View File

@@ -20,13 +20,11 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
@@ -35,10 +33,6 @@ jobs:
run: |
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
run: yarn build:server
@@ -59,7 +53,6 @@ jobs:
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
@@ -83,12 +76,6 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly

View File

@@ -16,10 +16,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Web
run: yarn build:server
@@ -40,7 +36,6 @@ jobs:
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
@@ -64,12 +59,6 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public

View File

@@ -10,7 +10,7 @@
"builtin",
"external",
"loot-core",
["parent", "subpath"],
"parent",
"sibling",
"index",
"desktop-client"
@@ -22,7 +22,7 @@
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
"elementNamePattern": ["loot-core/**"]
},
{
"groupName": "desktop-client",

View File

@@ -101,18 +101,12 @@
"typescript/no-var-requires": "error",
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
"typescript/no-duplicate-type-constituents": "off",
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
"typescript/no-redundant-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "error",
"typescript/require-array-sort-compare": "error",
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "error",
"typescript/no-for-in-array": "warn", // TODO: covert to error
"typescript/restrict-template-expressions": "error",
"typescript/no-misused-spread": "warn", // TODO: enable this
"typescript/no-base-to-string": "warn", // TODO: enable this
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
// Import rules
"import/consistent-type-specifier-style": "error",

View File

@@ -84,7 +84,7 @@ The core application logic that runs on any platform.
```bash
# Run all loot-core tests
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
@@ -219,7 +219,7 @@ yarn test
yarn test:debug
# Run tests for a specific package
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
```
**E2E Tests (Playwright)**
@@ -625,7 +625,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsgo + lage typecheck)
- `yarn typecheck` (tsc + lage typecheck)
### Testing and previewing the app

View File

@@ -17,7 +17,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"

View File

@@ -51,16 +51,16 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/core build:node
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace loot-core exec tsc -p tsconfig.json
yarn workspace desktop-electron update-client

View File

@@ -25,16 +25,16 @@
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -53,11 +53,11 @@
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
@@ -65,7 +65,6 @@
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.10",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",

View File

@@ -3,8 +3,8 @@ import type {
RequestInit as FetchInit,
} from 'node-fetch';
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
import { init as initLootCore } from 'loot-core/server/main';
import type { InitConfig, lib } from 'loot-core/server/main';
import { validateNodeVersion } from './validateNodeVersion';

View File

@@ -3,7 +3,7 @@ import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import type { RuleEntity } from 'loot-core/types/models';
import * as api from './index';

View File

@@ -6,16 +6,16 @@ import type {
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from '@actual-app/core/server/api-models';
import { lib } from '@actual-app/core/server/main';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type { Handlers } from '@actual-app/core/types/handlers';
} 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';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
} from 'loot-core/types/models';
export { q } from './app/query';

View File

@@ -10,26 +10,26 @@
"main": "dist/index.js",
"types": "@types/index.d.ts",
"scripts": {
"build": "vite build",
"build": "yarn workspace loot-core exec tsc && vite build && node scripts/inline-loot-core-types.mjs",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
"typecheck": "tsc -b && tsc-strict"
},
"dependencies": {
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"@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": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0"
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20"

View File

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

View File

@@ -1,4 +1,4 @@
import { lib } from '@actual-app/core/server/main';
import { lib } from 'loot-core/server/main';
export const amountToInteger = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;

View File

@@ -81,6 +81,12 @@ export default defineConfig({
],
resolve: {
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(__dirname, '../crdt/src') + '$1',
},
],
},
test: {
globals: true,

View File

@@ -1 +0,0 @@
dist/*

View File

@@ -5,12 +5,12 @@
"scripts": {
"tsx": "node --import=extensionless/register --experimental-strip-types",
"test": "vitest --run",
"typecheck": "tsgo -b"
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"extensionless": "^2.0.6",
"vitest": "^4.1.0"
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"extensionless": {
"lookFor": [

View File

@@ -8,8 +8,7 @@
"strict": true,
"types": ["node"],
"outDir": "dist",
"rootDir": ".",
"composite": true
"rootDir": "."
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules"]

View File

@@ -2,7 +2,7 @@ import { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
/**
* This function is used to resolve the absolute path of a package.
@@ -32,9 +32,11 @@ const config: StorybookConfig = {
const { mergeConfig } = await import('vite');
return mergeConfig(config, {
plugins: [react()],
resolve: {
tsconfigPaths: true,
// Telling Vite how to resolve path aliases
plugins: [viteTsconfigPaths({ root: '../..' })],
esbuild: {
// Needed to handle JSX in .ts/.tsx files
jsx: 'automatic',
},
});
},

View File

@@ -40,7 +40,7 @@
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
"start:storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"typecheck": "tsgo -b"
"typecheck": "tsc -b"
},
"dependencies": {
"@emotion/css": "^11.13.5",
@@ -54,14 +54,12 @@
"@storybook/react-vite": "^10.2.7",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"vite": "^8.0.0",
"vitest": "^4.1.0"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18"
},
"peerDependencies": {
"react": ">=19.2",

View File

@@ -16,8 +16,8 @@ import { View } from './View';
const MenuLine: unique symbol = Symbol('menu-line');
const MenuLabel: unique symbol = Symbol('menu-label');
Menu.line = MenuLine as typeof MenuLine;
Menu.label = MenuLabel as typeof MenuLabel;
Menu.line = MenuLine;
Menu.label = MenuLabel;
type KeybindingProps = {
keyName: ReactNode;

View File

@@ -1,6 +1,5 @@
import path from 'path';
import react from '@vitejs/plugin-react';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
@@ -24,7 +23,13 @@ export default defineConfig({
maxWorkers: 2,
},
resolve: {
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve('../../../crdt/src$1'),
},
],
extensions: resolveExtensions,
},
plugins: [react(), peggyLoader()],
plugins: [peggyLoader()],
});

View File

@@ -8,23 +8,12 @@
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build:node": "tsgo",
"build:node": "tsc",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --run",
"typecheck": "tsgo -b"
"typecheck": "tsc -b"
},
"dependencies": {
"google-protobuf": "^3.21.4",
@@ -33,9 +22,9 @@
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"vitest": "^4.1.0"
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

View File

@@ -1,18 +1,18 @@
import type { Locator, Page } from '@playwright/test';
const NO_PAYEES_FOUND_TEXT = 'No payees found.';
export class MobilePayeesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly payeesList: Locator;
readonly noPayeesFoundText: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter payees…');
this.payeesList = page.getByRole('grid', { name: 'Payees' });
this.noPayeesFoundText = this.payeesList.getByText(NO_PAYEES_FOUND_TEXT);
this.emptyMessage = page.getByText('No payees found.');
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
@@ -47,11 +47,7 @@ export class MobilePayeesPage {
* Get all visible payee items
*/
getAllPayees() {
// `GridList.renderEmptyState` still renders a row with "No payees found" text
// when no payees are present, so we need to filter that out to get the actual payee items.
return this.payeesList
.getByRole('row')
.filter({ hasNotText: NO_PAYEES_FOUND_TEXT });
return this.payeesList.getByRole('gridcell');
}
/**
@@ -69,4 +65,11 @@ export class MobilePayeesPage {
const payees = this.getAllPayees();
return await payees.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -1,21 +1,18 @@
import type { Locator, Page } from '@playwright/test';
const NO_RULES_FOUND_TEXT =
'No rules found. Create your first rule to get started!';
export class MobileRulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly rulesList: Locator;
readonly noRulesFoundText: Locator;
readonly emptyMessage: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules…');
this.addButton = page.getByRole('button', { name: 'Add new rule' });
this.rulesList = page.getByRole('grid', { name: 'Rules' });
this.noRulesFoundText = this.rulesList.getByText(NO_RULES_FOUND_TEXT);
this.rulesList = page.getByRole('main');
this.emptyMessage = page.getByText('No rules found');
}
async waitFor(options?: {
@@ -50,11 +47,7 @@ export class MobileRulesPage {
* Get all visible rule items
*/
getAllRules() {
// `GridList.renderEmptyState` still renders a row with "No rules found" text
// when no rules are present, so we need to filter that out to get the actual rule items.
return this.rulesList
.getByRole('row')
.filter({ hasNotText: NO_RULES_FOUND_TEXT });
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
}
/**

View File

@@ -1,23 +1,22 @@
import type { Locator, Page } from '@playwright/test';
const NO_SCHEDULES_FOUND_TEXT =
'No schedules found. Create your first schedule to get started!';
export class MobileSchedulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly schedulesList: Locator;
readonly noSchedulesFoundText: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter schedules…');
this.addButton = page.getByRole('button', { name: 'Add new schedule' });
this.schedulesList = page.getByRole('grid', { name: 'Schedules' });
this.noSchedulesFoundText = this.schedulesList.getByText(
NO_SCHEDULES_FOUND_TEXT,
this.emptyMessage = page.getByText(
'No schedules found. Create your first schedule to get started!',
);
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
@@ -52,11 +51,7 @@ export class MobileSchedulesPage {
* Get all visible schedule items
*/
getAllSchedules() {
// `GridList.renderEmptyState` still renders a row with "No schedules found" text
// when no schedules are present, so we need to filter that out to get the actual schedule items.
return this.schedulesList
.getByRole('row')
.filter({ hasNotText: NO_SCHEDULES_FOUND_TEXT });
return this.schedulesList.getByRole('gridcell');
}
/**
@@ -81,4 +76,11 @@ export class MobileSchedulesPage {
const schedules = this.getAllSchedules();
return await schedules.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -34,6 +34,8 @@ test.describe('Mobile Payees', () => {
});
test('checks the page visuals', async () => {
await payeesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(page.getByRole('heading', { name: 'Payees' })).toBeVisible();
@@ -61,6 +63,8 @@ test.describe('Mobile Payees', () => {
});
test('clicking on a payee opens payee edit page', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
@@ -85,7 +89,8 @@ test.describe('Mobile Payees', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
await expect(payeesPage.noPayeesFoundText).toBeVisible();
const emptyMessage = page.getByText('No payees found.');
await expect(emptyMessage).toBeVisible();
// Check that no payee items are visible
const payees = payeesPage.getAllPayees();
@@ -94,6 +99,8 @@ test.describe('Mobile Payees', () => {
});
test('search functionality works correctly', async () => {
await payeesPage.waitForLoadingToComplete();
// Test searching for a specific payee
await payeesPage.searchFor('Fast Internet');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -79,7 +79,8 @@ test.describe('Mobile Rules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
await expect(rulesPage.noRulesFoundText).toBeVisible();
const emptyMessage = page.getByText(/No rules found/);
await expect(emptyMessage).toBeVisible();
// Check that no rule items are visible
const rules = rulesPage.getAllRules();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -27,7 +27,6 @@ test.describe('Mobile Schedules', () => {
// Navigate to schedules page and wait for it to load
schedulesPage = await navigation.goToSchedulesPage();
await schedulesPage.waitFor();
});
test.afterEach(async () => {
@@ -35,6 +34,8 @@ test.describe('Mobile Schedules', () => {
});
test('checks the page visuals', async () => {
await schedulesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(
page.getByRole('heading', { name: 'Schedules' }),
@@ -59,15 +60,14 @@ test.describe('Mobile Schedules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
await expect(schedulesPage.noSchedulesFoundText).toBeVisible();
await expect(schedulesPage.emptyMessage).toBeVisible();
// Check that no schedule items are visible
const schedules = schedulesPage.getAllSchedules();
await expect(schedules).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a schedule opens edit form', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for at least one schedule to be present
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -89,6 +89,8 @@ test.describe('Mobile Schedules', () => {
});
test('searches and filters schedules', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -116,6 +118,8 @@ test.describe('Mobile Schedules', () => {
});
test('displays schedule details correctly in list', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -16,12 +16,10 @@
"e2e": "npx playwright test --browser=chromium",
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
"playwright": "playwright",
"typecheck": "tsgo -b && tsc-strict"
"typecheck": "tsc -b && tsc-strict"
},
"devDependencies": {
"@actual-app/components": "workspace:*",
"@actual-app/core": "workspace:*",
"@babel/core": "^7.29.0",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.1",
@@ -33,7 +31,6 @@
"@juggle/resize-observer": "^3.4.0",
"@lezer/highlight": "^1.2.3",
"@playwright/test": "1.58.2",
"@rolldown/plugin-babel": "~0.1.7",
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.15.11",
"@swc/helpers": "^0.5.18",
@@ -48,11 +45,10 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@uiw/react-codemirror": "^4.25.4",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.2.0",
"@vitejs/plugin-react": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^2.1.4",
"@vitejs/plugin-react": "^5.1.3",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"cmdk": "^1.1.1",
@@ -65,6 +61,7 @@
"i18next-resources-to-backend": "^1.2.1",
"jsdom": "^27.4.0",
"lodash": "^4.17.23",
"loot-core": "workspace:*",
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
@@ -94,12 +91,13 @@
"remark-gfm": "^4.0.1",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.97.3",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"vite": "^8.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.0",
"vitest": "^4.0.18",
"xml2js": "^0.6.2"
}
}

View File

@@ -130,8 +130,8 @@ export function ManageRules({
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
});
const { data: { list: categories } = { list: [] } } = useCategories();
const { data: payees } = usePayees();

View File

@@ -17,7 +17,6 @@ import { CategoryMenuModal } from './modals/CategoryMenuModal';
import { CloseAccountModal } from './modals/CloseAccountModal';
import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal';
import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
import { ConfirmPayeesMergeModal } from './modals/ConfirmPayeesMergeModal';
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
@@ -141,9 +140,6 @@ export function Modals() {
case 'confirm-category-delete':
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
case 'confirm-payees-merge':
return <ConfirmPayeesMergeModal key={key} {...modal.options} />;
case 'confirm-unlink-account':
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;

View File

@@ -59,6 +59,7 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
import type { Actions } from '@desktop-client/hooks/useSelected';
import {
@@ -82,7 +83,6 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
import { schedulesViewQuery } from '@desktop-client/schedules';
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
@@ -1661,11 +1661,6 @@ class AccountInternal extends PureComponent<
}
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
// Always add sort_order as a final tiebreaker to maintain stable ordering
// when transactions have the same values in the sorted column(s)
this.currentQuery = this.currentQuery.orderBy({ sort_order: sortAscDesc });
this.updateQuery(this.currentQuery, isFiltered);
};
@@ -1866,12 +1861,6 @@ class AccountInternal extends PureComponent<
accountId === 'onbudget' ||
accountId === 'uncategorized'
}
allowReorder={
!!accountId &&
accountId !== 'offbudget' &&
accountId !== 'onbudget' &&
accountId !== 'uncategorized'
}
isAdding={this.state.isAdding}
isNew={this.isNew}
isMatched={this.isMatched}
@@ -2010,7 +1999,7 @@ export function Account() {
const savedFiters = useTransactionFilters();
const schedulesQuery = useMemo(
() => schedulesViewQuery(params.id),
() => getSchedulesQuery(params.id),
[params.id],
);

View File

@@ -12,10 +12,7 @@ import { useHover } from 'usehooks-ts';
import { q } from 'loot-core/shared/query';
import type { Query } from 'loot-core/shared/query';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import {
getScheduleFromPreviewId,
isPreviewId,
} from 'loot-core/shared/transactions';
import { isPreviewId } from 'loot-core/shared/transactions';
import type { AccountEntity } from 'loot-core/types/models';
import { FinancialText } from '@desktop-client/components/FinancialText';
@@ -94,29 +91,28 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
let scheduleBalance = 0;
const { data: schedules = [], isLoading } = useCachedSchedules();
const { isLoading, schedules = [] } = useCachedSchedules();
if (isLoading) {
return null;
}
const previewedScheduleIds = [...selectedItems]
const previewIds = [...selectedItems]
.filter(id => isPreviewId(id))
.map(id => getScheduleFromPreviewId(id));
.map(id => id.slice(8));
let isExactBalance = true;
for (const schedule of schedules) {
if (previewedScheduleIds.includes(schedule.id)) {
for (const s of schedules) {
if (previewIds.includes(s.id)) {
// If a schedule is `between X and Y` then we calculate the average
if (schedule._amountOp === 'isbetween') {
if (s._amountOp === 'isbetween') {
isExactBalance = false;
}
if (!account || account.id === schedule._account) {
scheduleBalance += getScheduledAmount(schedule._amount);
if (!account || account.id === s._account) {
scheduleBalance += getScheduledAmount(s._amount);
} else {
scheduleBalance -= getScheduledAmount(schedule._amount);
scheduleBalance -= getScheduledAmount(s._amount);
}
}
}

View File

@@ -1,10 +1,11 @@
import React, { useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { composeRenderProps, GridListItem } from 'react-aria-components';
import { GridListItem } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { animated, config, useSpring } from 'react-spring';
import { Button } from '@actual-app/components/button';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { useDrag } from '@use-gesture/react';
@@ -88,11 +89,14 @@ export function ActionableGridListItem<T extends object>({
{...props}
value={value}
textValue={textValue}
style={composeRenderProps(props.style, propStyle => ({
backgroundColor: hasActions ? actionsBackgroundColor : undefined,
style={{
...styles.mobileListItem,
padding: 0,
backgroundColor: hasActions
? actionsBackgroundColor
: (styles.mobileListItem.backgroundColor ?? 'transparent'),
overflow: 'hidden',
...propStyle,
}))}
}}
>
<animated.div
{...(hasActions ? bind() : {})}

View File

@@ -14,6 +14,7 @@ import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountP
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import {
@@ -24,7 +25,6 @@ import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSear
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function AccountTransactions({
@@ -33,7 +33,7 @@ export function AccountTransactions({
readonly account: AccountEntity;
}) {
const schedulesQuery = useMemo(
() => schedulesViewQuery(account.id),
() => getSchedulesQuery(account.id),
[account.id],
);

View File

@@ -11,16 +11,16 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function AllAccountTransactions() {
const schedulesQuery = useMemo(() => schedulesViewQuery(), []);
const schedulesQuery = useMemo(() => getSchedulesQuery(), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -12,16 +12,16 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useOffBudgetAccounts } from '@desktop-client/hooks/useOffBudgetAccounts';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function OffBudgetAccountTransactions() {
const schedulesQuery = useMemo(() => schedulesViewQuery('offbudget'), []);
const schedulesQuery = useMemo(() => getSchedulesQuery('offbudget'), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -12,16 +12,16 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useOnBudgetAccounts } from '@desktop-client/hooks/useOnBudgetAccounts';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function OnBudgetAccountTransactions() {
const schedulesQuery = useMemo(() => schedulesViewQuery('onbudget'), []);
const schedulesQuery = useMemo(() => getSchedulesQuery('onbudget'), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -117,6 +117,10 @@ export function MobilePayeesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search

View File

@@ -33,7 +33,7 @@ export function PayeesList({
}: PayeesListProps) {
const { t } = useTranslation();
if (isLoading) {
if (isLoading && payees.length === 0) {
return (
<View
style={{
@@ -48,6 +48,29 @@ export function PayeesList({
);
}
if (payees.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>No payees found.</Trans>
</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
<Virtualizer layout={ListLayout}>
@@ -61,26 +84,6 @@ export function PayeesList({
overflow: 'auto',
}}
dependencies={[ruleCounts, isRuleCountsLoading]}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>No payees found.</Trans>
</Text>
</View>
)}
>
{payee => (
<PayeesListItem

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
@@ -15,8 +14,6 @@ import type { WithRequired } from 'loot-core/types/util';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { PayeeRuleCountLabel } from '@desktop-client/components/payees/PayeeRuleCountLabel';
export const ROW_HEIGHT = 55;
type PayeesListItemProps = {
ruleCount: number;
isRuleCountLoading?: boolean;
@@ -30,7 +27,6 @@ export function PayeesListItem({
isRuleCountLoading,
onDelete,
onViewRules,
style,
...props
}: PayeesListItemProps) {
const { t } = useTranslation();
@@ -45,22 +41,9 @@ export function PayeesListItem({
value={payee}
textValue={label}
actionsWidth={200}
style={composeRenderProps(style, propStyle => ({
height: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
actions={
!payee.transfer_acct && (
<View
style={{
flexDirection: 'row',
flex: 1,
height: ROW_HEIGHT,
width: '100%',
}}
>
<View style={{ flexDirection: 'row', flex: 1 }}>
<Button
variant="bare"
onPress={onViewRules}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -31,14 +31,20 @@ export function MobileRuleEditPage() {
const [rule, setRule] = useState<RuleEntity | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { data: schedules = [], isSuccess } = useSchedules({
query: rule?.id
? q('schedules').filter({ rule: rule.id, completed: false }).select('*')
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule,
const { schedules = [] } = useSchedules({
query: useMemo(
() =>
rule?.id
? q('schedules')
.filter({ rule: rule.id, completed: false })
.select('*')
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule
[rule?.id],
),
});
// Check if the current rule is linked to a schedule
const isLinkedToSchedule = isSuccess && schedules.length > 0;
const isLinkedToSchedule = schedules.length > 0;
// Load rule by ID if we're in edit mode
useEffect(() => {

View File

@@ -37,8 +37,8 @@ export function MobileRulesPage() {
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
});
const { data: { list: categories } = { list: [] } } = useCategories();
const { data: payees = [] } = usePayees();
@@ -179,6 +179,10 @@ export function MobileRulesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search

View File

@@ -1,5 +1,5 @@
import { GridList, ListLayout, Virtualizer } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { Text } from '@actual-app/components/text';
@@ -8,7 +8,7 @@ import { View } from '@actual-app/components/view';
import type { RuleEntity } from 'loot-core/types/models';
import { ROW_HEIGHT, RulesListItem } from './RulesListItem';
import { RulesListItem } from './RulesListItem';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
@@ -27,7 +27,7 @@ export function RulesList({
}: RulesListProps) {
const { t } = useTranslation();
if (isLoading) {
if (isLoading && rules.length === 0) {
return (
<View
style={{
@@ -42,12 +42,36 @@ export function RulesList({
);
}
if (rules.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
{t('No rules found. Create your first rule to get started!')}
</Text>
</View>
);
}
return (
<View style={{ flex: 1, overflow: 'auto' }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: ROW_HEIGHT,
estimatedRowHeight: 140,
padding: 0,
}}
>
<GridList
@@ -57,28 +81,6 @@ export function RulesList({
style={{
paddingBottom: MOBILE_NAV_HEIGHT,
}}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>
No rules found. Create your first rule to get started!
</Trans>
</Text>
</View>
)}
>
{rule => (
<RulesListItem

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -16,8 +16,6 @@ import { ActionExpression } from '@desktop-client/components/rules/ActionExpress
import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
export const ROW_HEIGHT = 150;
type RulesListItemProps = {
onDelete: () => void;
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
@@ -39,19 +37,13 @@ export function RulesListItem({
id={rule.id}
value={rule}
textValue={t('Rule {{id}}', { id: rule.id })}
style={composeRenderProps(style, propStyle => ({
minHeight: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
minHeight: ROW_HEIGHT,
width: '100%',
}}
>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -24,7 +24,6 @@ import { useFormat } from '@desktop-client/hooks/useFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -39,13 +38,12 @@ export function MobileSchedulesPage() {
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const {
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} } = {},
} = useScheduleStatus({ schedules });
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
const { data: payees = [] } = usePayees();
const { data: accounts = [] } = useAccounts();
@@ -71,7 +69,7 @@ export function MobileSchedulesPage() {
const dateStr = schedule.next_date
? monthUtilFormat(schedule.next_date, dateFormat)
: null;
const statusLabel = statusLookup[schedule.id];
const statusLabel = statuses.get(schedule.id);
return (
filterIncludes(schedule.name) ||
@@ -139,6 +137,10 @@ export function MobileSchedulesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search
@@ -155,8 +157,8 @@ export function MobileSchedulesPage() {
</View>
<SchedulesList
schedules={filteredSchedules}
isLoading={isSchedulesLoading || isScheduleStatusLoading}
statusLookup={statusLookup}
isLoading={isSchedulesLoading}
statuses={statuses}
onSchedulePress={handleSchedulePress}
onScheduleDelete={handleScheduleDelete}
hasCompletedSchedules={hasCompletedSchedules}

View File

@@ -6,10 +6,10 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { ScheduleStatusLookup } from 'loot-core/shared/schedules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
import { ROW_HEIGHT, SchedulesListItem } from './SchedulesListItem';
import { SchedulesListItem } from './SchedulesListItem';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
@@ -20,7 +20,7 @@ type SchedulesListEntry = ScheduleEntity | CompletedSchedulesItem;
type SchedulesListProps = {
schedules: readonly ScheduleEntity[];
isLoading: boolean;
statusLookup: ScheduleStatusLookup;
statuses: Map<ScheduleEntity['id'], ScheduleStatusType>;
onSchedulePress: (schedule: ScheduleEntity) => void;
onScheduleDelete: (schedule: ScheduleEntity) => void;
hasCompletedSchedules?: boolean;
@@ -31,7 +31,7 @@ type SchedulesListProps = {
export function SchedulesList({
schedules,
isLoading,
statusLookup,
statuses,
onSchedulePress,
onScheduleDelete,
hasCompletedSchedules = false,
@@ -44,8 +44,9 @@ export function SchedulesList({
const listItems: readonly SchedulesListEntry[] = shouldShowCompletedItem
? [...schedules, { id: 'show-completed' }]
: schedules;
const showCompletedLabel = t('Show completed schedules');
if (isLoading) {
if (isLoading && listItems.length === 0) {
return (
<View
style={{
@@ -60,51 +61,54 @@ export function SchedulesList({
);
}
if (listItems.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
paddingLeft: 10,
paddingRight: 10,
}}
>
{t('No schedules found. Create your first schedule to get started!')}
</Text>
</View>
);
}
return (
<View style={{ flex: 1, overflow: 'auto' }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: ROW_HEIGHT,
estimatedRowHeight: 140,
padding: 0,
}}
>
<GridList
aria-label={t('Schedules')}
aria-busy={isLoading || undefined}
items={listItems}
dependencies={[statusLookup]}
style={{
paddingBottom: MOBILE_NAV_HEIGHT,
}}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>
No schedules found. Create your first schedule to get started!
</Trans>
</Text>
</View>
)}
>
{item =>
!('completed' in item) ? (
<ActionableGridListItem
id="show-completed"
value={item}
textValue={t('Show completed schedules')}
textValue={showCompletedLabel}
onAction={onShowCompleted}
>
<View style={{ width: '100%', alignItems: 'center' }}>
@@ -121,7 +125,7 @@ export function SchedulesList({
) : (
<SchedulesListItem
value={item}
status={statusLookup[item.id] || 'scheduled'}
status={statuses.get(item.id) || 'scheduled'}
onAction={() => onSchedulePress(item)}
onDelete={() => onScheduleDelete(item)}
/>

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
@@ -11,8 +10,8 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { format as monthUtilFormat } from 'loot-core/shared/months';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
import type { WithRequired } from 'loot-core/types/util';
@@ -22,11 +21,9 @@ import { DisplayId } from '@desktop-client/components/util/DisplayId';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
export const ROW_HEIGHT = 110;
type SchedulesListItemProps = {
onDelete: () => void;
status: ScheduleStatus;
status: ScheduleStatusType;
} & WithRequired<GridListItemProps<ScheduleEntity>, 'value'>;
export function SchedulesListItem({
@@ -53,19 +50,13 @@ export function SchedulesListItem({
id={schedule.id}
value={schedule}
textValue={schedule.name || t('Unnamed schedule')}
style={composeRenderProps(style, propStyle => ({
minHeight: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
minHeight: ROW_HEIGHT,
width: '100%',
}}
>

View File

@@ -320,7 +320,7 @@ type PayeeIconsProps = {
function PayeeIcons({ transaction, transferAccount }: PayeeIconsProps) {
const { id, schedule: scheduleId } = transaction;
const { isLoading: isSchedulesLoading, data: schedules = [] } =
const { isLoading: isSchedulesLoading, schedules = [] } =
useCachedSchedules();
const isPreview = isPreviewId(id);
const schedule = schedules.find(s => s.id === scheduleId);

View File

@@ -337,8 +337,9 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
onLoaded,
});
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
query: schedulesQuery,
});
const categories = useBudgetAutomationCategories();

View File

@@ -1,118 +0,0 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgArrowDown } from '@actual-app/components/icons/v1';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { Information } from '@desktop-client/components/alerts';
import {
Modal,
ModalButtons,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { usePayees } from '@desktop-client/hooks/usePayees';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
const mergePayeeStyle = {
padding: 10,
border: 'solid',
borderWidth: 1,
borderRadius: 6,
borderColor: theme.tableBorder,
backgroundColor: theme.tableBackground,
};
const targetPayeeStyle = {
...mergePayeeStyle,
backgroundColor: theme.tableRowBackgroundHighlight,
};
type ConfirmPayeesMergeModalProps = Extract<
ModalType,
{ name: 'confirm-payees-merge' }
>['options'];
export function ConfirmPayeesMergeModal({
payeeIds,
targetPayeeId,
onConfirm,
}: ConfirmPayeesMergeModalProps) {
const { t } = useTranslation();
const { data: allPayees = [] } = usePayees();
const mergePayees = allPayees.filter(p => payeeIds.includes(p.id));
const targetPayee = allPayees.find(p => p.id === targetPayeeId);
if (!targetPayee || mergePayees.length === 0) {
return null;
}
return (
<Modal name="confirm-payees-merge">
{({ state }) => (
<>
<ModalHeader title={t('Confirm Merge')} />
<View style={{ maxWidth: 500, marginTop: 20 }}>
<View
style={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 20,
}}
>
<View style={{ width: '100%', flexDirection: 'column', gap: 10 }}>
{mergePayees.map(payee => (
<View style={mergePayeeStyle} key={payee.id}>
<Text>{payee.name}</Text>
</View>
))}
</View>
<SvgArrowDown width={20} height={20} />
<View style={{ width: '100%' }}>
<View style={targetPayeeStyle}>
<Text
style={{
fontWeight: 700,
color: theme.tableRowBackgroundHighlightText,
}}
>
{targetPayee.name}
</Text>
</View>
</View>
</View>
<Information style={{ marginTop: 20 }}>
<Trans>
Merging will delete the selected payee(s) and transfer any
associated rules to the target payee.
</Trans>
</Information>
<ModalButtons style={{ marginTop: 20 }} focusButton>
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
style={{ marginRight: 10 }}
onPress={async () => {
onConfirm?.();
state.close();
}}
>
<Trans>Merge</Trans>
</Button>
</ModalButtons>
</View>
</>
)}
</Modal>
);
}

View File

@@ -85,8 +85,24 @@ export function CreateAccountModal({
try {
const results = await send('simplefin-accounts');
if (results.error_code === 'INVALID_ACCESS_TOKEN') {
setLoadingSimpleFinAccounts(false);
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
return;
}
if (results.error_code) {
throw new Error(results.reason);
throw new Error(
results.reason || `SimpleFIN error: ${results.error_code}`,
);
}
const newAccounts = [];
@@ -127,12 +143,12 @@ export function CreateAccountModal({
} catch (err) {
console.error(err);
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact SimpleFIN'),
message: (err as Error).message || t('An unknown error occurred.'),
timeout: 10000,
},
}),
);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -9,6 +9,7 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { format } from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import {
extractScheduleConds,
scheduleIsRecurring,
@@ -21,7 +22,7 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useSchedule } from '@desktop-client/hooks/useSchedule';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type ScheduledTransactionMenuModalProps = Extract<
@@ -43,12 +44,19 @@ export function ScheduledTransactionMenuModal({
borderTop: `1px solid ${theme.pillBorder}`,
};
const scheduleId = transactionId?.split('/')?.[1];
const { isPending: isSchedulesLoading, data: schedule } =
useSchedule(scheduleId);
if (isSchedulesLoading || !schedule) {
const schedulesQuery = useMemo(
() => q('schedules').filter({ id: scheduleId }).select('*'),
[scheduleId],
);
const { isLoading: isSchedulesLoading, schedules } = useSchedules({
query: schedulesQuery,
});
if (isSchedulesLoading) {
return null;
}
const schedule = schedules?.[0];
const { date: dateCond } = extractScheduleConds(schedule._conditions);
const canBeSkipped = scheduleIsRecurring(dateCond);
@@ -59,7 +67,7 @@ export function ScheduledTransactionMenuModal({
{({ state }) => (
<>
<ModalHeader
title={<ModalTitle title={schedule.name || ''} shrinkOnOverflow />}
title={<ModalTitle title={schedule?.name || ''} shrinkOnOverflow />}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View
@@ -73,7 +81,7 @@ export function ScheduledTransactionMenuModal({
<Trans>Scheduled date</Trans>
</Text>
<Text style={{ fontSize: 17, fontWeight: 700 }}>
{format(schedule.next_date || '', 'MMMM dd, yyyy', locale)}
{format(schedule?.next_date || '', 'MMMM dd, yyyy', locale)}
</Text>
</View>
<ScheduledTransactionMenu

View File

@@ -191,25 +191,9 @@ export const ManagePayees = ({
async function onMerge() {
const ids = [...selected.items];
if (ids.length < 2) return;
await props.onMerge(ids);
const targetPayeeId = ids[0];
dispatch(
pushModal({
modal: {
name: 'confirm-payees-merge',
options: {
payeeIds: ids.filter(id => id !== targetPayeeId),
targetPayeeId,
onConfirm: async () => {
await props.onMerge(ids);
selected.dispatch({ type: 'select-none' });
},
},
},
}),
);
selected.dispatch({ type: 'select-none' });
}
const onChangeCategoryLearning = useCallback(() => {

View File

@@ -697,7 +697,6 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
onMakeAsNonSplitTransactions={() => {}}
showSelection={false}
allowSplitTransaction={false}
allowReorder={false}
/>
</SplitsExpandedProvider>
) : (

View File

@@ -46,7 +46,6 @@ import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useRuleConditionFilters } from '@desktop-client/hooks/useRuleConditionFilters';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useUpdateDashboardWidgetMutation } from '@desktop-client/reports/mutations';
@@ -74,9 +73,6 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const format = useFormat();
const [budgetTypePref] = useSyncedPref('budgetType');
const budgetType: 'envelope' | 'tracking' =
budgetTypePref === 'tracking' ? 'tracking' : 'envelope';
const {
conditions,
@@ -149,9 +145,8 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
conditionsOp,
compare,
compareTo,
budgetType,
}),
[conditions, conditionsOp, compare, compareTo, budgetType],
[conditions, conditionsOp, compare, compareTo],
);
const data = useReport('default', getGraphData);

View File

@@ -21,7 +21,6 @@ import { createSpendingSpreadsheet } from '@desktop-client/components/reports/sp
import { useDashboardWidgetCopyMenu } from '@desktop-client/components/reports/useDashboardWidgetCopyMenu';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
type SpendingCardProps = {
widgetId: string;
@@ -42,9 +41,6 @@ export function SpendingCard({
}: SpendingCardProps) {
const { t } = useTranslation();
const format = useFormat();
const [budgetTypePref] = useSyncedPref('budgetType');
const budgetType: 'envelope' | 'tracking' =
budgetTypePref === 'tracking' ? 'tracking' : 'envelope';
const [isCardHovered, setIsCardHovered] = useState(false);
const [nameMenuOpen, setNameMenuOpen] = useState(false);
@@ -64,9 +60,8 @@ export function SpendingCard({
conditionsOp: meta?.conditionsOp,
compare,
compareTo,
budgetType,
});
}, [meta?.conditions, meta?.conditionsOp, compare, compareTo, budgetType]);
}, [meta?.conditions, meta?.conditionsOp, compare, compareTo]);
const data = useReport('default', getGraphData);
const todayDay =

View File

@@ -20,7 +20,6 @@ type createSpendingSpreadsheetProps = {
conditionsOp?: string;
compare?: string;
compareTo?: string;
budgetType?: 'envelope' | 'tracking';
};
export function createSpendingSpreadsheet({
@@ -28,7 +27,6 @@ export function createSpendingSpreadsheet({
conditionsOp,
compare,
compareTo,
budgetType = 'envelope',
}: createSpendingSpreadsheetProps) {
const startDate = monthUtils.subMonths(compare, 3) + '-01';
const endDate = monthUtils.getMonthEnd(compare + '-01');
@@ -115,11 +113,9 @@ export function createSpendingSpreadsheet({
const combineDebts = [...debts, ...overlapDebts];
const budgetMonth = parseInt(compare.replace('-', ''));
const budgetTable =
budgetType === 'tracking' ? 'reflect_budgets' : 'zero_budgets';
const [budgets] = await Promise.all([
aqlQuery(
q(budgetTable)
q('zero_budgets')
.filter({
$and: [{ month: { $eq: budgetMonth } }],
})

View File

@@ -40,7 +40,7 @@ import {
parse,
unparse,
} from 'loot-core/shared/rules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type {
NewRuleEntity,
RuleActionEntity,
@@ -58,8 +58,7 @@ import { GenericInput } from '@desktop-client/components/util/GenericInput';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useSchedule } from '@desktop-client/hooks/useSchedule';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import {
SelectedProvider,
useSelected,
@@ -366,22 +365,27 @@ function ScheduleDescription({ id }) {
const { isNarrowWidth } = useResponsive();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const format = useFormat();
const { data: schedule, isLoading: isSchedulesLoading } = useSchedule(id);
const scheduleQuery = useMemo(
() => q('schedules').filter({ id }).select('*'),
[id],
);
const {
data: { statusLookup = {} } = {},
isLoading: isScheduleStatusLoading,
} = useScheduleStatus({ schedules: [schedule] });
schedules,
statusLabels,
isLoading: isSchedulesLoading,
} = useSchedules({ query: scheduleQuery });
if (isSchedulesLoading || isScheduleStatusLoading) {
if (isSchedulesLoading) {
return null;
}
if (!schedule) {
const [schedule] = schedules;
if (schedule && schedules.length === 0) {
return <View style={{ flex: 1 }}>{id}</View>;
}
const status = statusLookup[schedule.id] as ScheduleStatus;
const status = statusLabels.get(schedule.id) as ScheduleStatusType;
return (
<View

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
@@ -20,11 +20,10 @@ type ScheduleValueProps = {
export function ScheduleValue({ value }: ScheduleValueProps) {
const { t } = useTranslation();
const { data: byId = {} } = usePayeesById();
const { data: schedules = [], isLoading: isSchedulesLoading } = useSchedules({
query: q('schedules').select('*'),
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules = [], isLoading } = useSchedules({ query: schedulesQuery });
if (isSchedulesLoading) {
if (isLoading) {
return (
<View aria-label={t('Loading...')} style={{ display: 'inline-flex' }}>
<AnimatedLoading width={10} height={10} />

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -10,7 +10,6 @@ import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { q } from 'loot-core/shared/query';
import type { ScheduleEntity } from 'loot-core/types/models';
import { ROW_HEIGHT, SchedulesTable } from './SchedulesTable';
@@ -21,7 +20,6 @@ import {
} from '@desktop-client/components/common/Modal';
import { Search } from '@desktop-client/components/common/Search';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -41,23 +39,24 @@ export function ScheduleLink({
const dispatch = useDispatch();
const [filter, setFilter] = useState(accountName || '');
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').filter({ completed: false }).select('*'),
});
const schedulesQuery = useMemo(
() => q('schedules').filter({ completed: false }).select('*'),
[],
);
const {
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} },
} = useScheduleStatus({ schedules });
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
const searchInput = useRef<HTMLInputElement | null>(null);
async function onSelect(schedule: ScheduleEntity) {
async function onSelect(scheduleId: string) {
if (ids?.length > 0) {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: schedule.id })),
updated: ids.map(id => ({ id, schedule: scheduleId })),
});
onScheduleLinked?.(schedule);
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
}
}
@@ -144,16 +143,16 @@ export function ScheduleLink({
}}
>
<SchedulesTable
isLoading={isSchedulesLoading || isScheduleStatusLoading}
isLoading={isSchedulesLoading}
allowCompleted={false}
filter={filter}
minimal
onSelect={schedule => {
void onSelect(schedule);
onSelect={id => {
void onSelect(id);
state.close();
}}
schedules={schedules}
statusLookup={statusLookup}
statuses={statuses}
style={null}
/>
</View>

View File

@@ -17,8 +17,8 @@ import { format as monthUtilFormat } from 'loot-core/shared/months';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import type {
ScheduleStatus,
ScheduleStatusLookup,
ScheduleStatuses,
ScheduleStatusType,
} from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
@@ -39,14 +39,13 @@ import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { usePayees } from '@desktop-client/hooks/usePayees';
type SchedulesTableProps = {
isLoading?: boolean;
schedules: readonly ScheduleEntity[];
statusLookup: ScheduleStatusLookup;
statuses: ScheduleStatuses;
filter: string;
allowCompleted: boolean;
onSelect: (schedule: ScheduleEntity) => void;
onSelect: (id: ScheduleEntity['id']) => void;
style: CSSProperties;
tableStyle?: CSSProperties;
} & (
@@ -82,7 +81,7 @@ function OverflowMenu({
onAction,
}: {
schedule: ScheduleEntity;
status: ScheduleStatus;
status: ScheduleStatusType;
onAction: SchedulesTableProps['onAction'];
}) {
const { t } = useTranslation();
@@ -204,13 +203,15 @@ function ScheduleRow({
onAction,
onSelect,
minimal,
statusLookup,
statuses,
dateFormat,
}: {
schedule: ScheduleEntity;
statusLookup: ScheduleStatusLookup;
dateFormat: string;
} & Pick<SchedulesTableProps, 'onSelect' | 'onAction' | 'minimal'>) {
} & Pick<
SchedulesTableProps,
'onSelect' | 'onAction' | 'minimal' | 'statuses'
>) {
const { t } = useTranslation();
const rowRef = useRef(null);
@@ -229,7 +230,7 @@ function ScheduleRow({
ref={rowRef}
height={ROW_HEIGHT}
inset={15}
onClick={() => onSelect(schedule)}
onClick={() => onSelect(schedule.id)}
style={{
cursor: 'pointer',
backgroundColor: theme.tableBackground,
@@ -250,7 +251,7 @@ function ScheduleRow({
>
<OverflowMenu
schedule={schedule}
status={statusLookup[schedule.id]}
status={statuses.get(schedule.id)}
onAction={(action, id) => {
onAction(action, id);
resetPosition();
@@ -283,7 +284,7 @@ function ScheduleRow({
: null}
</Field>
<Field width={120} name="status" style={{ alignItems: 'flex-start' }}>
<StatusBadge status={statusLookup[schedule.id]} />
<StatusBadge status={statuses.get(schedule.id)} />
</Field>
<ScheduleAmountCell amount={schedule._amount} op={schedule._amountOp} />
{!minimal && (
@@ -323,7 +324,7 @@ function ScheduleRow({
export function SchedulesTable({
isLoading,
schedules,
statusLookup,
statuses,
filter,
minimal,
allowCompleted,
@@ -370,11 +371,11 @@ export function SchedulesTable({
filterIncludes(payee && payee.name) ||
filterIncludes(account && account.name) ||
filterIncludes(amountStr) ||
filterIncludes(statusLookup[schedule.id]) ||
filterIncludes(statuses.get(schedule.id)) ||
filterIncludes(dateStr)
);
});
}, [payees, accounts, schedules, filter, statusLookup, format, dateFormat]);
}, [payees, accounts, schedules, filter, statuses, format, dateFormat]);
const items: readonly SchedulesTableItem[] = useMemo(() => {
const unCompletedSchedules = filteredSchedules.filter(s => !s.completed);
@@ -422,7 +423,7 @@ export function SchedulesTable({
return (
<ScheduleRow
schedule={item as ScheduleEntity}
{...{ statusLookup, dateFormat, onSelect, onAction, minimal }}
{...{ statuses, dateFormat, onSelect, onAction, minimal }}
/>
);
}

View File

@@ -15,12 +15,12 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { getStatusLabel } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import { titleFirst } from 'loot-core/shared/util';
// Consists of Schedule Statuses + Transaction statuses
export type ScheduleTransactionStatus =
| ScheduleStatus
export type StatusTypes =
| ScheduleStatusType
| 'cleared'
| 'pending'
| 'reconciled';
@@ -31,7 +31,7 @@ export const defaultStatusProps = {
Icon: SvgCheckCircleHollow,
};
export function getStatusProps(status?: ScheduleTransactionStatus | null) {
export function getStatusProps(status: StatusTypes | null | undefined) {
switch (status) {
case 'missed':
return {
@@ -92,7 +92,7 @@ export function getStatusProps(status?: ScheduleTransactionStatus | null) {
}
}
export function StatusBadge({ status }: { status: ScheduleTransactionStatus }) {
export function StatusBadge({ status }: { status: ScheduleStatusType }) {
const { color, backgroundColor, Icon } = getStatusProps(status);
return (
<View

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -15,7 +15,6 @@ import type { ScheduleItemAction } from './SchedulesTable';
import { Search } from '@desktop-client/components/common/Search';
import { Page } from '@desktop-client/components/Page';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -26,11 +25,9 @@ export function Schedules() {
const [filter, setFilter] = useState('');
const onEdit = useCallback(
(schedule: ScheduleEntity) => {
(id: ScheduleEntity['id']) => {
dispatch(
pushModal({
modal: { name: 'schedule-edit', options: { id: schedule.id } },
}),
pushModal({ modal: { name: 'schedule-edit', options: { id } } }),
);
},
[dispatch],
@@ -81,14 +78,12 @@ export function Schedules() {
[],
);
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const {
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} } = {},
} = useScheduleStatus({ schedules });
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
return (
<Page header={t('Schedules')}>
@@ -115,10 +110,10 @@ export function Schedules() {
</View>
<SchedulesTable
isLoading={isSchedulesLoading || isScheduleStatusLoading}
isLoading={isSchedulesLoading}
schedules={schedules}
filter={filter}
statusLookup={statusLookup}
statuses={statuses}
allowCompleted
onSelect={onEdit}
onAction={onAction}

View File

@@ -38,14 +38,12 @@ type ThemeInstallerProps = {
onInstall: (theme: InstalledTheme) => void;
onClose: () => void;
installedTheme?: InstalledTheme | null;
mode?: 'light' | 'dark';
};
export function ThemeInstaller({
onInstall,
onClose,
installedTheme,
mode,
}: ThemeInstallerProps) {
const { t } = useTranslation();
const [selectedCatalogTheme, setSelectedCatalogTheme] =
@@ -248,9 +246,9 @@ export function ThemeInstaller({
return null;
}
const catalogItems = [...(catalog ?? [])]
.filter(catalogTheme => !mode || catalogTheme.mode === mode)
.sort((a, b) => a.name.localeCompare(b.name));
const catalogItems = [...(catalog ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
);
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {

View File

@@ -285,11 +285,6 @@ export function ThemeSettings() {
? installedCustomDarkTheme
: installedCustomLightTheme
}
mode={
showInstaller === 'light' || showInstaller === 'dark'
? showInstaller
: undefined
}
/>
)}
</View>

View File

@@ -6,7 +6,6 @@ import React, {
useRef,
useState,
} from 'react';
import type { DropPosition as AriaDropPosition } from 'react-aria';
import { useDrag, useDrop } from 'react-dnd';
import { theme } from '@actual-app/components/theme';
@@ -151,9 +150,7 @@ type ItemPosition = 'first' | 'last' | null;
export const DropHighlightPosContext = createContext<ItemPosition>(null);
type DropHighlightProps = {
// Supports legacy ('top'/'bottom') and react-aria ('before'/'after'/'on') positions
// 'on' is not used in our UI but is included for type compatibility
pos: DropPosition | AriaDropPosition | null;
pos: DropPosition;
offset?: {
top?: number;
bottom?: number;
@@ -162,17 +159,15 @@ type DropHighlightProps = {
export function DropHighlight({ pos, offset }: DropHighlightProps) {
const itemPos = useContext(DropHighlightPosContext);
// 'on' position is not supported for highlight (used for dropping onto items, not between)
if (pos == null || pos === 'on') {
if (pos == null) {
return null;
}
const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0);
const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0);
// Support both legacy ('top'/'bottom') and aria ('before'/'after') position names
const isTop = pos === 'top' || pos === 'before';
const posStyle = isTop ? { top: topOffset } : { bottom: bottomOffset };
const posStyle =
pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset };
return (
<View

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