Compare commits

..

20 commits
master ... i18n

Author SHA1 Message Date
Tom French f5d9f30e17 Revert "fix: correct "_many" translations"
This reverts commit adbaf27859.
2022-09-08 16:31:37 +01:00
Tom French adbaf27859 fix: correct "_many" translations
Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>
2022-09-08 15:18:08 +01:00
Jed Fox 5217835c55
Implement localization for schedule descriptions (#225)
* monthUtils.{format → nonLocalizedFormat}

* Implement localization for schedule descriptions

* Remove outdated comment

* Add general.ordinal in Spanish

Co-Authored-By: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>

* yay time zones?

* fix: re-add missing keys

* fix: fix broken i18n imports/initialisation

* style: linting

* fix: re-add english ordinal keys

* fix: add remaining english ordinal keys

* fix: correct dates in schedules.js

* refactor: store translations keys for loot-core in loot-core

* fix: add ns to i18n.t calls directly so parser can find them

* feat: add spanish translation from manuelcanepa

* fix: add comments to help i18n-parser to find contexts

* fix: add "many" context to spanish translations

Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>
Co-authored-by: Tom French <tom@tomfren.ch>
2022-09-08 14:37:45 +01:00
Manuel Eduardo Cánepa Cihuelo 6fb497dec5
Adding translation to rule editor and transaction table (#224)
* #199 Adding translation to rule editor and transaction table

* Feature: Translation to discover schedule table

Fix: Some translation improvements

* fix: Fix minor after check

* Feature: More translation to account

Fix: Add *_old.json files to ignore

* Update packages/desktop-client/src/components/accounts/Account.js

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>

* fix: Workaround for know caveats

* lint: fix import order

* fix: t is not a function when empty transactions list

* Feature: Translate account filters

* Feature: Translation on transactions table

* Feature: Translate budget and the rest of bootstrap

* Update packages/desktop-client/src/locales/es-ES.json

Co-authored-by: Jed Fox <git@jedfox.com>

* fix: Using the new key for unknow error

* refactor: push useTranslation up above function definition, etc

* refactor: push useTranslation up above function definition

* refactor: set key for Trans component balanceType

* refactor: pass i18keys to Trans components explicitly

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>
Co-authored-by: Jed Fox <git@jedfox.com>
2022-09-06 10:22:22 +01:00
Tom French cbf1e18299 style: linting 2022-09-02 15:12:24 +01:00
Tom French 11186c9374 Merge branch 'master' into i18n
* master:
  Sort import in alphabetical order (#238)
  Separate external, monorepo and internal imports (#237)
  Allow `enter` to create new transaction when focused on `cleared` column (#234)
  Enforce linting in loot-design (#233)
  style: run linter (#232)
  refactor: create index.js for aql directory (#68)
  Revert "build: update yarn.lock" (#230)
  Fix handling of -0 in budget summary (#229)
  Update bug-report.yml (#228)
2022-09-02 15:10:56 +01:00
Tom French 953846732c style: remove setting of indent size to 4 in i18n-parser config 2022-08-31 00:21:17 +01:00
Tom French 55049da705 refactor: use i18n-next style of plurals 2022-08-31 00:20:33 +01:00
Tom French 618dd0f27f Merge branch 'master' into i18n
* master:
  fix: use correct comment style
  build: remove patch-package dependency from loot-design
  Conditionally set MSYS
  build: update yarn.lock
  build: use workspace ranges for monorepo dependencies
  changes needed to build on windows
2022-08-30 23:24:43 +01:00
Manuel Eduardo Cánepa Cihuelo e436c01430
#199 Adding translation to schedules list (#219)
* #199 Adding translation to schedules list

* #199 Complete translation on EditSchedule Form

* #199 Translation for status badge

* #199 Minor changes, suggested by @j-f1

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>
2022-08-30 23:22:43 +01:00
Tom French 304a384b6c style: format json with 2 space indents 2022-08-30 13:40:33 +01:00
Tom French b0f0c4a71d ci: fix workspace name in i18n job 2022-08-30 11:19:27 +01:00
Tom French 1fd3234613 ci: correct i18n job name 2022-08-30 11:16:19 +01:00
Tom French a4fe21927d feat: fallback to english rather than showing translation key 2022-08-30 11:14:00 +01:00
Tom French 5c56370920 feat: prevent translation keys which don't exist in locale files 2022-08-30 11:12:19 +01:00
Tom French 43740f18f1 Merge branch 'master' into i18n
* master: (24 commits)
  refactor: sort imports in desktop-client alphabetically
  chore: remove unused imports from desktop-client
  adm-zip to 0.5.6
  style: prettify .eslintrc.js
  build: make eslint-plugin-prettier a dependency of desktop-client
  ci: lint desktop-client in CI
  style: fix or silence linting errors in desktop-client
  chore: update remaining test scripts
  github fix indentation in issue template
  github: update issue template
  fix: correct some re-exports which were breaking things
  fix: stop trying to transform node_modules
  fix: stop the web tests from running in node environment
  fix: add ts-jest presets which are equivalent to the old `transform` properties
  fix: add esModuleInterop so that the default imports issue goes away
  fix: stop typechecking javascript files
  chore: allow mobile package to pass with no tests
  build: replace babel-jest with ts-jest
  Update bug-report.yml
  Addition: Issue template
  ...
2022-08-30 10:24:17 +01:00
Tom French 2d025d8b08 fix: get bold tags to display correctly 2022-08-24 17:22:48 +01:00
Tom French dd9d32a6ed fix: ensure that translation keys match 2022-08-24 17:21:03 +01:00
Manuel Canepa 9b3dbd187f #199 Adding some translation to check if im doing right 2022-08-24 00:18:07 -03:00
Tom French fd0d30c07c feat: add skeleton of i18n framework 2022-08-24 01:04:08 +01:00
89 changed files with 68703 additions and 659 deletions

View file

@ -6,7 +6,7 @@ runs:
- name: Install node
uses: actions/setup-node@v1
with:
node-version: 16.15.0
node-version: 16
- name: Cache
uses: actions/cache@v2
id: cache

18
.github/workflows/i18n.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: i18n
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
check-keys:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up environment
uses: ./.github/actions/setup
- name: Check i18n keys
run: yarn workspaces foreach --verbose run check-i18n --fail-on-update

View file

@ -1,22 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
days-before-issue-stale: 90
days-before-issue-close: -1
stale-issue-label: "stale"
stale-issue-message: "🚧🚨 This issue is being marked as stale due to 90 days of inactivity. 🚧🚨"
days-before-pr-stale: -1
days-before-pr-close: -1
only-labels: 'needs triage'
repo-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,39 +1,72 @@
## Getting Started
This is the source code for [Actual](https://actualbudget.com), a local-first personal finance tool. It is 100% free and open-source.
Actual is a local-first personal finance tool. It is 100% free and open-source, written in NodeJS, it has a synchronization element so that all your changes can move between devices without any heavy lifting.
If you are only interested in running the latest version, you don't need this repo. You can get the latest version through npm.
If you are interested in contributing, or want to know how development works, see [CONTRIBUTING.md](https://github.com/actualbudget/actual/blob/master/CONTRIBUTING.md) we would love to have you.
More docs are available in the [docs](https://github.com/actualbudget/actual/tree/master/docs) folder.
Want to say thanks? Click the ⭐ at the top of the page.
If you are interested in contributing, or want to know how development works, see [CONTRIBUTING.md](https://github.com/actualbudget/actual/blob/master/CONTRIBUTING.md)
## Key Links
* Actual [discord](https://discord.gg/pRYNYr4W5A) community.
* Actual [Community Documentation](https://actualbudget.github.io/docs)
Join the [discord](https://discord.gg/pRYNYr4W5A)!
## Installation
If you are only interested in running the latest version and not contributing to the source code, you don't need to clone this repo. You can get the latest version through npm.
**Please Note:** While the Actual repository holds source code for the mobile applications that were supported when Actual was closed source, these are no longer supported on the Open Source version of Actual.
### The easy way: using a server (recommended)
The easiest way to get Actual running is to use the [actual-server](https://github.com/actualbudget/actual-server) project. That is the server for syncing changes across devices, and it comes with the latest version of Actual. The server will provide both the web project and a server for syncing.
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.github.io/docs/Installing/Local/your-own-machine)
```
git clone https://github.com/actualbudget/actual-server.git
cd actual-server
yarn install
yarn start
```
## Documentation
Navigate to https://localhost:5006 in your browser and you will see Actual.
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.github.io/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
You should deploy the server somewhere so you can access your data from anywhere. See instructions on the [actual-server](https://github.com/actualbudget/actual-server) repo.
### Without a server
This will give you a fully local web app without a server. This npm package is the `packages/desktop-client` package in this repo built for production:
```
yarn add @actual-app/web
```
Now you need to serve the files in `node_modules/@actual-app/web/build`. One way to do it:
```
cd node_modules/@actual-app/web/build
npx http-server .
```
Navigate to http://localhost:8080 and you should see Actual.
## Building
If you want to build the latest version, see [releasing.md](https://github.com/actualbudget/actual/blob/master/docs/releasing.md). It provides instructions for building this code into the same artifacts that come from npm.
## Run locally
Both the electron and web app can started with a single command. When running in development, it will store data in a `data` directory in the root of the `actual` directory.
First, make sure to run `yarn install` to install all dependencies.
In the root of the project:
```
yarn start # Run the electron app
yarn start:browser # Run the web app
```
## Code structure
The Actual app is split up into a few packages:
The app is split up into a few packages:
* loot-core - The core application that runs on any platform
* loot-design - The generic design components that make up the UI
* desktop-client - The desktop UI
* desktop-electron - The desktop app
* mobile - The mobile app
More information on the project structure is available in our [community documentation](https://actualbudget.github.io/docs/Developers/project-layout).
More docs are available in the [docs](https://github.com/actualbudget/actual/tree/master/docs) folder.

20
docs/API.md Normal file
View file

@ -0,0 +1,20 @@
Previous docs for the API are [here](https://actualbudget.com/docs/developers/using-the-API/). The API is currently being improved. Previously, the API connected to an existing running instance of Actual. Now the API is bundled and fully isolated, capable of running all of Actual itself. Setting up the API is different because of this.
You need to call `init` and pass it the directory where your files live. Call `load-budget` to load the file you want to work on. After that, you can use the same API as before.
Example:
```js
let actual = require('@actual-app/api');
await actual.init({
config: {
dataDir: join(__dirname, 'user-files')
}
});
await actual.internal.send('load-budget', { id: 'My-Finances' });
await actual.getAccounts();
```

View file

@ -0,0 +1,18 @@
# How to build browser for Windows
Many of the build scripts are bash scripts and not natively invokable in Windows. To solve this, you can build the project using Git Bash.
1. Install [Git & Git Bash for Windows](https://git-scm.com/downloads)
2. Install Node v16.x (latest version 17.x does not work due to issue with crypto package)
3. Clone this repo
4. From the root of this repo, run `sh` to launch a bash shell
5. From inside the bash shell, run `yarn install`
6. From still inside the shell, run `yarn start:browser`
7. Open your browser to `localhost:3001`
# How to build electron for Windows
1. Follow steps 1 - 5 above.
2. Run `yarn start`
3. If you get an error from electron, run `yarn rebuild-electron` and rerun `yarn start`;
## rsync: command not found
If you run into this error, you will need to install the rsync binary to Git Bash. Follow the [directions here](https://prasaz.medium.com/add-rsync-to-windows-git-bash-f42736bae1b3). When you get to the final step - installing the libxxhash dll - rename the dll from `msys-xxhash-0.8.0.dll` to `msys-xxhash-0.dll`

44
docs/releasing.md Normal file
View file

@ -0,0 +1,44 @@
# How to cut a release
In the open-source version of Actual, all updates go through npm. There are two libraries:
* `@actual-app/api`: The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with node.
* `@actual-app/web`: A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
Both the API and web libraries are versioned together. This may change in the future, but because the web library also brings along its own backend it's easier to maintain a single version for now. That makes it clear which version the backend is regardless of library.
## Releasing `@actual-app/api`
This generates a bundle for the API:
```
cd packages/loot-core
yarn build:api
```
The built files live in `lib-dist`, so we need to copy them to the API package:
```
cp lib-dist/bundle.api* ../api/app
```
Next, bump the version on package.json. Finally, publish it:
```
npm publish
```
## Releasing `@actual-app/web`
In the root of `actual` (not just `desktop-client`), run this:
```
./bin/package-browser
```
This will compile both the backend and the frontend into a single directory in `packages/desktop-client/build`. This directory is all the files that need to be published. After bumping the version, publish `desktop-client`:
```
cd packages/desktop-client
npm publish
```

View file

@ -1 +0,0 @@
app/bundle.api.js*

66420
packages/api/app/bundle.api.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,8 @@
let bundle = require('./app/bundle.api.js');
let injected = require('./injected');
let methods = require('./methods');
let utils = require('./utils');
let injected = require('./injected');
let actualApp;
async function init({ budgetId, config } = {}) {

View file

@ -1,18 +1,9 @@
{
"name": "@actual-app/api",
"version": "4.1.5",
"version": "4.0.2",
"license": "MIT",
"description": "An API for Actual",
"main": "index.js",
"files": [
"app",
"default-db.sqlite",
"index.js",
"injected.js",
"methods.js",
"migrations",
"utils.js"
],
"dependencies": {
"better-sqlite3": "^7.5.0",
"node-fetch": "^1.6.3",

View file

@ -1,5 +1,5 @@
function amountToInteger(n) {
return Math.round(n * 100);
return Math.round(n * 100) | 0;
}
function integerToAmount(n) {

View file

@ -16,3 +16,6 @@ npm-debug.log
*kcab.*
public/kcab
# Ignore auto generated dictionaries with check-i18n
src/locales/*_old.json

View file

@ -0,0 +1,16 @@
module.exports = {
input: ['src/**/*.js'],
output: 'src/locales/$LOCALE.json',
locales: ['en-GB', 'es-ES'],
defaultNamespace: 'web',
sort: true,
// Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components.
lexers: {
js: ['JsxLexer'],
ts: ['JsxLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JsxLexer']
}
};

View file

@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "22.12.03",
"version": "4.0.2",
"license": "MIT",
"files": [
"build"
@ -42,6 +42,8 @@
"fs-extra": "7.0.0",
"glamor": "^2.20.40",
"html-webpack-plugin": "4.0.0-alpha.2",
"i18next": "^21.9.1",
"i18next-parser": "^6.5.0",
"identity-obj-proxy": "3.0.0",
"load-js": "^3.0.3",
"mini-css-extract-plugin": "0.4.3",
@ -61,6 +63,7 @@
"react-dev-utils": "^12.0.1",
"react-dnd": "^10.0.2",
"react-dom": "16.13.1",
"react-i18next": "^11.18.4",
"react-modal": "3.4.4",
"react-redux": "7.2.1",
"react-router": "5.2.0",
@ -86,7 +89,8 @@
"watch": "cross-env PORT=3001 node scripts/start.js",
"build": "cross-env INLINE_RUNTIME_CHUNK=false node scripts/build.js",
"build:browser": "cross-env ./bin/build-browser",
"lint": "eslint src"
"lint": "eslint src",
"check-i18n": "i18next"
},
"browserslist": [
"electron 3.0"

View file

@ -1,5 +1,4 @@
default-db.sqlite
migrations/.force-copy-windows
migrations/1548957970627_remove-db-version.sql
migrations/1550601598648_payees.sql
migrations/1555786194328_remove_category_group_unique.sql
@ -15,3 +14,4 @@ migrations/1615745967948_meta.sql
migrations/1616167010796_accounts_order.sql
migrations/1618975177358_schedules.sql
migrations/1632571489012_remove_cache.js
migrations/.force-copy-windows

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux';
import { Redirect, useParams, useHistory, useLocation } from 'react-router-dom';
@ -35,7 +36,6 @@ import {
Stack
} from 'loot-design/src/components/common';
import { KeyHandlers } from 'loot-design/src/components/KeyHandlers';
import NotesButton from 'loot-design/src/components/NotesButton';
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
import format from 'loot-design/src/components/spreadsheet/format';
import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue';
@ -68,6 +68,8 @@ import {
} from './TransactionsTable';
function EmptyMessage({ onAdd }) {
const { t } = useTranslation();
return (
<View
style={{
@ -87,17 +89,15 @@ function EmptyMessage({ onAdd }) {
}}
>
<Text style={{ textAlign: 'center', lineHeight: '1.4em' }}>
For Actual to be useful, you need to <strong>add an account</strong>.
You can link an account to automatically download transactions, or
manage it locally yourself.
<Trans>{'account.needAccountMessage'}</Trans>
</Text>
<Button primary style={{ marginTop: 20 }} onClick={onAdd}>
Add account
{t('account.addAccount')}
</Button>
<View style={{ marginTop: 20, fontSize: 13, color: colors.n5 }}>
In the future, you can add accounts from the sidebar.
{t('account.addAccountInFutureFromSidebar')}
</View>
</View>
</View>
@ -105,9 +105,10 @@ function EmptyMessage({ onAdd }) {
}
function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
const { t } = useTranslation();
let cleared = useSheetValue({
name: balanceQuery.name + '-cleared',
value: 0,
query: balanceQuery.query.filter({ cleared: true })
});
let targetDiff = targetBalance - cleared;
@ -144,27 +145,27 @@ function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
marginRight: 3
}}
/>
All reconciled!
{t('account.allReconciled')}
</View>
) : (
<View style={{ color: colors.n3 }}>
<Text style={{ fontStyle: 'italic', textAlign: 'center' }}>
Your cleared balance{' '}
<strong>{format(cleared, 'financial')}</strong> needs{' '}
<strong>
{(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')}
</strong>{' '}
to match
<br /> your bank{"'"}s balance of{' '}
<Text style={{ fontWeight: 700 }}>
{format(targetBalance, 'financial')}
</Text>
<Trans
i18nKey={'account.clearedBalance'}
values={{
cleared: format(cleared, 'financial'),
diff:
(targetDiff > 0 ? '+' : '') +
format(targetDiff, 'financial'),
balance: format(targetBalance, 'financial')
}}
/>
</Text>
</View>
)}
<View style={{ marginLeft: 15 }}>
<Button primary onClick={onDone}>
Done Reconciling
{t('account.doneReconciling')}
</Button>
</View>
</View>
@ -174,6 +175,7 @@ function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
function ReconcileTooltip({ account, onReconcile, onClose }) {
let balance = useSheetValue(queries.accountBalance(account));
const { t } = useTranslation();
function onSubmit(e) {
let input = e.target.elements[0];
@ -185,10 +187,7 @@ function ReconcileTooltip({ account, onReconcile, onClose }) {
return (
<Tooltip position="bottom-right" width={275} onClose={onClose}>
<View style={{ padding: '5px 8px' }}>
<Text>
Enter the current balance of your bank account that you want to
reconcile with:
</Text>
<Text>{t('account.enterCurrentBalanceToReconcileAdvice')}</Text>
<form onSubmit={onSubmit}>
{balance != null && (
<InitialFocus>
@ -198,7 +197,7 @@ function ReconcileTooltip({ account, onReconcile, onClose }) {
/>
</InitialFocus>
)}
<Button primary>Reconcile</Button>
<Button primary>{t('account.reconcile')}</Button>
</form>
</View>
</Tooltip>
@ -241,6 +240,7 @@ function AccountMenu({
onMenuSelect
}) {
let [tooltip, setTooltip] = useState('default');
const { t } = useTranslation();
return tooltip === 'reconcile' ? (
<ReconcileTooltip
@ -261,19 +261,21 @@ function AccountMenu({
items={[
canShowBalances && {
name: 'toggle-balance',
text: (showBalances ? 'Hide' : 'Show') + ' Running Balance'
text: showBalances
? t('account.hideRunningBalance')
: t('account.showRunningBalance')
},
{ name: 'export', text: 'Export' },
{ name: 'reconcile', text: 'Reconcile' },
{ name: 'export', text: t('general.export') },
{ name: 'reconcile', text: t('account.reconcile') },
syncEnabled &&
account &&
!account.closed &&
(canSync
? { name: 'unlink', text: 'Unlink Account' }
: { name: 'link', text: 'Link Account' }),
? { name: 'unlink', text: t('account.unlinkAccount') }
: { name: 'link', text: t('account.linkAccount') }),
account.closed
? { name: 'reopen', text: 'Reopen Account' }
: { name: 'close', text: 'Close Account' }
? { name: 'reopen', text: t('account.reopenAccount') }
: { name: 'close', text: t('account.closeAccount') }
].filter(x => x)}
/>
</MenuTooltip>
@ -281,19 +283,30 @@ function AccountMenu({
}
function CategoryMenu({ onClose, onMenuSelect }) {
const { t } = useTranslation();
return (
<MenuTooltip onClose={onClose}>
<Menu
onMenuSelect={item => {
onMenuSelect(item);
}}
items={[{ name: 'export', text: 'Export' }]}
items={[{ name: 'export', text: t('general.export') }]}
/>
</MenuTooltip>
);
}
function DetailedBalance({ name, balance }) {
const balanceType = {
// t('account.selectedBalance')
selected: 'account.selectedBalance',
// t('account.clearedTotal')
cleared: 'account.clearedTotal',
// t('account.unclearedTotal')
uncleared: 'account.unclearedTotal'
};
return (
<Text
style={{
@ -304,8 +317,12 @@ function DetailedBalance({ name, balance }) {
color: colors.n5
}}
>
{name}{' '}
<Text style={{ fontWeight: 600 }}>{format(balance, 'financial')}</Text>
<Trans
i18nKey={balanceType[name] || name}
values={{
amount: format(balance, 'financial')
}}
/>
</Text>
);
}
@ -340,7 +357,8 @@ function SelectedBalance({ selectedItems }) {
if (balance == null) {
return null;
}
return <DetailedBalance name="Selected balance:" balance={balance} />;
return <DetailedBalance name="selected" balance={balance} />;
}
function MoreBalances({ balanceQuery }) {
@ -355,8 +373,8 @@ function MoreBalances({ balanceQuery }) {
return (
<View style={{ flexDirection: 'row' }}>
<DetailedBalance name="Cleared total:" balance={cleared} />
<DetailedBalance name="Uncleared total:" balance={uncleared} />
<DetailedBalance name="cleared" balance={cleared} />
<DetailedBalance name="uncleared" balance={uncleared} />
</View>
);
}
@ -456,6 +474,7 @@ function SelectedTransactionsButton({
}) {
let selectedItems = useSelectedItems();
let history = useHistory();
const { t } = useTranslation();
let types = useMemo(() => {
let items = [...selectedItems];
@ -496,37 +515,43 @@ function SelectedTransactionsButton({
items={[
...(!types.trans
? [
{ name: 'view-schedule', text: 'View schedule' },
{ name: 'post-transaction', text: 'Post transaction' },
{ name: 'skip', text: 'Skip scheduled date' }
{ name: 'view-schedule', text: t('schedules.view_one') },
{
name: 'post-transaction',
text: t('schedules.postTransaction')
},
{ name: 'skip', text: t('schedules.skipScheduledDate') }
]
: [
{ name: 'show', text: 'Show', key: 'F' },
{ name: 'delete', text: 'Delete', key: 'D' },
{ name: 'show', text: t('general.show'), key: 'F' },
{ name: 'delete', text: t('general.delete'), key: 'D' },
...(linked
? [
{
name: 'view-schedule',
text: 'View schedule',
text: t('schedules.view_one'),
disabled: selectedItems.size > 1
},
{ name: 'unlink-schedule', text: 'Unlink schedule' }
{
name: 'unlink-schedule',
text: t('schedules.unlinkSchedule')
}
]
: [
{
name: 'link-schedule',
text: 'Link schedule'
text: t('schedules.linkSchedule')
}
]),
Menu.line,
{ type: Menu.label, name: 'Edit field' },
{ name: 'date', text: 'Date' },
{ name: 'account', text: 'Account', key: 'A' },
{ name: 'payee', text: 'Payee', key: 'P' },
{ name: 'notes', text: 'Notes', key: 'N' },
{ name: 'category', text: 'Category', key: 'C' },
{ name: 'amount', text: 'Amount' },
{ name: 'cleared', text: 'Cleared', key: 'L' }
{ type: Menu.label, name: t('general.editField') },
{ name: 'date', text: t('general.date') },
{ name: 'account', text: t('general.account_one'), key: 'A' },
{ name: 'payee', text: t('general.payee_one'), key: 'P' },
{ name: 'notes', text: t('general.note_other'), key: 'N' },
{ name: 'category', text: t('general.category_one'), key: 'C' },
{ name: 'amount', text: t('general.amount') },
{ name: 'cleared', text: t('account.cleared'), key: 'L' }
])
]}
onSelect={name => {
@ -618,6 +643,7 @@ const AccountHeader = React.memo(
let [menuOpen, setMenuOpen] = useState(false);
let searchInput = useRef(null);
let splitsExpanded = useSplitsExpanded();
const { t } = useTranslation();
let canSync = syncEnabled && account && account.account_id;
if (!account) {
@ -671,46 +697,30 @@ const AccountHeader = React.memo(
/>
</InitialFocus>
) : isNameEditable ? (
<View
<Button
bare
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 3,
'& .hover-visible': {
opacity: 0,
transition: 'opacity .25s'
},
'&:hover .hover-visible': {
opacity: 1
}
fontSize: 25,
fontWeight: 500,
marginLeft: -5,
marginTop: -5,
backgroundColor: 'transparent',
'& svg': { display: 'none' },
'&:hover svg': { display: 'unset' }
}}
onClick={() => onExposeName(true)}
>
<View
style={{
fontSize: 25,
fontWeight: 500,
marginRight: 5,
marginBottom: 5
}}
>
{accountName}
</View>
{accountName}
<NotesButton id={`account-${account.id}`} />
<Button
bare
className="hover-visible"
onClick={() => onExposeName(true)}
>
<Pencil1
style={{
width: 11,
height: 11,
color: colors.n8
}}
/>
</Button>
</View>
<Pencil1
style={{
width: 11,
height: 11,
marginLeft: 5,
color: colors.n4
}}
/>
</Button>
) : (
<View
style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }}
@ -746,7 +756,7 @@ const AccountHeader = React.memo(
}
style={{ color: 'currentColor', marginRight: 4 }}
/>{' '}
Sync
{t('general.sync')}
</>
) : (
<>
@ -755,7 +765,7 @@ const AccountHeader = React.memo(
height={13}
style={{ color: 'currentColor', marginRight: 4 }}
/>{' '}
Import
{t('general.import')}
</>
)}
</Button>
@ -767,7 +777,7 @@ const AccountHeader = React.memo(
height={10}
style={{ color: 'inherit', marginRight: 3 }}
/>{' '}
Add New
{t('general.addNew')}
</Button>
)}
<View>
@ -788,7 +798,7 @@ const AccountHeader = React.memo(
}
inputRef={searchInput}
value={search}
placeholder="Search"
placeholder={t('general.search')}
getStyle={focused => [
{
backgroundColor: 'transparent',
@ -826,8 +836,8 @@ const AccountHeader = React.memo(
onClick={onToggleSplits}
title={
splitsExpanded.state.mode === 'collapse'
? 'Collapse split transactions'
: 'Expand split transactions'
? t('account.collapseSplitTransaction_other')
: t('account.expandSplitTransaction_other')
}
>
{splitsExpanded.state.mode === 'collapse' ? (
@ -1205,11 +1215,15 @@ class AccountInternal extends React.PureComponent {
onImport = async () => {
const accountId = this.props.accountId;
const account = this.props.accounts.find(acct => acct.id === accountId);
const t = this.props.t;
if (account) {
const res = await window.Actual.openFileDialog({
filters: [
{ name: 'Financial Files', extensions: ['qif', 'ofx', 'qfx', 'csv'] }
{
name: t('general.financialFile_other'),
extensions: ['qif', 'ofx', 'qfx', 'csv']
}
]
});
@ -1234,11 +1248,12 @@ class AccountInternal extends React.PureComponent {
let normalizedName =
accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-');
let filename = `${normalizedName || 'transactions'}.csv`;
const t = this.props.t;
window.Actual.saveFile(
exportedTransactions,
filename,
'Export Transactions'
t('general.exportTransaction_other')
);
};
@ -1352,21 +1367,24 @@ class AccountInternal extends React.PureComponent {
if (filterName) {
return filterName;
}
const t = this.props.t;
if (!account) {
if (id === 'budgeted') {
return 'Budgeted Accounts';
return t('account.budgetedAccount_other');
} else if (id === 'offbudget') {
return 'Off Budget Accounts';
return t('account.offBudgetAccount_other');
} else if (id === 'uncategorized') {
return 'Uncategorized';
return t('account.uncategorized');
} else if (!id) {
return 'All Accounts';
return t('account.allAccounts');
}
return null;
}
return (account.closed ? 'Closed: ' : '') + account.name;
return account.closed
? t('account.closedNamed', { name: account.name })
: account.name;
}
getBalanceQuery(account, id) {
@ -1405,8 +1423,10 @@ class AccountInternal extends React.PureComponent {
};
onShowTransactions = async ids => {
const t = this.props.t;
this.onApplyFilter({
customName: 'Selected transactions',
customName: t('account.selectedTransaction_other'),
filter: { id: { $oneof: ids } }
});
};
@ -1588,7 +1608,8 @@ class AccountInternal extends React.PureComponent {
replaceModal,
showExtraBalances,
expandSplits,
accountId
accountId,
t
} = this.props;
let {
transactions,
@ -1723,7 +1744,7 @@ class AccountInternal extends React.PureComponent {
fontStyle: 'italic'
}}
>
No transactions
{t('general.noTransaction_other')}
</View>
) : null
}
@ -1753,10 +1774,13 @@ class AccountInternal extends React.PureComponent {
function AccountHack(props) {
let { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
const { t } = useTranslation();
return (
<AccountInternal
{...props}
splitsExpandedDispatch={splitsExpandedDispatch}
t={t}
/>
);
}

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import {
@ -132,6 +133,8 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
ops = ['is'];
}
const { t } = useTranslation();
return (
<Tooltip
position="bottom-left"
@ -146,15 +149,15 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
options={
field === 'amount'
? [
['amount', 'Amount'],
['amount-inflow', 'Amount (inflow)'],
['amount-outflow', 'Amount (outflow)']
['amount', t('general.amount')],
['amount-inflow', t('general.amountIinflow')],
['amount-outflow', t('general.amountOutflow')]
]
: field === 'date'
? [
['date', 'Date'],
['month', 'Month'],
['year', 'Year']
['date', t('general.date')],
['month', t('general.month')],
['year', t('general.year')]
]
: null
}
@ -235,7 +238,7 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
});
}}
>
Apply
{t('general.apply')}
</Button>
</View>
</form>
@ -251,6 +254,8 @@ export function FilterButton({ onApply }) {
};
});
const { t } = useTranslation();
let [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
@ -306,7 +311,7 @@ export function FilterButton({ onApply }) {
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy-MM');
} else {
alert('Invalid date format');
alert(t('general.invalidDateFormat'));
return;
}
} else if (cond.options.year) {
@ -314,7 +319,7 @@ export function FilterButton({ onApply }) {
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy');
} else {
alert('Invalid date format');
alert(t('general.invalidDateFormat'));
return;
}
}

View file

@ -1,4 +1,5 @@
import React, { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import {
@ -181,6 +182,7 @@ export default function SimpleTransactionsTable({
},
[payees, categories, memoFields, selectedItems]
);
const { t } = useTranslation();
return (
<Table
@ -200,43 +202,43 @@ export default function SimpleTransactionsTable({
case 'date':
return (
<Field key={i} width={100}>
Date
{t('general.date')}
</Field>
);
case 'imported_payee':
return (
<Field key={i} width="flex">
Imported payee
{t('general.importedPayee')}
</Field>
);
case 'payee':
return (
<Field key={i} width="flex">
Payee
{t('general.payee_one')}
</Field>
);
case 'category':
return (
<Field key={i} width="flex">
Category
{t('general.category_one')}
</Field>
);
case 'account':
return (
<Field key={i} width="flex">
Account
{t('general.account_one')}
</Field>
);
case 'notes':
return (
<Field key={i} width="flex">
Notes
{t('general.note_other')}
</Field>
);
case 'amount':
return (
<Field key={i} width={75} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
);
default:

View file

@ -8,6 +8,7 @@ import React, {
useContext,
useReducer
} from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux';
import {
@ -253,6 +254,7 @@ export function SplitsExpandedProvider({ children, initialMode = 'expand' }) {
export const TransactionHeader = React.memo(
({ hasSelected, showAccount, showCategory, showBalance }) => {
let dispatchSelected = useSelectedDispatch();
const { t } = useTranslation();
return (
<Row
@ -271,14 +273,18 @@ export const TransactionHeader = React.memo(
width={20}
onSelect={() => dispatchSelected({ type: 'select-all' })}
/>
<Cell value="Date" width={110} />
{showAccount && <Cell value="Account" width="flex" />}
<Cell value="Payee" width="flex" />
<Cell value="Notes" width="flex" />
{showCategory && <Cell value="Category" width="flex" />}
<Cell value="Payment" width={80} textAlign="right" />
<Cell value="Deposit" width={80} textAlign="right" />
{showBalance && <Cell value="Balance" width={85} textAlign="right" />}
<Cell value={t('general.date')} width={110} />
{showAccount && <Cell value={t('general.account_one')} width="flex" />}
<Cell value={t('general.payee_other')} width="flex" />
<Cell value={t('general.note_other')} width="flex" />
{showCategory && (
<Cell value={t('general.category_other')} width="flex" />
)}
<Cell value={t('general.payment_one')} width={80} textAlign="right" />
<Cell value={t('general.deposit_one')} width={80} textAlign="right" />
{showBalance && (
<Cell value={t('general.balance_one')} width={85} textAlign="right" />
)}
<Field width={21} truncate={false} />
<Cell value="" width={15 + styles.scrollbarWidth} />
</Row>
@ -336,7 +342,7 @@ function StatusCell({
? colors.y5
: selected
? colors.b7
: colors.n7
: colors.n6
};
function onSelect() {
@ -362,8 +368,7 @@ function StatusCell({
':focus': {
border: '1px solid ' + props.color,
boxShadow: `0 1px 2px ${props.color}`
},
cursor: isClearedField ? 'pointer' : 'default'
}
},
isChild && { visibility: 'hidden' }
@ -529,6 +534,7 @@ export const Transaction = React.memo(function Transaction(props) {
onCreatePayee,
onToggleSplit
} = props;
const { t } = useTranslation();
let dispatchSelected = useSelectedDispatch();
@ -897,7 +903,7 @@ export const Transaction = React.memo(function Transaction(props) {
/>
)}
<Text style={{ fontStyle: 'italic', userSelect: 'none' }}>
Split
{t('general.split')}
</Text>
</View>
</CellButton>
@ -911,11 +917,11 @@ export const Transaction = React.memo(function Transaction(props) {
onExpose={!isPreview && (name => onEdit(id, name))}
value={
isParent
? 'Split'
? t('general.split')
: isOffBudget
? 'Off Budget'
? t('general.offBudget')
: isBudgetTransfer
? 'Transfer'
? t('general.transfer')
: ''
}
valueStyle={valueStyle}
@ -937,7 +943,7 @@ export const Transaction = React.memo(function Transaction(props) {
'name'
)
: transaction.id
? 'Categorize'
? t('general.categorize')
: ''
}
exposed={focusedField === 'category'}
@ -1050,6 +1056,8 @@ export const Transaction = React.memo(function Transaction(props) {
});
export function TransactionError({ error, isDeposit, onAddSplit, style }) {
const { t } = useTranslation();
switch (error.type) {
case 'SplitTransactionError':
if (error.version === 1) {
@ -1065,21 +1073,21 @@ export function TransactionError({ error, isDeposit, onAddSplit, style }) {
]}
data-testid="transaction-error"
>
<Text>
Amount left:{' '}
<Text style={{ fontWeight: 500 }}>
{integerToCurrency(
<Trans
i18nKey={'general.amountLeft'}
values={{
amount: integerToCurrency(
isDeposit ? error.difference : -error.difference
)}
</Text>
</Text>
)
}}
/>
<View style={{ flex: 1 }} />
<Button
style={{ marginLeft: 15, padding: '4px 10px' }}
primary
onClick={onAddSplit}
>
Add Split
{t('general.addSplit')}
</Button>
</View>
);
@ -1136,6 +1144,7 @@ function NewTransaction({
}) {
const error = transactions[0].error;
const isDeposit = transactions[0].amount > 0;
const { t } = useTranslation();
return (
<View
@ -1193,7 +1202,7 @@ function NewTransaction({
onClick={() => onClose()}
data-testid="cancel-button"
>
Cancel
{t('general.cancel')}
</Button>
{error ? (
<TransactionError
@ -1208,7 +1217,7 @@ function NewTransaction({
onClick={onAdd}
data-testid="add-button"
>
Add
{t('general.add')}
</Button>
)}
</View>
@ -1533,12 +1542,14 @@ export let TransactionTable = React.forwardRef((props, ref) => {
setPrevIsAdding(props.isAdding);
}
const { t } = useTranslation();
useEffect(() => {
if (shouldAdd.current) {
if (newTransactions[0].account == null) {
props.addNotification({
type: 'error',
message: 'Account is a required field'
message: t('transaction.accountIsRequired')
});
newNavigator.onEdit('temp', 'account');
} else {

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useBudgetMonthCount } from 'loot-design/src/components/budget/BudgetMonthCountContext';
import { View } from 'loot-design/src/components/common';
@ -16,6 +17,7 @@ function Calendar({ color, onClick }) {
export function MonthCountSelector({ maxMonths, onChange }) {
let { displayMax } = useBudgetMonthCount();
const { t } = useTranslation();
let style = { width: 15, height: 15, color: colors.n8 };
let activeStyle = { color: colors.n5 };
@ -50,7 +52,7 @@ export function MonthCountSelector({ maxMonths, onChange }) {
transform: 'scale(1.2)'
}
}}
title="Choose the number of months shown at a time"
title={t('budget.chooseNumberMonths')}
>
{calendars}
</View>

View file

@ -327,7 +327,7 @@ class Budget extends React.PureComponent {
pathname: '/accounts',
state: {
goBack: true,
filterName: `${categoryName} (${monthUtils.format(
filterName: `${categoryName} (${monthUtils.nonLocalizedFormat(
month,
'MMMM yyyy'
)})`,

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -12,6 +13,8 @@ import { useBootstrapped, Title } from './common';
import { ConfirmPasswordForm } from './ConfirmPasswordForm';
export default function Bootstrap() {
const { t } = useTranslation();
let dispatch = useDispatch();
let history = useHistory();
let [error, setError] = useState(null);
@ -21,13 +24,13 @@ export default function Bootstrap() {
function getErrorMessage(error) {
switch (error) {
case 'invalid-password':
return 'Password cannot be empty';
return t('bootstrap.passwordCannotBeEmpty');
case 'password-match':
return 'Passwords do not match';
return t('bootstrap.passwordsDoNotMatch');
case 'network-failure':
return 'Unable to contact the server';
return t('bootstrap.unableToContactTheServer');
default:
return "Whoops, an error occurred on our side! We'll try to get it fixed soon.";
return t('bootstrap.unknownError');
}
}
@ -53,7 +56,7 @@ export default function Bootstrap() {
return (
<>
<View style={{ width: 450, marginTop: -30 }}>
<Title text="Bootstrap this Actual instance" />
<Title text={t('bootstrap.title')} />
<Text
style={{
fontSize: 16,
@ -61,7 +64,7 @@ export default function Bootstrap() {
lineHeight: 1.4
}}
>
Set a password for this server instance
{t('bootstrap.setPassword')}
</Text>
{error && (
@ -84,7 +87,7 @@ export default function Bootstrap() {
style={{ fontSize: 15, color: colors.b4, marginRight: 15 }}
onClick={onDemo}
>
Try Demo
{t('bootstrap.tryDemo')}
</Button>
}
onSetPassword={onSetPassword}

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -14,17 +15,18 @@ export default function ChangePassword() {
let history = useHistory();
let [error, setError] = useState(null);
let [msg, setMessage] = useState(null);
const { t } = useTranslation();
function getErrorMessage(error) {
switch (error) {
case 'invalid-password':
return 'Password cannot be empty';
return t('bootstrap.passwordCannotBeEmpty');
case 'password-match':
return 'Passwords do not match';
return t('bootstrap.passwordsDoNotMatch');
case 'network-failure':
return 'Unable to contact the server';
return t('bootstrap.unableToContactTheServer');
default:
return 'Internal server error';
return t('bootstrap.unknownError');
}
}
@ -35,7 +37,7 @@ export default function ChangePassword() {
if (error) {
setError(error);
} else {
setMessage('Password successfully changed');
setMessage(t('bootstrap.passwordSuccessfullyChanged'));
setTimeout(() => {
history.push('/');
@ -46,7 +48,7 @@ export default function ChangePassword() {
return (
<>
<View style={{ width: 500, marginTop: -30 }}>
<Title text="Change server password" />
<Title text={t('bootstrap.changeServerPassword')} />
<Text
style={{
fontSize: 16,
@ -54,8 +56,7 @@ export default function ChangePassword() {
lineHeight: 1.4
}}
>
This will change the password for this server instance. All existing
sessions will stay logged in.
{t('bootstrap.thisWillChangeThePasswordAdvice')}
</Text>
{error && (

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import {
@ -258,7 +259,7 @@ function ScheduleDescription({ id }) {
</Text>
<Text style={{ margin: '0 5px' }}> </Text>
<Text style={{ flexShrink: 0 }}>
Next: {monthUtils.format(schedule.next_date, dateFormat)}
Next: {monthUtils.nonLocalizedFormat(schedule.next_date, dateFormat)}
</Text>
</View>
<StatusBadge status={status} />
@ -280,7 +281,6 @@ function ActionEditor({ ops, action, editorStyle, onChange, onDelete, onAdd }) {
return (
<Editor style={editorStyle} error={error}>
{/*<OpSelect ops={ops} value={op} onChange={onChange} />*/}
{op === 'set' ? (
<>
<View style={{ padding: '5px 10px', lineHeight: '1em' }}>
@ -325,6 +325,7 @@ function ActionEditor({ ops, action, editorStyle, onChange, onDelete, onAdd }) {
function StageInfo() {
let [open, setOpen] = useState();
const { t } = useTranslation();
return (
<View style={{ position: 'relative', marginLeft: 5 }}>
@ -346,9 +347,7 @@ function StageInfo() {
lineHeight: 1.5
}}
>
The stage of a rule allows you to force a specific order. Pre rules
always run first, and post rules always run last. Within each stage
rules are automatically ordered from least to most specific.
{t('rules.stageOfRuleAdvice')}
</Tooltip>
)}
</View>
@ -554,6 +553,7 @@ export default function EditRule({
let [transactions, setTransactions] = useState([]);
let dispatch = useDispatch();
let scrollableEl = useRef();
const { t } = useTranslation();
useEffect(() => {
dispatch(initiallyLoadPayees());
@ -687,7 +687,7 @@ export default function EditRule({
return (
<Modal
title="Rule"
title={t('general.rule_one')}
padding={0}
{...modalProps}
style={[modalProps.style, { flex: 'inherit', maxWidth: '90%' }]}
@ -713,7 +713,7 @@ export default function EditRule({
}}
>
<Text style={{ color: colors.n4, marginRight: 15 }}>
Stage of rule:
{t('rules.stageOfRule')}
</Text>
<Stack direction="row" align="center" spacing={1}>
@ -721,19 +721,19 @@ export default function EditRule({
selected={stage === 'pre'}
onSelect={() => onChangeStage('pre')}
>
Pre
{t('rules.stages.pre')}
</StageButton>
<StageButton
selected={stage === null}
onSelect={() => onChangeStage(null)}
>
Default
{t('rules.stages.default')}
</StageButton>
<StageButton
selected={stage === 'post'}
onSelect={() => onChangeStage('post')}
>
Post
{t('rules.stages.post')}
</StageButton>
<StageInfo />
@ -752,7 +752,7 @@ export default function EditRule({
<View style={{ flexShrink: 0 }}>
<View style={{ marginBottom: 30 }}>
<Text style={{ color: colors.n4, marginBottom: 15 }}>
If all these conditions match:
{t('rules.ifAllTheseConditionsMatch')}
</Text>
<ConditionsList
@ -764,7 +764,7 @@ export default function EditRule({
</View>
<Text style={{ color: colors.n4, marginBottom: 15 }}>
Then apply these actions:
{t('rules.thenApplyTheseActions')}
</Text>
<View style={{ flex: 1 }}>
{actions.length === 0 ? (
@ -772,7 +772,7 @@ export default function EditRule({
style={{ alignSelf: 'flex-start' }}
onClick={addInitialAction}
>
Add action
{t('rules.addAction')}
</Button>
) : (
<Stack spacing={2}>
@ -807,7 +807,7 @@ export default function EditRule({
}}
>
<Text style={{ color: colors.n4, marginBottom: 0 }}>
This rule applies to these transactions:
{t('rules.thisRuleAppliesToTheseTransactions')}
</Text>
<View style={{ flex: 1 }} />
@ -815,7 +815,7 @@ export default function EditRule({
disabled={selectedInst.items.size === 0}
onClick={onApply}
>
Apply actions ({selectedInst.items.size})
{t('rules.applyAction', { size: selectedInst.items.size })}
</Button>
</View>
@ -830,9 +830,11 @@ export default function EditRule({
justify="flex-end"
style={{ marginTop: 20 }}
>
<Button onClick={() => modalProps.onClose()}>Cancel</Button>
<Button onClick={() => modalProps.onClose()}>
{t('general.cancel')}
</Button>
<Button primary onClick={() => onSave()}>
Save
{t('general.save')}
</Button>
</Stack>
</View>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { format as formatDate, parseISO } from 'date-fns';
@ -52,6 +53,7 @@ export function Value({
data: dataProp,
describe = x => x.name
}) {
const { i18n } = useTranslation();
let { data, dateFormat } = useSelector(state => {
let data;
if (dataProp) {
@ -95,7 +97,7 @@ export function Value({
} else if (field === 'date') {
if (value) {
if (value.frequency) {
return getRecurringDescription(value);
return getRecurringDescription(value, i18n);
}
return formatDate(parseISO(value), dateFormat);
}
@ -238,9 +240,9 @@ function ScheduleValue({ value }) {
field="rule"
data={schedules}
describe={s => {
let payeeId = s._payee;
return payeeId
? `${byId[payeeId].name} (${s.next_date})`
let { payee } = extractScheduleConds(s._conditions);
return payee
? `${byId[payee.value].name} (${s.next_date})`
: `Next: ${s.next_date}`;
}}
/>

View file

@ -53,7 +53,7 @@ function CashFlow() {
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy')
pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
}))
.reverse();

View file

@ -51,7 +51,7 @@ function NetWorth({ accounts }) {
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy')
pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
}))
.reverse();

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useHistory } from 'react-router-dom';
import Platform from 'loot-core/src/client/platform';
@ -35,11 +36,12 @@ let ROW_HEIGHT = 43;
function DiscoverSchedulesTable({ schedules, loading }) {
let selectedItems = useSelectedItems();
let dispatchSelected = useSelectedDispatch();
let { t, i18n } = useTranslation();
function renderItem({ item }) {
let selected = selectedItems.has(item.id);
let amountOp = item._conditions.find(c => c.field === 'amount').op;
let recurDescription = getRecurringDescription(item.date);
let recurDescription = getRecurringDescription(item.date, i18n);
return (
<Row
@ -89,13 +91,13 @@ function DiscoverSchedulesTable({ schedules, loading }) {
selected={selectedItems.size > 0}
onSelect={() => dispatchSelected({ type: 'select-all' })}
/>
<Field width="flex">Payee</Field>
<Field width="flex">Account</Field>
<Field width="flex">{t('general.payee_one')}</Field>
<Field width="flex">{t('general.account_one')}</Field>
<Field width="auto" style={{ flex: 1.5 }}>
When
{t('general.when')}
</Field>
<Field width={100} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
</TableHeader>
<Table
@ -107,7 +109,7 @@ function DiscoverSchedulesTable({ schedules, loading }) {
loading={loading}
isSelected={id => selectedItems.has(id)}
renderItem={renderItem}
renderEmpty="No schedules found"
renderEmpty={t('schedules.noSchedulesFound')}
/>
</View>
);
@ -161,24 +163,19 @@ export default function DiscoverSchedules() {
setCreating(false);
history.goBack();
}
const { t } = useTranslation();
return (
<Page title="Found schedules" modalSize={{ width: 850, height: 650 }}>
<Page
title={t('schedules.foundSchedules')}
modalSize={{ width: 850, height: 650 }}
>
<P>{t('schedules.foundSomePossibleSchedulesAdvice')}</P>
<P>{t('schedules.expectedSchedulesAdvice')}</P>
<P>
We found some possible schedules in your current transactions. Select
the ones you want to create.
</P>
<P>
If you expected a schedule here and don't see it, it might be because
the payees of the transactions don't match. Make sure you rename payees
on all transactions for a schedule to be the same payee.
</P>
<P>
You can always do this later
{Platform.isBrowser
? ' from the "Find schedules" item in the sidebar menu'
: ' from the "Tools > Find schedules" menu item'}
.
? t('schedules.doFromFindSchedules')
: t('schedules.doFromToolsFindSchedules')}
</P>
<SelectedProvider instance={selectedInst}>
@ -194,14 +191,16 @@ export default function DiscoverSchedules() {
justify="flex-end"
style={{ paddingTop: 20 }}
>
<Button onClick={() => history.goBack()}>Do nothing</Button>
<Button onClick={() => history.goBack()}>
{t('general.doNothing')}
</Button>
<ButtonWithLoading
primary
loading={creating}
disabled={selectedInst.items.size === 0}
onClick={onCreate}
>
Create schedules
{t('schedules.createSchedule', { count: selectedInst.items.size })}
</ButtonWithLoading>
</Stack>
</Page>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useParams, useHistory } from 'react-router-dom';
@ -93,6 +94,7 @@ export default function ScheduleDetails() {
});
let pageType = usePageType();
const { t } = useTranslation();
let [state, dispatch] = useReducer(
(state, action) => {
@ -366,8 +368,10 @@ export default function ScheduleDetails() {
if (res.error) {
dispatch({
type: 'form-error',
error:
'An error occurred while saving. Please contact help@actualbudget.com for support.'
// Note: email is outside of translation to be easily replace on future
error: t('support.anErrorOccuredWhileSaving', {
email: 'help@actualbudget.com'
})
});
} else {
if (adding) {
@ -424,15 +428,19 @@ export default function ScheduleDetails() {
return (
<Page
title={payee ? `Schedule: ${payee.name}` : 'Schedule'}
title={
payee
? t('schedules.scheduleNamed', { name: payee.name })
: t('general.schedule')
}
modalSize="medium"
>
<Stack direction="row" style={{ marginTop: 20 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title="Payee" />
<FormLabel title={t('general.payee_one')} />
<PayeeAutocomplete
value={state.fields.payee}
inputProps={{ placeholder: '(none)' }}
inputProps={{ placeholder: t('schedules.none') }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'payee', value: id })
}
@ -440,10 +448,10 @@ export default function ScheduleDetails() {
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title="Account" />
<FormLabel title={t('general.account_one')} />
<AccountAutocomplete
value={state.fields.account}
inputProps={{ placeholder: '(none)' }}
inputProps={{ placeholder: t('schedules.none') }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'account', value: id })
}
@ -452,18 +460,21 @@ export default function ScheduleDetails() {
<FormField style={{ flex: 1 }}>
<Stack direction="row" align="center" style={{ marginBottom: 3 }}>
<FormLabel title="Amount" style={{ margin: 0, flex: 1 }} />
<FormLabel
title={t('general.amount')}
style={{ margin: 0, flex: 1 }}
/>
<OpSelect
ops={['is', 'isapprox', 'isbetween']}
value={state.fields.amountOp}
formatOp={op => {
switch (op) {
case 'is':
return 'is exactly';
return t('schedules.isExactly');
case 'isapprox':
return 'is approximately';
return t('schedules.isApproximately');
case 'isbetween':
return 'is between';
return t('schedules.isBetween');
default:
throw new Error('Invalid op for select: ' + op);
}
@ -505,7 +516,7 @@ export default function ScheduleDetails() {
</Stack>
<View style={{ marginTop: 20 }}>
<FormLabel title="Date" />
<FormLabel title={t('general.date')} />
</View>
<Stack direction="row" align="flex-start">
@ -530,7 +541,7 @@ export default function ScheduleDetails() {
{state.upcomingDates && (
<View style={{ fontSize: 13, marginTop: 20 }}>
<Text style={{ color: colors.n4, fontWeight: 600 }}>
Upcoming dates
{t('schedules.upcomingDates')}
</Text>
<Stack
direction="column"
@ -538,7 +549,9 @@ export default function ScheduleDetails() {
style={{ marginTop: 10, color: colors.n4 }}
>
{state.upcomingDates.map(date => (
<View>{monthUtils.format(date, `${dateFormat} EEEE`)}</View>
<View>
{monthUtils.nonLocalizedFormat(date, `${dateFormat} EEEE`)}
</View>
))}
</Stack>
</View>
@ -562,7 +575,7 @@ export default function ScheduleDetails() {
}}
/>
<label for="form_repeats" style={{ userSelect: 'none' }}>
Repeats
{t('general.repeats')}
</label>
</View>
@ -593,7 +606,7 @@ export default function ScheduleDetails() {
}}
/>
<label for="form_posts_transaction" style={{ userSelect: 'none' }}>
Automatically add transaction
{t('schedules.automaticallyAddTransaction')}
</label>
</View>
@ -607,8 +620,7 @@ export default function ScheduleDetails() {
lineHeight: '1.4em'
}}
>
If checked, the schedule will automatically create transactions for
you in the specified account
{t('schedules.automaticallyAddTransactionAdvice')}
</Text>
{!adding && state.schedule.rule && (
@ -622,11 +634,11 @@ export default function ScheduleDetails() {
width: 350
}}
>
This schedule has custom conditions and actions
{t('schedules.thisScheduleHasCustomConditionsAndActions')}
</Text>
)}
<Button onClick={() => onEditRule()} disabled={adding}>
Edit as rule
{t('schedules.editAsRule')}
</Button>
</Stack>
)}
@ -638,11 +650,11 @@ export default function ScheduleDetails() {
{adding ? (
<View style={{ flexDirection: 'row', padding: '5px 0' }}>
<Text style={{ color: colors.n4 }}>
These transactions match this schedule:
{t('schedules.theseTransactionsMatchThisSchedule')}
</Text>
<View style={{ flex: 1 }} />
<Text style={{ color: colors.n6 }}>
Select transactions to link on save
{t('schedules.selectTransactionsToLinkOnSave')}
</Text>
</View>
) : (
@ -657,7 +669,7 @@ export default function ScheduleDetails() {
}}
onClick={() => onSwitchTransactions('linked')}
>
Linked transactions
{t('schedules.linkedTransactions')}
</Button>{' '}
<Button
bare
@ -670,15 +682,20 @@ export default function ScheduleDetails() {
}}
onClick={() => onSwitchTransactions('matched')}
>
Find matching transactions
{t('schedules.findMatchingTransactions')}
</Button>
<View style={{ flex: 1 }} />
<SelectedItemsButton
name="transactions"
items={
state.transactionsMode === 'linked'
? [{ name: 'unlink', text: 'Unlink from schedule' }]
: [{ name: 'link', text: 'Link to schedule' }]
? [
{
name: 'unlink',
text: t('schedules.unlinkFromSchedule')
}
]
: [{ name: 'link', text: t('schedules.linkToSchedule') }]
}
onSelect={(name, ids) => {
switch (name) {
@ -716,10 +733,10 @@ export default function ScheduleDetails() {
>
{state.error && <Text style={{ color: colors.r4 }}>{state.error}</Text>}
<Button style={{ marginRight: 10 }} onClick={() => history.goBack()}>
Cancel
{t('general.cancel')}
</Button>
<Button primary onClick={onSave}>
{adding ? 'Add' : 'Save'}
{adding ? t('general.add') : t('general.save')}
</Button>
</Stack>
</Page>

View file

@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import * as monthUtils from 'loot-core/src/shared/months';
@ -30,6 +31,8 @@ export let ROW_HEIGHT = 43;
function OverflowMenu({ schedule, status, onAction }) {
let [open, setOpen] = useState(false);
const { t } = useTranslation();
return (
<View>
<Button
@ -60,15 +63,15 @@ function OverflowMenu({ schedule, status, onAction }) {
items={[
status === 'due' && {
name: 'post-transaction',
text: 'Post transaction'
text: t('schedules.postTransaction')
},
...(schedule.completed
? [{ name: 'restart', text: 'Restart' }]
? [{ name: 'restart', text: t('general.restart') }]
: [
{ name: 'skip', text: 'Skip next date' },
{ name: 'complete', text: 'Complete' }
{ name: 'skip', text: t('schedules.skipNextDate') },
{ name: 'complete', text: t('general.complete') }
]),
{ name: 'delete', text: 'Delete' }
{ name: 'delete', text: t('general.delete') }
]}
/>
</Tooltip>
@ -82,6 +85,8 @@ export function ScheduleAmountCell({ amount, op }) {
let str = integerToCurrency(Math.abs(num || 0));
let isApprox = op === 'isapprox' || op === 'isbetween';
const { t } = useTranslation();
return (
<Cell
width={100}
@ -101,7 +106,7 @@ export function ScheduleAmountCell({ amount, op }) {
lineHeight: '1em',
marginRight: 10
}}
title={(isApprox ? 'Approximately ' : '') + str}
title={t('general.approximatelyWithAmount', { amount: str })}
>
~
</View>
@ -114,7 +119,9 @@ export function ScheduleAmountCell({ amount, op }) {
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={(isApprox ? 'Approximately ' : '') + str}
title={
isApprox ? t('general.approximatelyWithAmount', { amount: str }) : str
}
>
{num > 0 ? `+${str}` : `${str}`}
</Text>
@ -137,6 +144,8 @@ export function SchedulesTable({
let [showCompleted, setShowCompleted] = useState(false);
const { t } = useTranslation();
let items = useMemo(() => {
if (!allowCompleted) {
return schedules.filter(s => !s.completed);
@ -172,7 +181,7 @@ export function SchedulesTable({
</Field>
<Field width={110}>
{item.next_date
? monthUtils.format(item.next_date, dateFormat)
? monthUtils.nonLocalizedFormat(item.next_date, dateFormat)
: null}
</Field>
<Field width={120} style={{ alignItems: 'flex-start' }}>
@ -221,7 +230,7 @@ export function SchedulesTable({
color: colors.n6
}}
>
Show completed schedules
{t('schedules.showCompletedSchedules')}
</Field>
</Row>
);
@ -232,16 +241,16 @@ export function SchedulesTable({
return (
<>
<TableHeader height={ROW_HEIGHT} inset={15} version="v2">
<Field width="flex">Payee</Field>
<Field width="flex">Account</Field>
<Field width={110}>Next date</Field>
<Field width={120}>Status</Field>
<Field width="flex">{t('general.payee_one')}</Field>
<Field width="flex">{t('general.account_one')}</Field>
<Field width={110}>{t('schedules.nextDate')}</Field>
<Field width={120}>{t('general.status')}</Field>
<Field width={100} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
{!minimal && (
<Field width={80} style={{ textAlign: 'center' }}>
Recurring
{t('general.recurring')}
</Field>
)}
{!minimal && <Field width={40}></Field>}
@ -253,7 +262,7 @@ export function SchedulesTable({
style={[{ flex: 1, backgroundColor: 'transparent' }, style]}
items={items}
renderItem={renderItem}
renderEmpty="No schedules"
renderEmpty={t('schedules.noSchedules')}
allowPopupsEscape={items.length < 6}
/>
</>

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { titleFirst } from 'loot-core/src/shared/util';
import { View, Text } from 'loot-design/src/components/common';
@ -6,63 +7,67 @@ import { colors } from 'loot-design/src/style';
import AlertTriangle from 'loot-design/src/svg/v2/AlertTriangle';
import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1';
import CheckCircleHollow from 'loot-design/src/svg/v2/CheckCircleHollow';
import EditSkull1 from 'loot-design/src/svg/v2/EditSkull1';
import FavoriteStar from 'loot-design/src/svg/v2/FavoriteStar';
import ValidationCheck from 'loot-design/src/svg/v2/ValidationCheck';
export function getStatusProps(status) {
let color, backgroundColor, Icon;
let color, backgroundColor, Icon, title;
const { t } = useTranslation();
switch (status) {
case 'missed':
color = colors.r1;
backgroundColor = colors.r10;
Icon = EditSkull1;
title = t('status.missed');
break;
case 'due':
color = colors.y1;
backgroundColor = colors.y9;
Icon = AlertTriangle;
title = t('status.due');
break;
case 'upcoming':
color = colors.p1;
backgroundColor = colors.p10;
Icon = CalendarIcon;
title = t('status.upcoming');
break;
case 'paid':
color = colors.g2;
backgroundColor = colors.g10;
Icon = ValidationCheck;
title = t('status.paid');
break;
case 'completed':
color = colors.n4;
backgroundColor = colors.n11;
Icon = FavoriteStar;
title = t('status.completed');
break;
case 'pending':
color = colors.g4;
backgroundColor = colors.g11;
Icon = CalendarIcon;
title = t('status.pending');
break;
case 'scheduled':
color = colors.n1;
backgroundColor = colors.n11;
Icon = CalendarIcon;
break;
case 'cleared':
color = colors.g5;
backgroundColor = colors.n11;
Icon = CheckCircle1;
title = t('status.scheduled');
break;
default:
color = colors.n1;
backgroundColor = colors.n11;
Icon = CheckCircleHollow;
Icon = CheckCircle1;
title = status;
break;
}
return { color, backgroundColor, Icon };
return { title, color, backgroundColor, Icon };
}
export function StatusIcon({ status }) {
@ -72,7 +77,7 @@ export function StatusIcon({ status }) {
}
export function StatusBadge({ status, style }) {
let { color, backgroundColor, Icon } = getStatusProps(status);
let { title, color, backgroundColor, Icon } = getStatusProps(status);
return (
<View
style={[
@ -96,7 +101,7 @@ export function StatusBadge({ status, style }) {
marginRight: 7
}}
/>
<Text style={{ lineHeight: '1em' }}>{titleFirst(status)}</Text>
<Text style={{ lineHeight: '1em' }}>{titleFirst(title)}</Text>
</View>
);
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
@ -13,6 +14,8 @@ export default function Schedules() {
let scheduleData = useSchedules();
const { t } = useTranslation();
if (scheduleData == null) {
return null;
}
@ -52,7 +55,7 @@ export default function Schedules() {
}
return (
<Page title="Schedules">
<Page title={t('general.schedule_other')}>
<View
style={{
marginTop: 20,
@ -72,7 +75,7 @@ export default function Schedules() {
<View style={{ alignItems: 'flex-end', margin: '20px 0', flexShrink: 0 }}>
<Button primary onClick={onAdd}>
Add new schedule
{t('schedules.addNewSchedule')}
</Button>
</View>
</Page>

View file

@ -16,7 +16,7 @@ import Navigation from './Navigation';
function Overspending({ navigationProps, stepTwo }) {
let currentMonth = monthUtils.currentMonth();
let sheetName = monthUtils.sheetForMonth(currentMonth);
let month = monthUtils.format(currentMonth, 'MMM');
let month = monthUtils.nonLocalizedFormat(currentMonth, 'MMM');
let [minimized, toggle] = useMinimized();
return (

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
integerToCurrency,
@ -52,6 +53,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) {
let [num1, setNum1] = useState(defaultValue.num1);
let [num2, setNum2] = useState(defaultValue.num2);
const { t } = useTranslation();
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<AmountInput
@ -61,7 +64,7 @@ export function BetweenAmountInput({ defaultValue, onChange }) {
onChange({ num1: value, num2 });
}}
/>
<View style={{ margin: '0 5px' }}>and</View>
<View style={{ margin: '0 5px' }}>{t('general.and')}</View>
<AmountInput
defaultValue={num2}
onChange={value => {

View file

@ -5,6 +5,7 @@ import './browser-preload';
// A hack for now: this makes sure it's appended before glamor
import '@reach/listbox/styles.css';
import './locales';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

View file

@ -0,0 +1,163 @@
{
"account": {
"addAccount": "Add account",
"addAccountInFutureFromSidebar": "In the future, you can add accounts from the sidebar.",
"allAccounts": "All Accounts",
"allReconciled": "All reconciled!",
"budgetedAccount_other": "Budgeted Accounts",
"cleared": "Cleared",
"clearedBalance": "Your cleared balance <strong>{{cleared}}</strong> needs <strong>{{diff}}</strong> to match your bank's balance of <strong>{{balance}}</strong>",
"clearedTotal": "Cleared Total: <strong>{{amount}}</strong>",
"closeAccount": "Close Account",
"closedNamed": "Close: {{name}}",
"collapseSplitTransaction_other": "Collapse split transactions",
"doneReconciling": "Done Reconciling",
"enterCurrentBalanceToReconcileAdvice": "Enter the current balance of your bank account that you want to reconcile with:",
"expandSplitTransaction_other": "Expand split transactions",
"hideRunningBalance": "Hide Running Balance",
"linkAccount": "Link account",
"needAccountMessage": "For Actual to be useful, you need to <strong>add an account</strong>. You can link an account to automatically download transactions, or manage it locally yourself.",
"offBudgetAccount_other": "Off Budget Accounts",
"reconcile": "Reconcile",
"reopenAccount": "Reopen account",
"selectedBalance": "Selected Balance: <strong>{{amount}}</strong>",
"selectedTransaction_other": "Selected Transactions",
"showRunningBalance": "Show Running Balance",
"uncategorized": "Uncategorized",
"unclearedTotal": "Uncleared Total: <strong>{{amount}}</strong>",
"unlinkAccount": "Unlink Account"
},
"bootstrap": {
"changeServerPassword": "Change server password",
"passwordCannotBeEmpty": "Password cannot be empty",
"passwordsDoNotMatch": "Passwords do not match",
"passwordSuccessfullyChanged": "Password Successfully changed",
"setPassword": "Set a password for this server instance",
"thisWillChangeThePasswordAdvice": "This will change the password for this server instance. All existing sessions will stay logged in.",
"title": "Bootstrap this Actual instance",
"tryDemo": "Try Demo",
"unableToContactTheServer": "Unable to contact the server",
"unknownError": "Whoops, an error occurred on our side! We'll try to get it fixed soon."
},
"budget": {
"chooseNumberMonths": "Choose the number of months shown at a time"
},
"general": {
"account_one": "Account",
"account_other": "Accounts",
"add": "Add",
"addNew": "Add new",
"addSplit": "Add Split",
"amount": "Amount",
"amountIinflow": "Amount (inflow)",
"amountLeft": "Amount left: <strong>{{amount}}</strong>",
"amountOutflow": "Amount (outflow)",
"and": "and",
"apply": "Apply",
"approximatelyWithAmount": "Approximately {{amount}}",
"balance_one": "Balance",
"cancel": "Cancel",
"categorize": "Categorize",
"category_one": "Category",
"category_other": "Categories",
"complete": "Complete",
"date": "Date",
"delete": "Delete",
"deposit_one": "Deposit",
"doNothing": "Do nothing",
"editField": "Edit field",
"export": "Export",
"exportTransaction_other": "Export Transactions",
"financialFile_other": "Financial Files",
"import": "Import",
"importedPayee": "Imported payee",
"invalidDateFormat": "Invalid date format",
"month": "Month",
"note_other": "Notes",
"noTransaction_other": "No transactions",
"offBudget": "Off Budget",
"payee_one": "Payee",
"payee_other": "Payees",
"payment_one": "Payment",
"recurring": "Recurring",
"repeats": "Repeats",
"restart": "Restart",
"rule_one": "Rule",
"save": "Save",
"schedule": "Schedule",
"schedule_other": "Schedules",
"search": "Search",
"show": "Show",
"split": "Split",
"status": "Status",
"sync": "Sync",
"transfer": "Transfer",
"when": "When",
"year": "Year"
},
"rules": {
"addAction": "Add action",
"applyAction": "Apply action ({{size}})",
"ifAllTheseConditionsMatch": "If all these conditions match:",
"stageOfRule": "Stage of rule:",
"stageOfRuleAdvice": "The stage of a rule allows you to force a specific order. Pre rules always run first, and post rules always run last. Within each stage rules are automatically ordered from least to most specific.",
"stages": {
"default": "Default",
"post": "Post",
"pre": "Pre"
},
"thenApplyTheseActions": "Then apply these actions:",
"thisRuleAppliesToTheseTransactions": "This rule applies to these transactions:"
},
"schedules": {
"addNewSchedule": "Add new schedule",
"automaticallyAddTransaction": "Automatically add transaction",
"automaticallyAddTransactionAdvice": "If checked, the schedule will automatically create transactions for you in the specified account",
"createSchedule_one": "Create schedule",
"createSchedule_other": "Create schedules",
"doFromFindSchedules": "You can always do this later from the \"Find schedules\" item in the sidebar menu",
"doFromToolsFindSchedules": "You can always do this later from the \"Tools > Find schedules\" menu item",
"editAsRule": "Edit as rule",
"expectedSchedulesAdvice": "If you expected a schedule here and don't see it, it might be because the payees of the transactions don't match. Make sure you rename payees on all transactions for a schedule to be the same payee.",
"findMatchingTransactions": "Find matching transactions",
"foundSchedules": "Found schedules",
"foundSomePossibleSchedulesAdvice": "We found some possible schedules in your current transactions. Select the ones you want to create.",
"isApproximately": "is approximately",
"isBetween": "is between",
"isExactly": "is exactly",
"linkedTransactions": "Linked transactions",
"linkSchedule": "Link Schedule",
"linkToSchedule": "Link to schedule",
"nextDate": "Next date",
"none": "(none)",
"noSchedules": "No schedules",
"noSchedulesFound": "No schedules found",
"postTransaction": "Post transaction",
"scheduleNamed": "Schedule: {{name}}",
"selectTransactionsToLinkOnSave": "Select transactions to link on save",
"showCompletedSchedules": "Show completed schedules",
"skipNextDate": "Skip next date",
"skipScheduledDate": "Skip scheduled date",
"theseTransactionsMatchThisSchedule": "These transactions match this schedule:",
"thisScheduleHasCustomConditionsAndActions": "This schedule has custom conditions and actions",
"unlinkFromSchedule": "Unlink from schedule",
"unlinkSchedule": "Unlink Schedule",
"upcomingDates": "Upcoming dates",
"view_one": "View schedule"
},
"status": {
"completed": "completed",
"due": "due",
"missed": "missed",
"paid": "paid",
"pending": "pending",
"scheduled": "scheduled",
"upcoming": "upcoming"
},
"support": {
"anErrorOccuredWhileSaving": "An error occurred while saving. Please contact {{email}} for support."
},
"transaction": {
"accountIsRequired": "Account is a required field"
}
}

View file

@ -0,0 +1,164 @@
{
"account": {
"addAccount": "Agregar cuenta",
"addAccountInFutureFromSidebar": "En el futuro puedes agregar cuentas desde la barra lateral.",
"allAccounts": "Todas las cuentas",
"allReconciled": "¡Todo conciliado!",
"budgetedAccount_other": "Cuentas presupuestadas",
"cleared": "Aclarada",
"clearedBalance": "Su saldo compensado <strong>{{cleared}}</strong> necesita <strong>{{diff}}</strong> para coincidir con el saldo del banco de <strong>{{balance}}</strong>",
"clearedTotal": "Total aclarado: <strong>{{amount}}</strong>",
"closeAccount": "Cerrar cuenta",
"closedNamed": "Cerrada: {{name}}",
"collapseSplitTransaction_other": "Colapsar transacciones divididas",
"doneReconciling": "Conciliación finalizada",
"enterCurrentBalanceToReconcileAdvice": "Ingrese el saldo actual de su cuenta que desea conciliar:",
"expandSplitTransaction_other": "Expandir transacciones divididas",
"hideRunningBalance": "Ocultar saldo actualizado",
"linkAccount": "Vincular cuenta",
"needAccountMessage": "Para que Actual sea útil, debe <strong>agregar una cuenta</strong>. Puedes vincular la cuenta para descargar transacciones automáticamente o administrala localmente.",
"offBudgetAccount_other": "Cuentas sin presupuestar",
"reconcile": "Conciliar",
"reopenAccount": "Re abrir cuenta",
"selectedBalance": "Balance seleccionado: <strong>{{amount}}</strong>",
"selectedTransaction_other": "Transacciones seleccionadas",
"showRunningBalance": "Mostrar saldo actualizado",
"uncategorized": "Sin categorizar",
"unclearedTotal": "Total sin aclarar: <strong>{{amount}}</strong>",
"unlinkAccount": "Desvincular cuenta"
},
"bootstrap": {
"changeServerPassword": "Cambiar contraseña del servidor",
"passwordCannotBeEmpty": "La contraseña no puede estar vacía",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordSuccessfullyChanged": "La contraseña fue modificada exitosamente",
"setPassword": "Establecer una contraseña para esta instancia de servidor",
"thisWillChangeThePasswordAdvice": "Este proceso modificará la contraseña para el servidor. Todas las sesiones existentes permanecerán conectadas.",
"title": "Bootstrap esta instancia de Actual",
"tryDemo": "Probar Demo",
"unableToContactTheServer": "Incapaz de conectarse con el servidor",
"unknownError": "¡Vaya, ocurrió un error de nuestro lado! Intentaremos solucionarlo pronto."
},
"budget": {
"chooseNumberMonths": "Seleccióne el numero de meses para mostrar a la vez"
},
"general": {
"account_one": "Cuenta",
"account_other": "Cuentas",
"add": "Agregar",
"addNew": "Agregar nuevo",
"addSplit": "Agregar división",
"amount": "Monto",
"amountIinflow": "Monto (Entrada)",
"amountLeft": "Monto restante: <strong>{{amount}}</strong>",
"amountOutflow": "Monto (Salida)",
"and": "y",
"apply": "Aplicar",
"approximatelyWithAmount": "Aproximadamente {{amount}}",
"balance_one": "Balance",
"cancel": "Cancelar",
"categorize": "Categorizar",
"category_one": "Categoría",
"category_other": "Categorías",
"complete": "Completar",
"date": "Fecha",
"delete": "Borrar",
"deposit_one": "Depósito",
"doNothing": "No hacer nada",
"editField": "Editar campo",
"export": "Exportar",
"exportTransaction_other": "Exportar transacciones",
"financialFile_other": "Archivos financieros",
"import": "Importar",
"importedPayee": "Beneficiario importado",
"invalidDateFormat": "Formato de fecha inválido",
"month": "Mes",
"note_other": "Notas",
"noTransaction_other": "Sin transacciones",
"offBudget": "Fuera de presupuesto",
"payee_one": "Beneficiario",
"payee_other": "Beneficiarios",
"payment_one": "Pago",
"recurring": "Periódico",
"repeats": "Repetir",
"restart": "Reiniciar",
"rule_one": "Regla",
"save": "Guardar",
"schedule": "Agenda",
"schedule_other": "Agendas",
"search": "Buscar",
"show": "Mostrar",
"split": "Dividir",
"status": "Estado",
"sync": "Sincronizar",
"transfer": "Transferencia",
"when": "Cuando",
"year": "Año"
},
"rules": {
"addAction": "Agregar acción",
"applyAction": "Aplicar acción ({{size}})",
"ifAllTheseConditionsMatch": "Si todas estas condiciones coinciden:",
"stageOfRule": "Etapa de la regla:",
"stageOfRuleAdvice": "La etapa de una regla le permite forzar un orden específico. Reglas previas siempre se ejecuta primero y las reglas posteriores siempre se ejecutan al final. Dentro de cada etapa las reglas se ordenan automáticamente de menos a más específicas.",
"stages": {
"default": "Por defecto",
"post": "Posterior",
"pre": "Previa"
},
"thenApplyTheseActions": "Aplíque éstas acciones:",
"thisRuleAppliesToTheseTransactions": "Ésta regla aplica a las siguientes transacciones:"
},
"schedules": {
"addNewSchedule": "Agregar nueva agenda",
"automaticallyAddTransaction": "Agregar transacción automáticamente",
"automaticallyAddTransactionAdvice": "Si se selecciona, la agenda creará automáticamente una transacción para la cuenta especificada",
"createSchedule_one": "Crear agenda",
"createSchedule_many": "Crear agendas",
"createSchedule_other": "Crear agendas",
"doFromFindSchedules": "Puedes hacerlo después desde el ítem de menú \"Buscar agendas\" en la barra lateral",
"doFromToolsFindSchedules": "Puedes hacerlo después desde el ítem de menú \"Herramientas > Buscar agendas\"",
"editAsRule": "Editar como regla",
"expectedSchedulesAdvice": "Si estabas esperando encontrar alguna agenda y no se visualiza, puede deberse a que los beneficiarios de las transacciones no coincidan. Asegurate de cambiar el nombre los beneficiarios en todas las transacciones de una agenda para que sean el mismo.",
"findMatchingTransactions": "Encontrar transacciones que coincidan",
"foundSchedules": "Agendas encontradas",
"foundSomePossibleSchedulesAdvice": "Encontramos posibles agendas en la transacción actual. Selecciona las que desea crear.",
"isApproximately": "es aproximadamente",
"isBetween": "está entre",
"isExactly": "es exactamente",
"linkedTransactions": "Transacciones vinculadas",
"linkSchedule": "Vincular agenda",
"linkToSchedule": "Vincular a agenda",
"nextDate": "Próxima fecha",
"none": "(ninguno)",
"noSchedules": "Sin agendas",
"noSchedulesFound": "No se encontraron agendas",
"postTransaction": "Publicar transacción",
"scheduleNamed": "Agenda: {{name}}",
"selectTransactionsToLinkOnSave": "Seleccionar transacciones para vincular al guardar",
"showCompletedSchedules": "Mostrar agendas completadas",
"skipNextDate": "Saltar próxima fecha",
"skipScheduledDate": "Saltar próxima fecha agendada",
"theseTransactionsMatchThisSchedule": "Éstas transacciones coinciden con la agenda",
"thisScheduleHasCustomConditionsAndActions": "Ésta agenda tiene condiciones y acciones personalizadas",
"unlinkFromSchedule": "Desvincular de la agenda",
"unlinkSchedule": "Desvincular agenda",
"upcomingDates": "Próximas fechas",
"view_one": "Ver agenda"
},
"status": {
"completed": "completo",
"due": "adeudado",
"missed": "omitido",
"paid": "pago",
"pending": "pendiente",
"scheduled": "agendado",
"upcoming": "próximo"
},
"support": {
"anErrorOccuredWhileSaving": "Ocurrió un error al guardar. Por favor, póngase en contacto con {{email}} para obtener asistencia."
},
"transaction": {
"accountIsRequired": "Cuenta es un campo requerido"
}
}

View file

@ -0,0 +1,40 @@
import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import enUKCore from 'loot-core/src/locales/en-GB.json';
import esESCore from 'loot-core/src/locales/es-ES.json';
import enUK from './en-GB.json';
import esES from './es-ES.json';
const resources = {
en: {
web: enUK,
core: enUKCore
},
es: {
web: esES,
core: esESCore
}
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
defaultNS: 'web',
lng: 'es', // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
// We enforce that a locales have all keys so we treat empty string as missing value.
returnEmptyString: false,
fallbackLng: 'en',
interpolation: {
escapeValue: false // react already safes from xss
}
});
export default i18n;

View file

@ -362,8 +362,8 @@ ipcMain.on('screenshot', () => {
let width = 1100;
// This is for the main screenshot inside the frame
clientWin.setSize(width, Math.floor(width * (427 / 623)));
// clientWin.setSize(width, Math.floor(width * (495 / 700)));
clientWin.setSize(width, (width * (427 / 623)) | 0);
// clientWin.setSize(width, (width * (495 / 700)) | 0);
}
});

View file

@ -3,7 +3,7 @@
"productName": "Actual",
"author": "Shift Reset LLC",
"description": "A simple and powerful personal finance system",
"version": "22.12.03",
"version": "4.0.2",
"scripts": {
"clean": "rm -rf dist",
"build": "electron-builder",

View file

@ -1,13 +1,9 @@
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
const actual = require('@actual-app/api/methods');
const { amountToInteger } = require('@actual-app/api/utils');
const AdmZip = require('adm-zip');
const d = require('date-fns');
const normalizePathSep = require('slash');
const uuid = require('uuid');
const AdmZip = require('adm-zip');
const actual = require('@actual-app/api');
const amountToInteger = actual.utils.amountToInteger;
// Utils

View file

@ -16,7 +16,7 @@
"bin": "./index.js",
"homepage": "https://github.com/actualbudget/actual/tree/master/packages/import-ynab4#readme",
"dependencies": {
"@actual-app/api": "*",
"@actual-app/api": "^1.0.0",
"adm-zip": "^0.5.9",
"date-fns": "2.0.0-alpha.27",
"slash": "3.0.0",

View file

@ -1,10 +1,6 @@
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
const actual = require('@actual-app/api/methods');
const d = require('date-fns');
const uuid = require('uuid');
const actual = require('@actual-app/api');
function amountFromYnab(amount) {
// ynabs multiplies amount by 1000 and actual by 100

View file

@ -16,7 +16,7 @@
"bin": "./index.js",
"homepage": "https://github.com/actualbudget/actual/tree/master/packages/import-ynab5#readme",
"dependencies": {
"@actual-app/api": "*",
"@actual-app/api": "^1.0.0",
"date-fns": "2.0.0-alpha.27",
"uuid": "3.3.2"
}

View file

@ -35,31 +35,31 @@ async function init() {
for (let i = 0; i < 100; i++) {
if (Math.random() < 0.02) {
let parent = {
date: '2020-01-' + pad(Math.floor(Math.random() * 30)),
amount: Math.floor(Math.random() * 10000),
date: '2020-01-' + pad((Math.random() * 30) | 0),
amount: (Math.random() * 10000) | 0,
account: accounts[0].id,
notes: 'foo'
};
db.insertTransaction(parent);
db.insertTransaction(
makeChild(parent, {
amount: Math.floor(Math.random() * 1000)
amount: (Math.random() * 1000) | 0
})
);
db.insertTransaction(
makeChild(parent, {
amount: Math.floor(Math.random() * 1000)
amount: (Math.random() * 1000) | 0
})
);
db.insertTransaction(
makeChild(parent, {
amount: Math.floor(Math.random() * 1000)
amount: (Math.random() * 1000) | 0
})
);
} else {
db.insertTransaction({
date: '2020-01-' + pad(Math.floor(Math.random() * 30)),
amount: Math.floor(Math.random() * 10000),
date: '2020-01-' + pad((Math.random() * 30) | 0),
amount: (Math.random() * 10000) | 0,
account: accounts[0].id
});
}

View file

@ -0,0 +1,16 @@
module.exports = {
input: ['src/**/*.js'],
output: 'src/locales/$LOCALE.json',
locales: ['en-GB', 'es-ES'],
defaultNamespace: 'core',
sort: true,
// Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components.
lexers: {
js: ['JsxLexer'],
ts: ['JsxLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JsxLexer']
}
};

View file

@ -12,7 +12,8 @@
"lint": "eslint src",
"test": "npm-run-all -cp 'test:*'",
"test:node": "jest -c jest.config.js",
"test:web": "jest -c jest.web.config.js"
"test:web": "jest -c jest.web.config.js",
"check-i18n": "i18next"
},
"author": "",
"license": "ISC",
@ -56,6 +57,8 @@
"fake-indexeddb": "^3.1.3",
"fast-check": "2.13.0",
"fast-glob": "^2.2.0",
"i18next": "^21.9.1",
"i18next-parser": "^6.5.0",
"jest": "^28.1.0",
"jsverify": "^0.8.4",
"lru-cache": "^5.1.1",

View file

@ -24,12 +24,21 @@ export function applyBudgetAction(month, type, args) {
case 'set-3-avg':
await send('budget/set-3month-avg', { month });
break;
case 'set-all-future':
await send('budget/set-all-future', { startMonth: month });
break;
case 'hold':
await send('budget/hold-for-next-month', {
month,
amount: args.amount
});
break;
case 'hold-all-future':
await send('budget/hold-for-future-months', {
startMonth: month,
amount: args.amount
});
break;
case 'reset-hold':
await send('budget/reset-hold', { month });
break;

View file

@ -10,6 +10,10 @@ import {
import q from '../shared/query';
import { currencyToAmount, amountToInteger } from '../shared/util';
function isInteger(num) {
return (num | 0) === num;
}
export function getAccountFilter(accountId, field = 'account') {
if (accountId) {
if (accountId === 'budgeted') {
@ -78,7 +82,7 @@ export function makeTransactionSearchQuery(currentQuery, search, dateFormat) {
amount: { $transform: '$abs', $eq: amountToInteger(amount) }
},
amount != null &&
Number.isInteger(amount) && {
isInteger(amount) && {
amount: {
$transform: { $abs: { $idiv: ['$', 100] } },
$eq: amount

View file

@ -93,7 +93,7 @@ function initBasicServer(delay) {
function initPagingServer(dataLength, { delay, eventType = 'select' } = {}) {
let data = [];
for (let i = 0; i < dataLength; i++) {
data.push({ id: i, date: subDays('2020-05-01', Math.floor(i / 5)) });
data.push({ id: i, date: subDays('2020-05-01', (i / 5) | 0) });
}
initServer({

View file

@ -0,0 +1,27 @@
{
"general": {
"ordinal_one": "{{count}}st",
"ordinal_two": "{{count}}nd",
"ordinal_few": "{{count}}rd",
"ordinal_other": "{{count}}th"
},
"schedules": {
"recurring": {
"monthly_one": "Every month on the {{day}}",
"monthly_other": "Every {{count}} months on the {{day}}",
"monthlyPattern_one": "Every month on the {{pattern}}",
"monthlyPattern_other": "Every {{count}} months on the {{pattern}}",
"pattern": {
"lastDay": "last day",
"lastWeekday": "last {{dayName}}",
"lastWeekday_sameDay": "last",
"weekAndDay": "{{week}} {{dayName}}",
"weekAndDay_sameDay": "{{week}}"
},
"weekly_one": "Every week on {{day}}",
"weekly_other": "Every {{count}} weeks on {{day}}",
"yearly_one": "Every year on {{day}}",
"yearly_other": "Every {{count}} years on {{day}}"
}
}
}

View file

@ -0,0 +1,28 @@
{
"general": {
"ordinal_other": "{{count}}º"
},
"schedules": {
"recurring": {
"monthly_one": "Cada {{day}} día del mes",
"monthly_many": "Cada {{count}} de meses el {{day}} día",
"monthly_other": "Cada {{count}} meses el {{day}} día",
"monthlyPattern_one": "Cada mes {{pattern}}",
"monthlyPattern_many": "Cada {{count}} de meses {{pattern}}",
"monthlyPattern_other": "Cada {{count}} meses {{pattern}}",
"pattern": {
"lastDay": "el último día",
"lastWeekday": "el último {{dayName}}",
"lastWeekday_sameDay": "el último",
"weekAndDay": "{{week}} {{dayName}}",
"weekAndDay_sameDay": "{{week}}"
},
"weekly_one": "Cada semana {{day}}",
"weekly_many": "Cada {{count}} de semanas el {{day}}",
"weekly_other": "Cada {{count}} semanas el {{day}}",
"yearly_one": "Cada año el {{day}}",
"yearly_many": "Cada {{count}} de años el {{day}}",
"yearly_other": "Cada {{count}} años el {{day}}"
}
}
}

View file

@ -11,7 +11,7 @@ import * as monthUtils from '../shared/months';
import q from '../shared/query';
function pickRandom(list) {
return list[Math.floor(Math.random() * list.length) % list.length];
return list[((Math.random() * list.length) | 0) % list.length];
}
function number(start, end) {
@ -19,7 +19,7 @@ function number(start, end) {
}
function integer(start, end) {
return Math.round(number(start, end));
return number(start, end) | 0;
}
function findMin(items, field) {
@ -104,13 +104,13 @@ async function fillPrimaryChecking(handlers, account, payees, groups) {
amount,
payee: payee.id,
account: account.id,
date: monthUtils.subDays(monthUtils.currentDay(), Math.floor(i / 3)),
date: monthUtils.subDays(monthUtils.currentDay(), (i / 3) | 0),
category: category.id
};
transactions.push(transaction);
if (Math.random() < 0.2) {
let a = Math.round(transaction.amount / 3);
let a = (transaction.amount / 3) | 0;
let pick = () =>
payee === incomePayee
? incomeGroup.categories.find(c => c.name === 'Income').id
@ -244,7 +244,7 @@ async function fillChecking(handlers, account, payees, groups) {
amount,
payee: payee.id,
account: account.id,
date: monthUtils.subDays(monthUtils.currentDay(), i * 2),
date: monthUtils.subDays(monthUtils.currentDay(), (i * 2) | 0),
category: category.id
});
}
@ -334,7 +334,7 @@ async function fillSavings(handlers, account, payees, groups) {
amount,
payee: payee.id,
account: account.id,
date: monthUtils.subDays(monthUtils.currentDay(), i * 5),
date: monthUtils.subDays(monthUtils.currentDay(), (i * 5) | 0),
category: category.id
});
}

View file

@ -6,9 +6,9 @@ export function generateAccount(name, isConnected, type, offbudget) {
return {
id: uuid.v4Sync(),
name,
balance_current: isConnected ? Math.floor(Math.random() * 100000) : null,
bank: isConnected ? Math.floor(Math.random() * 10000) : null,
bankId: isConnected ? Math.floor(Math.random() * 10000) : null,
balance_current: isConnected ? (Math.random() * 100000) | 0 : null,
bank: isConnected ? (Math.random() * 10000) | 0 : null,
bankId: isConnected ? (Math.random() * 10000) | 0 : null,
bankName: isConnected ? 'boa' : null,
type: type || 'checking',
offbudget: offbudget ? 1 : 0,
@ -54,7 +54,7 @@ function _generateTransaction(data) {
const id = data.id || uuid.v4Sync();
return {
id: id,
amount: data.amount || Math.floor(Math.random() * 10000 - 7000),
amount: data.amount || (Math.random() * 10000 - 7000) | 0,
payee: data.payee || (Math.random() < 0.9 ? 'payed-to' : 'guy'),
notes:
Math.random() < 0.1 ? 'A really long note that should overflow' : 'Notes',

View file

@ -456,7 +456,7 @@ function compileLiteral(value) {
} else if (typeof value === 'boolean') {
return typed(value ? 1 : 0, 'boolean', { literal: true });
} else if (typeof value === 'number') {
return typed(value, Number.isInteger(value) ? 'integer' : 'float', {
return typed(value, (value | 0) === value ? 'integer' : 'float', {
literal: true
});
} else if (Array.isArray(value)) {

View file

@ -42,7 +42,7 @@ export function convertInputType(value, type) {
}
return value;
case 'integer':
if (typeof value === 'number' && Number.isInteger(value)) {
if (typeof value === 'number' && (value | 0) === value) {
return value;
} else {
throw new Error("Can't convert to integer: " + JSON.stringify(value));

View file

@ -91,7 +91,7 @@ function expectTransactionOrder(data, fields) {
}
async function expectPagedData(query, numTransactions, allData) {
let pageCount = Math.max(Math.floor(numTransactions / 3), 3);
let pageCount = Math.max((numTransactions / 3) | 0, 3);
let pagedData = [];
let done = false;

View file

@ -1,5 +1,4 @@
import * as monthUtils from '../../shared/months';
import { safeNumber } from '../../shared/util';
import * as db from '../db';
import * as prefs from '../prefs';
import * as sheet from '../sheet';
@ -7,7 +6,7 @@ import { batchMessages } from '../sync';
async function getSheetValue(sheetName, cell) {
const node = await sheet.getCell(sheetName, cell);
return safeNumber(typeof node.value === 'number' ? node.value : 0);
return typeof node.value === 'number' ? node.value : 0;
}
// We want to only allow the positive movement of money back and
@ -72,7 +71,9 @@ export function getBudget({ category, month }) {
}
export function setBudget({ category, month, amount }) {
amount = safeNumber(typeof amount === 'number' ? amount : 0);
if (typeof amount !== 'number') {
amount = 0;
}
const table = getBudgetTable();
let existing = db.firstSync(
@ -184,12 +185,32 @@ export async function set3MonthAvg({ month }) {
'sum-amount-' + cat.id
);
const avg = Math.round((spent1 + spent2 + spent3) / 3);
const avg = ((spent1 + spent2 + spent3) / 3) | 0;
setBudget({ category: cat.id, month, amount: -avg });
}
});
}
export async function setAllFuture({ startMonth }) {
if (!isReflectBudget()) {
throw new Error('setAllFuture only applies to report budget type');
}
let table = getBudgetTable();
let budgetData = await getBudgetData(table, dbMonth(startMonth));
let months = getAllMonths(monthUtils.addMonths(startMonth, 1));
batchMessages(() => {
for (let month of months) {
budgetData.forEach(budget => {
if (budget.is_income === 1 && !isReflectBudget()) {
return;
}
setBudget({ category: budget.category, month, amount: budget.amount });
});
}
});
}
export async function holdForNextMonth({ month, amount }) {
let row = await db.first(
'SELECT buffered FROM zero_budget_months WHERE id = ?',
@ -212,6 +233,18 @@ export async function holdForNextMonth({ month, amount }) {
return false;
}
export async function holdForFutureMonths({ startMonth, amount }) {
let months = getAllMonths(startMonth);
await batchMessages(async () => {
for (let month of months) {
if (!(await holdForNextMonth({ month, amount }))) {
break;
}
}
});
}
export async function resetHold({ month }) {
await setBuffer(month, 0);
}

View file

@ -12,10 +12,15 @@ app.method(
);
app.method('budget/set-zero', mutator(undoable(actions.setZero)));
app.method('budget/set-3month-avg', mutator(undoable(actions.set3MonthAvg)));
app.method('budget/set-all-future', mutator(undoable(actions.setAllFuture)));
app.method(
'budget/hold-for-next-month',
mutator(undoable(actions.holdForNextMonth))
);
app.method(
'budget/hold-for-future-months',
mutator(undoable(actions.holdForFutureMonths))
);
app.method('budget/reset-hold', mutator(undoable(actions.resetHold)));
app.method(
'budget/cover-overspending',

View file

@ -1,4 +1,3 @@
import { safeNumber } from '../../shared/util';
import * as sheet from '../sheet';
import { number, sumAmounts } from './util';
@ -26,17 +25,17 @@ export async function createCategory(cat, sheetName, prevSheetName) {
],
run: (budgeted, sumAmount, prevCarryover, prevLeftover) => {
if (cat.is_income) {
return safeNumber(
return (
number(budgeted) -
number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0)
number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0)
);
}
return safeNumber(
return (
number(budgeted) +
number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0)
number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0)
);
}
});
@ -51,7 +50,7 @@ export async function createCategory(cat, sheetName, prevSheetName) {
refresh: true,
run: (budgeted, sumAmount, carryover) => {
return carryover
? Math.max(0, safeNumber(number(budgeted) + number(sumAmount)))
? Math.max(0, number(budgeted) + number(sumAmount))
: sumAmount;
}
});
@ -110,7 +109,7 @@ export function createSummary(groups, categories, sheetName) {
initialValue: 0,
dependencies: ['total-income', 'total-spent'],
run: (income, spent) => {
return safeNumber(income - -spent);
return income - -spent;
}
});
}

View file

@ -1,5 +1,4 @@
import * as monthUtils from '../../shared/months';
import { safeNumber } from '../../shared/util';
import * as sheet from '../sheet';
import { number, sumAmounts, flatten2, unflatten2 } from './util';
@ -52,10 +51,10 @@ export function createCategory(cat, sheetName, prevSheetName) {
`${prevSheetName}!leftover-pos-${cat.id}`
],
run: (budgeted, spent, prevCarryover, prevLeftover, prevLeftoverPos) => {
return safeNumber(
return (
number(budgeted) +
number(spent) +
(prevCarryover ? number(prevLeftover) : number(prevLeftoverPos))
number(spent) +
(prevCarryover ? number(prevLeftover) : number(prevLeftoverPos))
);
}
});
@ -79,7 +78,7 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
sheet.get().createDynamic(sheetName, 'from-last-month', {
initialValue: 0,
dependencies: [`${prevSheetName}!to-budget`, `${prevSheetName}!buffered`],
run: (toBudget, buffered) => safeNumber(number(toBudget) + number(buffered))
run: (toBudget, buffered) => number(toBudget) + number(buffered)
});
// Alias the group income total to `total-income`
@ -92,8 +91,7 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
sheet.get().createDynamic(sheetName, 'available-funds', {
initialValue: 0,
dependencies: ['total-income', 'from-last-month'],
run: (income, fromLastMonth) =>
safeNumber(number(income) + number(fromLastMonth))
run: (income, fromLastMonth) => number(income) + number(fromLastMonth)
});
sheet.get().createDynamic(sheetName, 'last-month-overspent', {
@ -106,14 +104,12 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
),
run: (...data) => {
data = unflatten2(data);
return safeNumber(
data.reduce((total, [leftover, carryover]) => {
if (carryover) {
return total;
}
return total + Math.min(0, number(leftover));
}, 0)
);
return data.reduce((total, [leftover, carryover]) => {
if (carryover) {
return total;
}
return total + Math.min(0, number(leftover));
}, 0);
}
});
@ -139,11 +135,11 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
'buffered'
],
run: (available, lastOverspent, totalBudgeted, buffered) => {
return safeNumber(
return (
number(available) +
number(lastOverspent) +
number(totalBudgeted) -
number(buffered)
number(lastOverspent) +
number(totalBudgeted) -
number(buffered)
);
}
});

View file

@ -1,14 +1,11 @@
import { safeNumber } from '../../shared/util';
import { number } from '../spreadsheet/globals';
export { number } from '../spreadsheet/globals';
export function sumAmounts(...amounts) {
return safeNumber(
amounts.reduce((total, amount) => {
return total + number(amount);
}, 0)
);
return amounts.reduce((total, amount) => {
return total + number(amount);
}, 0);
}
export function flatten2(arr) {

View file

@ -22,7 +22,7 @@ export function keyToTimestamp(key) {
export function insert(trie, timestamp) {
let hash = timestamp.hash();
let key = Number(Math.floor(timestamp.millis() / 1000 / 60)).toString(3);
let key = Number((timestamp.millis() / 1000 / 60) | 0).toString(3);
trie = Object.assign({}, trie, { hash: trie.hash ^ hash });
return insertKey(trie, key, hash);

View file

@ -34,7 +34,7 @@ export function shoveSortOrders(items, targetId) {
} else {
if (target.sort_order - (before ? before.sort_order : 0) <= 2) {
let next = to;
let order = Math.floor(items[next].sort_order) + SORT_INCREMENT;
let order = (items[next].sort_order | 0) + SORT_INCREMENT;
while (next < items.length) {
// No need to update it if it's already greater than the current
// order. This can happen because there may already be large

View file

@ -171,7 +171,7 @@ function shuffle(arr) {
let shuffled = new Array(src.length);
let item;
while ((item = src.pop())) {
let idx = Math.floor(Math.random() * shuffled.length);
let idx = (Math.random() * shuffled.length) | 0;
if (shuffled[idx]) {
src.push(item);
} else {

View file

@ -210,11 +210,11 @@ export function sheetForMonth(month) {
return 'budget' + month.replace('-', '');
}
export function nameForMonth(month) {
return d.format(_parse(month), "MMMM 'yy");
export function format(month, opts, locale) {
return Intl.DateTimeFormat(locale, opts).format(_parse(month));
}
export function format(month, str) {
export function nonLocalizedFormat(month, str) {
return d.format(_parse(month), str);
}

View file

@ -199,5 +199,5 @@ export function makeValue(value, cond) {
}
export function getApproxNumberThreshold(number) {
return Math.round(Math.abs(number) * 0.075);
return (Math.abs(number) * 0.075) | 0;
}

View file

@ -42,39 +42,49 @@ export function getHasTransactionsQuery(schedules) {
.select(['schedule', 'date']);
}
function makeNumberSuffix(num) {
// Slight abuse of date-fns to turn a number like "1" into the full
// form "1st" but formatting a date with that number
return monthUtils.format(new Date(2020, 0, num, 12), 'do');
}
function prettyDayName(day) {
function prettyDayName(day, locale) {
let days = {
SU: 'Sunday',
MO: 'Monday',
TU: 'Tuesday',
WE: 'Wednesday',
TH: 'Thursday',
FR: 'Friday',
SA: 'Saturday'
SU: new Date('2020-01-05T12:00:00.000Z'),
MO: new Date('2020-01-06T12:00:00.000Z'),
TU: new Date('2020-01-07T12:00:00.000Z'),
WE: new Date('2020-01-08T12:00:00.000Z'),
TH: new Date('2020-01-09T12:00:00.000Z'),
FR: new Date('2020-01-10T12:00:00.000Z'),
SA: new Date('2020-01-11T12:00:00.000Z')
};
return days[day];
return Intl.DateTimeFormat(locale, { weekday: 'long' }).format(days[day]);
}
export function getRecurringDescription(config) {
function formatMonthAndDay(month, i18n) {
let parts = Intl.DateTimeFormat(i18n.resolvedLanguage, {
month: 'short',
day: 'numeric'
}).formatToParts(monthUtils.parseDate(month));
let dayPart = parts.find(p => p.type === 'day');
dayPart.value = i18n.t('general.ordinal', {
count: monthUtils.parseDate(month).getDate(),
ordinal: true,
ns: 'core'
});
return parts.map(part => part.value).join('');
}
export function getRecurringDescription(config, i18n) {
let interval = config.interval || 1;
switch (config.frequency) {
case 'weekly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} weeks` : 'week';
desc += ' on ' + monthUtils.format(config.start, 'EEEE');
return desc;
return i18n.t('schedules.recurring.weekly', {
count: interval,
day: monthUtils.format(
config.start,
{ weekday: 'long' },
i18n.resolvedLanguage
),
ns: 'core'
});
}
case 'monthly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} months` : 'month';
if (config.patterns && config.patterns.length > 0) {
// Sort the days ascending. We filter out -1 because that
// represents "last days" and should always be last, but this
@ -95,56 +105,101 @@ export function getRecurringDescription(config) {
// Add on all -1 values to the end
patterns = patterns.concat(config.patterns.filter(p => p.value === -1));
desc += ' on the ';
let strs = [];
let uniqueDays = new Set(patterns.map(p => p.type));
let isSameDay = uniqueDays.length === 1 && !uniqueDays.has('day');
let context =
uniqueDays.length === 1 && !uniqueDays.has('day')
? 'sameDay'
: undefined;
for (let pattern of patterns) {
if (pattern.type === 'day') {
if (pattern.value === -1) {
strs.push('last day');
strs.push(
i18n.t('schedules.recurring.pattern.lastDay', {
ns: 'core'
})
);
} else {
// Example: 15th day
strs.push(makeNumberSuffix(pattern.value));
strs.push(
i18n.t('general.ordinal', {
count: pattern.value,
ordinal: true,
ns: 'core'
})
);
}
} else {
let dayName = isSameDay ? '' : ' ' + prettyDayName(pattern.type);
let dayName = prettyDayName(
pattern.type,
i18n.resolvedLanguage,
i18n.resolvedLanguage
);
if (pattern.value === -1) {
// Example: last Monday
strs.push('last' + dayName);
// t('schedules.recurring.pattern.lastWeekday')
// t('schedules.recurring.pattern.lastWeekday_sameDay')
strs.push(
i18n.t('schedules.recurring.pattern.lastWeekday', {
context,
dayName,
ns: 'core'
})
);
} else {
// Example: 3rd Monday
strs.push(makeNumberSuffix(pattern.value) + dayName);
// t('schedules.recurring.pattern.weekAndDay')
// t('schedules.recurring.pattern.weekAndDay_sameDay')
strs.push(
i18n.t('schedules.recurring.pattern.weekAndDay', {
context,
week: i18n.t('general.ordinal', {
count: pattern.value,
ordinal: true,
ns: 'core'
}),
dayName,
ns: 'core'
})
);
}
}
}
if (strs.length > 2) {
desc += strs.slice(0, strs.length - 1).join(', ');
desc += ', and ';
desc += strs[strs.length - 1];
} else {
desc += strs.join(' and ');
}
if (isSameDay) {
desc += ' ' + prettyDayName(patterns[0].type);
}
return i18n.t('schedules.recurring.monthlyPattern', {
context,
count: interval,
day: prettyDayName(patterns[0].type, i18n.resolvedLanguage),
pattern: new Intl.ListFormat(i18n.resolvedLanguage, {
style: 'long',
type: 'conjunction'
}).format(strs),
ns: 'core'
});
} else {
desc += ' on the ' + monthUtils.format(config.start, 'do');
return i18n.t('schedules.recurring.monthly', {
count: interval,
day: i18n.t('general.ordinal', {
count: monthUtils.parseDate(config.start).getDate(),
ordinal: true,
ns: 'core'
}),
ns: 'core'
});
}
return desc;
}
case 'yearly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} years` : 'year';
desc += ' on ' + monthUtils.format(config.start, 'LLL do');
return desc;
return i18n.t(
'schedules.recurring.yearly',
{
count: interval,
day: formatMonthAndDay(config.start, i18n),
ns: 'core'
},
i18n.resolvedLanguage
);
}
default:
return 'Recurring error';
@ -221,7 +276,7 @@ export function extractScheduleConds(conditions) {
export function getScheduledAmount(amount) {
if (amount && typeof amount !== 'number') {
return Math.round((amount.num1 + amount.num2) / 2);
return ((amount.num1 + amount.num2) / 2) | 0;
}
return amount;
}

View file

@ -1,7 +1,18 @@
import i18n from 'i18next';
import MockDate from 'mockdate';
import enUKCore from '../locales/en-GB.json';
import { getRecurringDescription } from './schedules';
i18n.init({
lng: 'en',
resources: {
en: {
core: enUKCore
}
}
});
describe('recurring date description', () => {
beforeEach(() => {
MockDate.set(new Date(2021, 4, 14));
@ -9,149 +20,197 @@ describe('recurring date description', () => {
it('describes weekly interval', () => {
expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'weekly' })
getRecurringDescription(
{ start: '2021-05-17', frequency: 'weekly' },
i18n
)
).toBe('Every week on Monday');
expect(
getRecurringDescription({
start: '2021-05-17',
frequency: 'weekly',
interval: 2
})
getRecurringDescription(
{
start: '2021-05-17',
frequency: 'weekly',
interval: 2
},
i18n
)
).toBe('Every 2 weeks on Monday');
});
it('describes monthly interval', () => {
expect(
getRecurringDescription({ start: '2021-04-25', frequency: 'monthly' })
getRecurringDescription(
{ start: '2021-04-25', frequency: 'monthly' },
i18n
)
).toBe('Every month on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
interval: 2
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
interval: 2
},
i18n
)
).toBe('Every 2 months on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 25 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every month on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
interval: 2,
patterns: [{ type: 'day', value: 25 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
interval: 2,
patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every 2 months on the 25th');
// Last day should work
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 31 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 31 }]
},
i18n
)
).toBe('Every month on the 31st');
// -1 should work, representing the last day
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: -1 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: -1 }]
},
i18n
)
).toBe('Every month on the last day');
// Day names should work
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: 2 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: 2 }]
},
i18n
)
).toBe('Every month on the 2nd Friday');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: -1 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: -1 }]
},
i18n
)
).toBe('Every month on the last Friday');
});
it('describes monthly interval with multiple days', () => {
// Note how order doesn't matter - the day should be sorted
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 15 },
{ type: 'day', value: 3 },
{ type: 'day', value: 20 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 15 },
{ type: 'day', value: 3 },
{ type: 'day', value: 20 }
]
},
i18n
)
).toBe('Every month on the 3rd, 15th, and 20th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'day', value: 20 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'day', value: 20 }
]
},
i18n
)
).toBe('Every month on the 3rd, 20th, and last day');
// Mix days and day names
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'FR', value: 2 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'FR', value: 2 }
]
},
i18n
)
).toBe('Every month on the 2nd Friday, 3rd, and last day');
// When there is a mixture of types, day names should always come first
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'SA', value: 1 },
{ type: 'day', value: 2 },
{ type: 'FR', value: 3 },
{ type: 'day', value: 10 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'SA', value: 1 },
{ type: 'day', value: 2 },
{ type: 'FR', value: 3 },
{ type: 'day', value: 10 }
]
},
i18n
)
).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th');
});
it('describes yearly interval', () => {
expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'yearly' })
getRecurringDescription(
{ start: '2021-05-17', frequency: 'yearly' },
i18n
)
).toBe('Every year on May 17th');
expect(
getRecurringDescription({
start: '2021-05-17',
frequency: 'yearly',
interval: 2
})
getRecurringDescription(
{
start: '2021-05-17',
frequency: 'yearly',
interval: 2
},
i18n
)
).toBe('Every 2 years on May 17th');
});
});

View file

@ -298,30 +298,6 @@ export function getNumberFormat() {
setNumberFormat('comma-dot');
// Number utilities
// We dont use `Number.MAX_SAFE_NUMBER` and such here because those
// numbers are so large that it's not safe to convert them to floats
// (i.e. N / 100). For example, `9007199254740987 / 100 ===
// 90071992547409.88`. While the internal arithemetic would be correct
// because we always do that on numbers, the app would potentially
// display wrong numbers. Instead of `2**53` we use `2**51` which
// gives division more room to be correct
const MAX_SAFE_NUMBER = 2 ** 51 - 1;
const MIN_SAFE_NUMBER = -MAX_SAFE_NUMBER;
export function safeNumber(value) {
if (!Number.isInteger(value)) {
throw new Error('safeNumber: number is not an integer: ' + value);
}
if (value > MAX_SAFE_NUMBER || value < MIN_SAFE_NUMBER) {
throw new Error(
"safeNumber: can't safely perform arithmetic with number: " + value
);
}
return value;
}
export function toRelaxedNumber(value) {
return integerToAmount(currencyToInteger(value) || 0);
}
@ -331,7 +307,8 @@ export function toRelaxedInteger(value) {
}
export function integerToCurrency(n) {
return numberFormat.formatter.format(safeNumber(n) / 100);
// Awesome
return numberFormat.formatter.format(n / 100);
}
export function amountToCurrency(n) {
@ -363,7 +340,7 @@ export function amountToInteger(n) {
}
export function integerToAmount(n) {
return parseFloat((safeNumber(n) / 100).toFixed(2));
return parseFloat((n / 100).toFixed(2));
}
// This is used when the input format could be anything (from

View file

@ -1,4 +1,5 @@
import React, { useEffect, useReducer, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { sendCatch } from 'loot-core/src/platform/client/fetch';
@ -12,8 +13,6 @@ import SubtractIcon from 'loot-design/src/svg/Subtract';
import { Button, Select, Input, Tooltip, View, Text, Stack } from './common';
import DateSelect from './DateSelect';
const DATE_FORMAT = 'yyyy-MM-dd';
// ex: There is no 6th Friday of the Month
const MAX_DAY_OF_WEEK_INTERVAL = 5;
@ -62,7 +61,7 @@ function unparseConfig(parsed) {
function createMonthlyRecurrence(startDate) {
return {
value: parseInt(monthUtils.format(startDate, 'd')),
value: parseInt(monthUtils.nonLocalizedFormat(startDate, 'd')),
type: 'day'
};
}
@ -152,8 +151,8 @@ function SchedulePreview({ previewDates }) {
<Stack direction="row" spacing={4} style={{ marginTop: 10 }}>
{previewDates.map(d => (
<View>
<Text>{monthUtils.format(d, dateFormat)}</Text>
<Text>{monthUtils.format(d, 'EEEE')}</Text>
<Text>{monthUtils.nonLocalizedFormat(d, dateFormat)}</Text>
<Text>{monthUtils.nonLocalizedFormat(d, 'EEEE')}</Text>
</View>
))}
</Stack>
@ -370,6 +369,7 @@ export default function RecurringSchedulePicker({
onChange
}) {
let { isOpen, close, getOpenEvents } = useTooltip();
let { i18n } = useTranslation();
function onSave(config) {
onChange(config);
@ -379,7 +379,7 @@ export default function RecurringSchedulePicker({
return (
<View>
<Button {...getOpenEvents()} style={[{ textAlign: 'left' }, buttonStyle]}>
{value ? getRecurringDescription(value) : 'No recurring date'}
{value ? getRecurringDescription(value, i18n) : 'No recurring date'}
</Button>
{isOpen && (
<RecurringScheduleTooltip

View file

@ -1256,7 +1256,7 @@ export const MonthPicker = scope(lively => {
function getCurrentMonthName(startMonth, currentMonth) {
return monthUtils.getYear(startMonth) === monthUtils.getYear(currentMonth)
? monthUtils.format(currentMonth, 'MMM')
? monthUtils.nonLocalizedFormat(currentMonth, 'MMM')
: null;
}
@ -1264,7 +1264,7 @@ export const MonthPicker = scope(lively => {
const currentMonth = monthUtils.currentMonth();
const range = getRangeForYear(currentMonth);
const monthNames = range.map(month => {
return monthUtils.format(month, 'MMM');
return monthUtils.nonLocalizedFormat(month, 'MMM');
});
return {
@ -1314,7 +1314,7 @@ export const MonthPicker = scope(lively => {
flex: '0 0 40px'
}}
>
{monthUtils.format(year, 'yyyy')}
{monthUtils.nonLocalizedFormat(year, 'yyyy')}
</View>
<ElementQuery
sizes={[

View file

@ -321,7 +321,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' }
])}
>
{monthUtils.format(month, 'MMMM')}
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div>
<View
@ -368,6 +368,10 @@ export default React.memo(function BudgetSummary({ month }) {
{
name: 'set-3-avg',
text: 'Set budgets to 3 month avg'
},
{
name: 'set-all-future',
text: 'Apply to all future budgets'
}
]}
/>

View file

@ -204,6 +204,10 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
name: 'buffer',
text: 'Hold for next month'
},
{
name: 'buffer-future',
text: 'Hold for all future months'
},
{
name: 'reset-buffer',
text: "Reset next month's buffer"
@ -220,6 +224,14 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
}}
/>
)}
{state.menuOpen === 'buffer-future' && (
<HoldTooltip
onClose={() => setState({ menuOpen: null })}
onSubmit={amount => {
onBudgetAction(month, 'hold-all-future', { amount });
}}
/>
)}
{state.menuOpen === 'transfer' && (
<TransferTooltip
initialAmountName="leftover"
@ -259,7 +271,10 @@ export default React.memo(function BudgetSummary({ month }) {
setMenuOpen(false);
}
let prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
let prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
@ -325,7 +340,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' }
])}
>
{monthUtils.format(month, 'MMMM')}
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div>
<View

View file

@ -938,6 +938,10 @@ export function ModalButtons({
style
]}
>
{/* Add a dummy button first so that when a user
presses "enter" they do a normal submit, instead of
activating the back button */}
<Button data-hidden={true} style={{ display: 'none' }} />
{leftContent}
<View style={{ flex: 1 }} />
{children}

View file

@ -1092,7 +1092,7 @@ export function BudgetHeader({
}
]}
>
{monthUtils.format(currentMonth, "MMMM ''yy")}
{monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")}
</Text>
{editMode ? (
<Button

View file

@ -692,7 +692,7 @@ export function DateHeader({ date }) {
}}
>
<Text style={[styles.text, { fontSize: 13, color: colors.n4 }]}>
{monthUtils.format(date, 'MMMM dd, yyyy')}
{monthUtils.nonLocalizedFormat(date, 'MMMM dd, yyyy')}
</Text>
</ListItem>
);

View file

@ -116,7 +116,7 @@ function CloseAccount({
<View>
<P>
This account has a balance of{' '}
<strong>{integerToCurrency(balance)}</strong>. To close
<strong>${integerToCurrency(balance)}</strong>. To close
this account, select a different account to transfer this
balance to:
</P>

View file

@ -146,9 +146,7 @@ function CreateLocalAccount({ modalProps, actions, history }) {
)}
<ModalButtons>
<Button onClick={() => modalProps.onBack()} type="button">
Back
</Button>
<Button onClick={() => modalProps.onBack()}>Back</Button>
<Button primary style={{ marginLeft: 10 }}>
Create
</Button>

View file

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import * as d from 'date-fns';
import * as actions from 'loot-core/src/client/actions';
import { format as formatDate_ } from 'loot-core/src/shared/months';
import { nonLocalizedFormat as formatDate_ } from 'loot-core/src/shared/months';
import {
amountToCurrency,
amountToInteger,

View file

@ -397,9 +397,6 @@ const MenuButton = withRouter(function MenuButton({ history }) {
case 'settings':
history.push('/settings');
break;
case 'help':
window.open('https://actualbudget.github.io/docs', '_blank');
break;
case 'close':
dispatch(closeBudget());
break;
@ -414,7 +411,6 @@ const MenuButton = withRouter(function MenuButton({ history }) {
{ name: 'repair-splits', text: 'Repair split transactions' },
Menu.line,
{ name: 'settings', text: 'Settings' },
{ name: 'help', text: 'Help' },
{ name: 'close', text: 'Close File' }
];

View file

@ -41,7 +41,7 @@ export default function useSheetValue(binding, onChange) {
let spreadsheet = useContext(SpreadsheetContext);
let [result, setResult] = useState({
name: sheetName + '!' + binding.name,
value: binding.value === undefined ? null : binding.value,
value: binding.value,
query: binding.query
});
let latestOnChange = useRef(onChange);

View file

@ -7,7 +7,7 @@ let groups = ['y', 'r', 'b', 'n', 'g', 'p'];
let colors = {};
list.forEach((color, idx) => {
const group = Math.floor(idx / 11);
const group = (idx / 11) | 0;
const n = idx % 11;
colors[groups[group] + (n + 1)] = color;

View file

@ -24,11 +24,11 @@ global.Date.now = () => 123456789;
let seqId = 1;
uuid.v4 = function() {
return Promise.resolve('testing-uuid-' + Math.floor(Math.random() * 1000000));
return Promise.resolve('testing-uuid-' + ((Math.random() * 1000000) | 0));
};
uuid.v4Sync = function() {
return 'testing-uuid-' + Math.floor(Math.random() * 1000000);
return 'testing-uuid-' + ((Math.random() * 1000000) | 0);
};
global.__resetWorld = () => {

View file

@ -1,19 +0,0 @@
import React from 'react';
const SvgCheckCircleHollow = props => (
<svg
{...props}
viewBox="0 0 24 24"
style={{
color: '#242134',
...props.style
}}
>
<path
d="M 12 0 C 1.3084197 0 -4.0435475 12.925204 3.515625 20.484375 C 11.074797 28.043547 24 22.69158 24 12 C 23.992285 5.3757944 18.624205 0.0077147446 12 0 z M 12.009766 1.9882812 C 17.531104 1.9947115 22.005288 6.4688953 22.011719 11.990234 C 22.011719 20.90177 11.238144 25.363144 4.9375 19.0625 C -1.3631434 12.761856 3.0982293 1.9882812 12.009766 1.9882812 z M 18.244141 6.5761719 A 1 1 0 0 0 17.316406 7.0175781 L 11.089844 15.46875 L 7.0136719 12.207031 A 1.0004882 1.0004882 0 1 0 5.7636719 13.769531 L 10.652344 17.677734 A 1.011 1.011 0 0 0 12.082031 17.488281 L 18.927734 8.1992188 A 1 1 0 0 0 18.244141 6.5761719 z "
fill="currentColor"
/>
</svg>
);
export default SvgCheckCircleHollow;

View file

@ -28,7 +28,10 @@ import {
} from 'loot-core/src/shared/categories.js';
function BudgetSummary({ month, onClose }) {
const prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
const prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
return (
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>
@ -330,6 +333,11 @@ class Budget extends React.Component {
case 3:
this.onBudgetAction('set-3-avg');
break;
case 4:
if (budgetType === 'report') {
this.onBudgetAction('set-all-future');
break;
}
default:
}
}

914
yarn.lock

File diff suppressed because it is too large Load diff