Compare commits

..

33 Commits

Author SHA1 Message Date
8d8e55fd0b Release 1.209.0 (#1427) 2022-11-05 13:39:26 +01:00
ca18621ce8 Improve locales (#1426) 2022-11-05 12:49:17 +01:00
b8574d24b2 Feature/improve usability of import (#1425)
* Improve usability by adding expected file format

* Update changelog
2022-11-05 12:22:24 +01:00
6d12c27f9c Feature/rename transactions to activities page (#1424)
* Rename transactions to activities

* Update changelog
2022-11-05 10:42:40 +01:00
c2c5326049 Feature/add buy me a coffee badge to about page (#1422)
* Add button: Buy me a coffee

* Update changelog
2022-11-05 10:22:18 +01:00
2a1339b61e Feature/improve usage of premium indicator component (#1421)
* Improve usage of premium indicator component

* Update changelog
2022-11-05 09:12:41 +01:00
c8a2579624 Feature/remove intro image in dark mode (#1420)
* Remove intro image in dark mode

* Update changelog
2022-11-05 09:06:40 +01:00
832ae063df Release 1.208.0 (#1415) 2022-11-03 20:21:12 +01:00
b5e026934f Harmonize extension (before and after) (#1414) 2022-11-03 20:18:40 +01:00
901c997908 Add pagination to activities table (#1404)
* Add pagination to activities table
2022-11-03 20:16:30 +01:00
3b6e0b20e2 Add test case (#1399)
* Add test case

* Fix calculation for portfolio evolution chart

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-11-03 20:06:16 +01:00
e449d51c3c Feature/restructure actions in admin control panel (#1410)
* Restructure actions

* Update changelog
2022-11-02 17:47:03 +01:00
f72d31bab3 Release 1.207.0 (#1409) 2022-10-31 20:27:52 +01:00
4c893c4dcc Bugfix/fix public page (#1408)
* Fix public page

* Update changelog
2022-10-31 20:25:42 +01:00
ffb11cd10e Add instructions for API Authorization (#1406)
* Add instructions for API Authorization
2022-10-31 20:12:42 +01:00
d424b7731e Feature/change background color of dark mode (#1396)
* Darken background color

* Update changelog
2022-10-30 18:48:28 +01:00
6043c87481 Simplify lead (#1401) 2022-10-30 17:02:01 +01:00
fca0a688b6 parse csv date in ISO format (#1303)
* Handle various date formats

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-10-28 11:26:19 +02:00
5c6cc4fed5 Handle division by zero (#1398) 2022-10-26 21:28:26 +02:00
64a7d38ff9 Add more translations (#1394) 2022-10-23 10:28:08 +02:00
68d0d39161 Feature/translate asset and asset sub classes (#1393)
* Translate asset and asset sub classes

* Update changelog
2022-10-22 07:47:05 +02:00
233a8a8a18 Bugfix/improve loading indicator of investment chart (#1392)
* Improve loading indicator

* Update changelog
2022-10-21 20:01:32 +02:00
190779ee35 Release 1.206.2 (#1391) 2022-10-20 23:40:42 +02:00
6ef8121561 Release 1.206.1 (#1390) 2022-10-20 21:49:12 +02:00
58bf57d1e6 Release 1.206.0 (#1389) 2022-10-20 20:58:33 +02:00
71c5412dd5 Add test case: sell partially with huge gain (#1380)
* Add test case: sell partially with huge gain
2022-10-20 20:56:58 +02:00
ae85398c3d Fix test description (#1379) 2022-10-20 20:48:40 +02:00
048900d01b Bugfix/improve performance calculation for sell activitities (#1388)
* Improve performance calculation for SELL activities

* Update changelog

Co-authored-by: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-10-20 20:47:57 +02:00
074b09b543 Add space (#1381) 2022-10-19 07:53:58 +02:00
f9e04022f4 Remove TWR calculation (#1377) 2022-10-18 21:06:28 +02:00
8fd1fbd44a Feature/upgrade nx to version 15 (#1375)
* Upgrade to Nx 15

* Update changelog
2022-10-18 20:05:58 +02:00
0fb33ae71c Feature/migrate angular.json to project.json (#1374)
* Migrate angular.json to project.json

* Update changelog
2022-10-17 20:41:13 +02:00
3a35d72ec2 Fix disabled placeholder color (#1365) 2022-10-17 17:25:50 +02:00
84 changed files with 4194 additions and 2927 deletions

4
.vscode/launch.json vendored
View File

@ -5,11 +5,11 @@
"name": "Debug Jest File", "name": "Debug Jest File",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng", "program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"args": [ "args": [
"test", "test",
"--codeCoverage=false", "--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts" "--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"console": "internalConsole" "console": "internalConsole"

View File

@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.209.0 - 05.11.2022
### Added
- Added the _Buy me a coffee_ button to the about page
### Changed
- Improved the usability of the activities import
- Improved the usage of the premium indicator component
- Removed the intro image in dark mode
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
## 1.208.0 - 03.11.2022
### Added
- Added pagination to the activities table
### Changed
- Restructured the actions in the admin control panel
### Fixed
- Fixed the calculation in the portfolio evolution chart
## 1.207.0 - 31.10.2022
### Added
- Added support for translated labels of asset and asset sub class
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
### Changed
- Darkened the background color of the dark mode
### Fixed
- Fixed the public page
- Improved the loading indicator of the portfolio evolution chart
## 1.206.2 - 20.10.2022
### Changed
- Fixed the `rxjs` version to `7.5.6` (resolutions)
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
### Fixed
- Fixed the performance calculation including `SELL` activities with a significant performance gain
## 1.205.2 - 16.10.2022 ## 1.205.2 - 16.10.2022
### Changed ### Changed

View File

@ -25,7 +25,6 @@ RUN yarn install
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.js jest.preset.js

View File

@ -128,7 +128,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
Open http://localhost:3333 in your browser and accomplish these steps: Open http://localhost:3333 in your browser and accomplish these steps:
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
#### Upgrade Version #### Upgrade Version
@ -158,7 +158,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Start Server ### Start Server
@ -190,20 +190,22 @@ Run `yarn test`
## Public API ## Public API
### Authorization: Bearer Token
Set the header for each request as follows:
```
"Authorization": "Bearer eyJh..."
```
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Import Activities ### Import Activities
#### Request #### Request
`POST http://localhost:3333/api/v1/import` `POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body #### Body
``` ```

View File

@ -1,395 +0,0 @@
{
"version": 1,
"projects": {
"api": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"]
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
}
},
"tags": []
},
"client": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/client",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"development-es": {
"baseHref": "/es/",
"localize": ["es"]
},
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
},
"development-nl": {
"baseHref": "/nl/",
"localize": ["nl"]
},
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"development-es": {
"browserTarget": "client:build:development-es"
},
"development-it": {
"browserTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": [
"messages.de.xlf",
"messages.es.xlf",
"messages.it.xlf",
"messages.nl.xlf"
]
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
},
"client-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
},
"common": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
},
"tags": []
},
"ui": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"],
"options": {
"outputDir": "dist/storybook/ui",
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": []
},
"ui-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}
}

56
apps/api/project.json Normal file
View File

@ -0,0 +1,56 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"generators": {},
"targets": {
"build": {
"executor": "@nrwl/webpack:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc"
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}
},
"tags": []
}

View File

@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'BTCUSD':
if (isSameDay(parseDate('2015-01-01'), date)) {
return { marketPrice: 314.25 };
} else if (isSameDay(parseDate('2017-12-31'), date)) {
return { marketPrice: 14156.4 };
} else if (isSameDay(parseDate('2018-01-01'), date)) {
return { marketPrice: 13657.2 };
}
return { marketPrice: 0 };
case 'NOVN.SW': case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) { if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 }; return { marketPrice: 87.8 };

View File

@ -0,0 +1,110 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2015-01-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(2),
symbol: 'BTCUSD',
type: 'BUY',
unitPrice: new Big(320.43)
},
{
currency: 'CHF',
date: '2017-12-31',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(1),
symbol: 'BTCUSD',
type: 'SELL',
unitPrice: new Big(14156.4)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('13657.2'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
positions: [
{
averagePrice: new Big('320.43'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
investment: new Big('320.43'),
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
marketPrice: 13657.2,
quantity: new Big('1'),
symbol: 'BTCUSD',
transactionCount: 2
}
],
totalInvestment: new Big('320.43')
});
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-01', investment: new Big('-14156.4') }
]);
});
});
});

View File

@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',

View File

@ -0,0 +1,130 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData(
parseDate('2022-03-07')
);
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(chartData[0]).toEqual({
date: '2022-03-07',
netPerformanceInPercentage: 0,
netPerformance: 0,
totalInvestment: 151.6,
value: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
date: '2022-04-11',
netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86,
totalInvestment: 0,
value: 19.86
});
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
investment: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
marketPrice: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-171.46') }
]);
});
});
});

View File

@ -234,21 +234,28 @@ export class PortfolioCalculator {
[symbol: string]: { [date: string]: Big }; [symbol: string]: { [date: string]: Big };
} = {}; } = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {}; const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {}; const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) { for (const symbol of Object.keys(symbols)) {
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({ const { investmentValues, maxInvestmentValues, netPerformanceValues } =
end, this.getSymbolMetrics({
marketSymbolMap, end,
start, marketSymbolMap,
step, start,
symbol, step,
isChartMode: true symbol,
}); isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues; netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues; investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
} }
for (const currentDate of dates) { for (const currentDate of dates) {
@ -267,19 +274,28 @@ export class PortfolioCalculator {
totalInvestmentValues[dateString] = totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0); totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) { if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[ totalInvestmentValues[dateString] = totalInvestmentValues[
dateString dateString
].add(investmentValuesBySymbol[symbol][dateString]); ].add(investmentValuesBySymbol[symbol][dateString]);
} }
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
} }
} }
return Object.keys(totalNetPerformanceValues).map((date) => { return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0) const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
? 0 ? 0
: totalNetPerformanceValues[date] : totalNetPerformanceValues[date]
.div(totalInvestmentValues[date]) .div(maxTotalInvestmentValues[date])
.mul(100) .mul(100)
.toNumber(); .toNumber();
@ -899,13 +915,14 @@ export class PortfolioCalculator {
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const investmentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0); // let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0); // let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0); let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {}; const netPerformanceValues: { [date: string]: Big } = {};
let timeWeightedGrossPerformancePercentage = new Big(1); // let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1); // let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
@ -1000,6 +1017,12 @@ export class PortfolioCalculator {
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
const order = orders[i]; const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(i + 1, order.type, order.itemType);
}
if (order.itemType === 'start') { if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no // Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date // orders of this symbol before the start date
@ -1027,9 +1050,21 @@ export class PortfolioCalculator {
valueAtStartDate = valueOfInvestmentBeforeTransaction; valueAtStartDate = valueOfInvestmentBeforeTransaction;
} }
const transactionInvestment = order.quantity const transactionInvestment =
.mul(order.unitPrice) order.type === 'BUY'
.mul(this.getFactor(order.type)); ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
}
totalInvestment = totalInvestment.plus(transactionInvestment); totalInvestment = totalInvestment.plus(transactionInvestment);
@ -1078,58 +1113,69 @@ export class PortfolioCalculator {
? new Big(0) ? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'totalInvestmentWithGrossPerformanceFromSell',
totalInvestmentWithGrossPerformanceFromSell.toNumber()
);
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
}
const newGrossPerformance = valueOfInvestment const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell) .minus(totalInvestment)
.plus(grossPerformanceFromSells); .plus(grossPerformanceFromSells);
if ( // if (
i > indexOfStartOrder && // i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction // !lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment) // .plus(lastTransactionInvestment)
.eq(0) // .eq(0)
) { // ) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction // const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus( // .minus(
lastValueOfInvestmentBeforeTransaction.plus( // lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment // lastTransactionInvestment
) // )
) // )
.div( // .div(
lastValueOfInvestmentBeforeTransaction.plus( // lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment // lastTransactionInvestment
) // )
); // );
timeWeightedGrossPerformancePercentage = // timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul( // timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn) // new Big(1).plus(grossHoldingPeriodReturn)
); // );
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction // const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate)) // .minus(fees.minus(feesAtStartDate))
.minus( // .minus(
lastValueOfInvestmentBeforeTransaction.plus( // lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment // lastTransactionInvestment
) // )
) // )
.div( // .div(
lastValueOfInvestmentBeforeTransaction.plus( // lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment // lastTransactionInvestment
) // )
); // );
timeWeightedNetPerformancePercentage = // timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul( // timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn) // new Big(1).plus(netHoldingPeriodReturn)
); // );
} // }
grossPerformance = newGrossPerformance; grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment; // lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction = // lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction; // valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') { if (order.itemType === 'start') {
feesAtStartDate = fees; feesAtStartDate = fees;
@ -1142,6 +1188,15 @@ export class PortfolioCalculator {
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment; investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
} }
if (i === indexOfEndOrder) { if (i === indexOfEndOrder) {
@ -1149,11 +1204,11 @@ export class PortfolioCalculator {
} }
} }
timeWeightedGrossPerformancePercentage = // timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1); // timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage = // timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1); // timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus( const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate grossPerformanceAtStartDate
@ -1218,6 +1273,7 @@ export class PortfolioCalculator {
Average price: ${averagePriceAtStartDate.toFixed( Average price: ${averagePriceAtStartDate.toFixed(
2 2
)} -> ${averagePriceAtEndDate.toFixed(2)} )} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)} Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${totalGrossPerformance.toFixed(
2 2
@ -1233,6 +1289,7 @@ export class PortfolioCalculator {
initialValue, initialValue,
grossPerformancePercentage, grossPerformancePercentage,
investmentValues, investmentValues,
maxInvestmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues, netPerformanceValues,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),

View File

@ -10,7 +10,6 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
@ -72,8 +71,13 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false; let hasError = false;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -134,7 +138,13 @@ export class PortfolioController {
accounts[name].current = current / totalValue; accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment; accounts[name].original = original / totalInvestment;
} }
}
if (
hasDetails === false ||
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds', 'committedFunds',
@ -152,11 +162,6 @@ export class PortfolioController {
]); ]);
} }
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
@ -176,7 +181,7 @@ export class PortfolioController {
hasError, hasError,
holdings, holdings,
totalValueInBaseCurrency, totalValueInBaseCurrency,
summary: hasDetails ? portfolioSummary : undefined summary: portfolioSummary
}; };
} }
@ -187,16 +192,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments: InvestmentItem[]; let investments: InvestmentItem[];
if (groupBy === 'month') { if (groupBy === 'month') {
@ -227,6 +222,15 @@ export class PortfolioController {
})); }));
} }
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return { investments }; return { investments };
} }
@ -240,7 +244,8 @@ export class PortfolioController {
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange, dateRange,
impersonationId impersonationId,
userId: this.request.user.id
}); });
if ( if (
@ -274,6 +279,17 @@ export class PortfolioController {
); );
} }
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
}
);
}
return performanceInformation; return performanceInformation;
} }
@ -331,7 +347,7 @@ export class PortfolioController {
dateRange: 'max', dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: access.userId userId: user.id
}); });
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {

View File

@ -3,7 +3,6 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -36,7 +35,8 @@ import {
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition, TimelinePosition,
UserSettings UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
@ -67,11 +67,9 @@ import {
isAfter, isAfter,
isBefore, isBefore,
max, max,
parse,
parseISO, parseISO,
set, set,
setDayOfYear, setDayOfYear,
startOfDay,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
@ -130,9 +128,9 @@ export class PortfolioService {
}), }),
this.getDetails({ this.getDetails({
filters, filters,
userId,
withExcludedAccounts, withExcludedAccounts,
impersonationId: userId impersonationId: userId,
userId: this.request.user.id
}) })
]); ]);
@ -304,12 +302,16 @@ export class PortfolioService {
public async getChart({ public async getChart({
dateRange = 'max', dateRange = 'max',
impersonationId impersonationId,
userCurrency,
userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
impersonationId: string; impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> { }): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(impersonationId, this.request.user.id); userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -317,7 +319,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -355,28 +357,24 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
impersonationId, impersonationId,
userId,
dateRange = 'max', dateRange = 'max',
filters, filters,
userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
impersonationId: string; impersonationId: string;
userId: string;
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
// TODO
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency =
user.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -540,7 +538,11 @@ export class PortfolioService {
withExcludedAccounts withExcludedAccounts
}); });
const summary = await this.getSummary({ impersonationId }); const summary = await this.getSummary({
impersonationId,
userCurrency,
userId
});
return { return {
accounts, accounts,
@ -560,8 +562,9 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const orders = ( const orders = (
await this.orderService.getOrders({ await this.orderService.getOrders({
@ -883,12 +886,16 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
impersonationId impersonationId,
userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
impersonationId: string; impersonationId: string;
userId: string;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(impersonationId, this.request.user.id); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -896,7 +903,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -947,7 +954,9 @@ export class PortfolioService {
const historicalDataContainer = await this.getChart({ const historicalDataContainer = await this.getChart({
dateRange, dateRange,
impersonationId impersonationId,
userCurrency,
userId
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = historicalDataContainer.items.find((item) => {
@ -995,8 +1004,9 @@ export class PortfolioService {
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -1010,7 +1020,7 @@ export class PortfolioService {
} }
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -1030,7 +1040,7 @@ export class PortfolioService {
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userId, userId,
userCurrency: currency userCurrency
}); });
return { return {
rules: { rules: {
@ -1077,7 +1087,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees(orders).toNumber() this.getFees({ orders, userCurrency }).toNumber()
) )
], ],
<UserSettings>this.request.user.Settings.settings <UserSettings>this.request.user.Settings.settings
@ -1180,7 +1190,15 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend(orders: OrderWithAccount[], date = new Date(0)) { private getDividend({
date = new Date(0),
orders,
userCurrency
}: {
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders return orders
.filter((order) => { .filter((order) => {
// Filter out all orders before given date and type dividend // Filter out all orders before given date and type dividend
@ -1193,7 +1211,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency userCurrency
); );
}) })
.reduce( .reduce(
@ -1202,7 +1220,15 @@ export class PortfolioService {
); );
} }
private getFees(orders: OrderWithAccount[], date = new Date(0)) { private getFees({
date = new Date(0),
orders,
userCurrency
}: {
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders return orders
.filter((order) => { .filter((order) => {
// Filter out all orders before given date // Filter out all orders before given date
@ -1212,7 +1238,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency userCurrency
); );
}) })
.reduce( .reduce(
@ -1262,16 +1288,20 @@ export class PortfolioService {
} }
private async getSummary({ private async getSummary({
impersonationId impersonationId,
userCurrency,
userId
}: { }: {
impersonationId: string; impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.settings.baseCurrency; userId = await this.getUserId(impersonationId, userId);
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({ const performanceInformation = await this.getPerformance({
impersonationId impersonationId,
userId
}); });
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
@ -1293,11 +1323,11 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend({ orders, userCurrency }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const fees = this.getFees(orders).toNumber(); const fees = this.getFees({ orders, userCurrency }).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber(); const items = this.getItems(orders).toNumber();
@ -1565,4 +1595,12 @@ export class PortfolioService {
}) })
.reduce((previous, current) => previous + current, 0); .reduce((previous, current) => previous + current, 0);
} }
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
);
}
} }

View File

@ -0,0 +1,22 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
}

201
apps/client/project.json Normal file
View File

@ -0,0 +1,201 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"generators": {
"@schematics/angular:component": {
"style": "scss"
}
},
"sourceRoot": "apps/client/src",
"prefix": "gf",
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"development-es": {
"baseHref": "/es/",
"localize": ["es"]
},
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
},
"development-nl": {
"baseHref": "/nl/",
"localize": ["nl"]
},
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"development-es": {
"browserTarget": "client:build:development-es"
},
"development-it": {
"browserTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": [
"messages.de.xlf",
"messages.es.xlf",
"messages.it.xlf",
"messages.nl.xlf"
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
}

View File

@ -148,8 +148,8 @@ const routes: Routes = [
{ {
path: 'portfolio/activities', path: 'portfolio/activities',
loadChildren: () => loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then( import('./pages/portfolio/activities/activities-page.module').then(
(m) => m.TransactionsPageModule (m) => m.ActivitiesPageModule
) )
}, },
{ {

View File

@ -4,7 +4,7 @@
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="compact-with-outline flex-grow-1 mr-2 without-hint" class="compact-with-outline without-hint w-100"
> >
<mat-select formControlName="status"> <mat-select formControlName="status">
<mat-option></mat-option> <mat-option></mat-option>
@ -15,14 +15,6 @@
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<button
class="mt-1"
color="warn"
mat-flat-button
(click)="onDeleteJobs()"
>
<span i18n>Delete Jobs</span>
</button>
</form> </form>
<table class="gf-table w-100"> <table class="gf-table w-100">
<thead> <thead>
@ -35,7 +27,21 @@
<th class="mat-header-cell px-1 py-2" i18n>Created</th> <th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th> <th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th> <th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
</mat-menu>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -102,12 +108,12 @@
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)"> <button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container> <ng-container i18n>View Data</ng-container>
</button> </button>

View File

@ -150,6 +150,35 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })

View File

@ -13,10 +13,10 @@
<div class="col"> <div class="col">
<table <table
class="gf-table w-100" class="gf-table w-100"
mat-table
matSort matSort
matSortActive="symbol" matSortActive="symbol"
matSortDirection="asc" matSortDirection="asc"
mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="symbol"> <ng-container matColumnDef="symbol">
@ -101,17 +101,37 @@
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n>Gather Recent Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherMax()">
<ng-container i18n>Gather All Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <button
mat-menu-item mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})" (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -43,7 +42,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -162,35 +160,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
} }
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) { public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE, key: PROPERTY_IS_READ_ONLY_MODE,

View File

@ -27,53 +27,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Management</div>
<div class="w-50">
<div class="overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGather7Days()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Recent Data</span>
</button>
</div>
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGatherMax()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2"
color="accent"
mat-flat-button
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div>
</div>
</div>
<div class="align-items-start d-flex my-3"> <div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div> <div class="w-50" i18n>Exchange Rates</div>
<div class="w-50"> <div class="w-50">

View File

@ -18,6 +18,7 @@
<mat-label i18n>Compare with...</mat-label> <mat-label i18n>Compare with...</mat-label>
<mat-select <mat-select
name="benchmark" name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'"
[value]="benchmark" [value]="benchmark"
(selectionChange)="onChangeBenchmark($event.value)" (selectionChange)="onChangeBenchmark($event.value)"
> >

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { BenchmarkComparatorComponent } from './benchmark-comparator.component'; import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
@ -12,6 +13,7 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfPremiumIndicatorModule,
MatSelectModule, MatSelectModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
ReactiveFormsModule ReactiveFormsModule

View File

@ -38,6 +38,7 @@ import {
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns'; import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
import { last } from 'lodash';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -53,6 +54,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() groupBy: GroupBy; @Input() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() isLoading = false;
@Input() locale: string; @Input() locale: string;
@Input() range: DateRange = 'max'; @Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ -60,9 +62,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>; public chart: Chart<any>;
public isLoading = true; private investments: InvestmentItem[];
private values: LineChartItem[];
private data: InvestmentItem[];
public constructor() { public constructor() {
Chart.register( Chart.register(
@ -92,34 +93,49 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
private initialize() { private initialize() {
this.isLoading = true;
// Create a clone // Create a clone
this.data = this.benchmarkDataItems.map((item) => Object.assign({}, item)); this.investments = this.benchmarkDataItems.map((item) =>
Object.assign({}, item)
);
this.values = this.historicalDataItems.map((item) =>
Object.assign({}, item)
);
if (!this.groupBy && this.investments?.length > 0) {
let date: string;
if (!this.groupBy && this.data?.length > 0) {
if (this.range === 'max') { if (this.range === 'max') {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.data[0]; date = format(
this.data.unshift({ subDays(
...firstItem, parseISO(this.investments[0].date),
date: format( this.daysInMarket * 0.05 || 90
subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90),
DATE_FORMAT
), ),
DATE_FORMAT
);
this.investments.unshift({
date,
investment: 0 investment: 0
}); });
this.values.unshift({
date,
value: 0
});
} }
// Extend chart by 5% of days in market (after) // Extend chart by 5% of days in market (after)
const lastItem = this.data[this.data.length - 1]; date = format(
this.data.push({ addDays(
...lastItem, parseDate(last(this.investments).date),
date: format( this.daysInMarket * 0.05 || 90
addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90), ),
DATE_FORMAT DATE_FORMAT
) );
this.investments.push({
date,
investment: last(this.investments).investment
}); });
this.values.push({ date, value: last(this.values).value });
} }
const data = { const data = {
@ -131,7 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 1, borderWidth: this.groupBy ? 0 : 1,
data: this.data.map(({ date, investment }) => { data: this.investments.map(({ date, investment }) => {
return { return {
x: parseDate(date), x: parseDate(date),
y: this.isInPercent ? investment * 100 : investment y: this.isInPercent ? investment * 100 : investment
@ -151,7 +167,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
{ {
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.historicalDataItems.map(({ date, value }) => { data: this.values.map(({ date, value }) => {
return { return {
x: parseDate(date), x: parseDate(date),
y: this.isInPercent ? value * 100 : value y: this.isInPercent ? value * 100 : value
@ -159,7 +175,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}), }),
fill: false, fill: false,
label: $localize`Total Amount`, label: $localize`Total Amount`,
pointRadius: 0 pointRadius: 0,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
}
} }
] ]
}; };
@ -273,8 +297,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}); });
} }
} }
this.isLoading = false;
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration() {

View File

@ -22,9 +22,6 @@
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div> <div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<span *ngIf="summary?.totalSell || summary?.totalSell === 0" class="mr-1"
>-</span
>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [currency]="baseCurrency"

View File

@ -96,6 +96,20 @@
title="Ghostfolio is an independent & bootstrapped business" title="Ghostfolio is an independent & bootstrapped business"
></div> ></div>
</div> </div>
<div
*ngIf="!hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<a
href="https://www.buymeacoffee.com/ghostfolio"
target="_blank"
title="Support Ghostfolio"
><img
class="mb-2"
src="../assets/images/button-buy-me-a-coffee.png"
width="180"
/></a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -177,7 +191,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/faq']" [routerLink]="['/faq']"
>FAQ</a >FAQ</a
> >
@ -189,7 +203,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/about', 'changelog']" [routerLink]="['/about', 'changelog']"
>Changelog & License</a >Changelog & License</a
> >
@ -198,7 +212,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/about', 'privacy-policy']" [routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a >Privacy Policy</a
> >

View File

@ -5,9 +5,9 @@
Manage your wealth like a boss Manage your wealth like a boss
</h1> </h1>
<p class="lead mb-4"> <p class="lead mb-4">
Ghostfolio is a privacy-first, open source dashboard to manage your Ghostfolio is a privacy-first, open source dashboard for your personal
personal finances. Break down your asset allocation, know your net worth finances. Break down your asset allocation, know your net worth and make
and make solid, data-driven investment decisions. solid, data-driven investment decisions.
</p> </p>
<div class="mb-4"> <div class="mb-4">
<a <a

View File

@ -114,9 +114,7 @@
} }
.outro-inner-container { .outro-inner-container {
div { display: none;
background-image: url('/assets/intro-dark.jpg') !important;
}
} }
.video { .video {

View File

@ -2,12 +2,12 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { TransactionsPageComponent } from './transactions-page.component'; import { ActivitiesPageComponent } from './activities-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: TransactionsPageComponent, component: ActivitiesPageComponent,
path: '', path: '',
title: $localize`Activities` title: $localize`Activities`
} }
@ -17,4 +17,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class TransactionsPageRoutingModule {} export class ActivitiesPageRoutingModule {}

View File

@ -10,35 +10,35 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isArray } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component'; import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component'; import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
selector: 'gf-transactions-page', selector: 'gf-activities-page',
styleUrls: ['./transactions-page.scss'], styleUrls: ['./activities-page.scss'],
templateUrl: './transactions-page.html' templateUrl: './activities-page.html'
}) })
export class TransactionsPageComponent implements OnDestroy, OnInit { export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[]; public activities: Activity[];
public defaultAccountId: string; public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateActivity: boolean;
public hasPermissionToDeleteOrder: boolean; public hasPermissionToDeleteActivity: boolean;
public hasPermissionToImportOrders: boolean; public hasPermissionToImportActivities: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public user: User; public user: User;
@ -51,24 +51,22 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService, private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateTransactionDialog(); this.openCreateActivityDialog();
} else if (params['editDialog']) { } else if (params['editDialog']) {
if (this.activities) { if (this.activities) {
const transaction = this.activities.find(({ id }) => { const activity = this.activities.find(({ id }) => {
return id === params['transactionId']; return id === params['activityId'];
}); });
this.openUpdateTransactionDialog(transaction); this.openUpdateActivityDialog(activity);
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
@ -96,7 +94,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe((aId) => { .subscribe((aId) => {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
this.hasPermissionToImportOrders = this.hasPermissionToImportActivities =
hasPermission(globalPermissions, permissions.enableImport) && hasPermission(globalPermissions, permissions.enableImport) &&
!this.hasImpersonationId; !this.hasImpersonationId;
}); });
@ -121,7 +119,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.activities = activities; this.activities = activities;
if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) { if (
this.hasPermissionToCreateActivity &&
this.activities?.length <= 0
) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
@ -129,11 +130,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
public onCloneTransaction(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
this.openCreateTransactionDialog(aActivity); this.openCreateActivityDialog(aActivity);
} }
public onDeleteTransaction(aId: string) { public onDeleteActivity(aId: string) {
this.dataService this.dataService
.deleteOrder(aId) .deleteOrder(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -183,98 +184,30 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} }
public onImport() { public onImport() {
const input = document.createElement('input'); const dialogRef = this.dialog.open(ImportActivitiesDialog, {
input.accept = 'application/JSON, .csv'; data: <ImportActivitiesDialogParams>{
input.type = 'file'; deviceType: this.deviceType,
user: this.user
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
input.onchange = (event) => { dialogRef
this.snackBar.open('⏳' + $localize`Importing data...`); .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
// Getting the file reference .subscribe(() => {
const file = (event.target as HTMLInputElement).files[0]; this.fetchActivities();
});
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
await this.importTransactionsService.importJson({
content: content.activities
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
await this.importTransactionsService.importCsv({
fileContent,
userAccounts: this.user.accounts
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
};
input.click();
} }
public onUpdateTransaction(aTransaction: OrderModel) { public onUpdateActivity(aActivity: OrderModel) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aTransaction.id } queryParams: { activityId: aActivity.id, editDialog: true }
}); });
} }
public openUpdateTransactionDialog(activity: Activity): void { public openUpdateActivityDialog(activity: Activity): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
activity, activity,
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
@ -312,41 +245,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private handleImportError({ private openCreateActivityDialog(aActivity?: Activity): void {
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, {
data: {
activities,
deviceType: this.deviceType,
messages: error?.error?.message
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
}
private handleImportSuccess() {
this.fetchActivities();
this.snackBar.open('✅' + $localize`Import has been completed`, undefined, {
duration: 3000
});
}
private openCreateTransactionDialog(aActivity?: Activity): void {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => { .subscribe((user) => {
this.updateUser(user); this.updateUser(user);
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES'; return account.accountType === 'SECURITIES';
@ -434,11 +340,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
return account.isDefault; return account.isDefault;
})?.id; })?.id;
this.hasPermissionToCreateOrder = hasPermission( this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createOrder
); );
this.hasPermissionToDeleteOrder = hasPermission( this.hasPermissionToDeleteActivity = hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteOrder permissions.deleteOrder
); );

View File

@ -6,14 +6,14 @@
[activities]="activities" [activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToImportActivities]="hasPermissionToImportOrders" [hasPermissionToImportActivities]="hasPermissionToImportActivities"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteTransaction($event)" (activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateActivity($event)"
(export)="onExport($event)" (export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)" (exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
@ -22,15 +22,15 @@
</div> </div>
<div <div
*ngIf="!hasImpersonationId && hasPermissionToCreateOrder && !user.settings.isRestrictedView" *ngIf="!hasImpersonationId && hasPermissionToCreateActivity && !user.settings.isRestrictedView"
class="fab-container" class="fab-container"
> >
<a <a
class="align-items-center d-flex justify-content-center" class="align-items-center d-flex justify-content-center"
color="primary" color="primary"
mat-fab mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }" [queryParams]="{ createDialog: true }"
[routerLink]="[]"
> >
<ion-icon name="add-outline" size="large"></ion-icon> <ion-icon name="add-outline" size="large"></ion-icon>
</a> </a>

View File

@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
import { ActivitiesPageComponent } from './activities-page.component';
import { GfCreateOrUpdateActivityDialogModule } from './create-or-update-activity-dialog/create-or-update-activity-dialog.module';
import { GfImportActivitiesDialogModule } from './import-activities-dialog/import-activities-dialog.module';
@NgModule({
declarations: [ActivitiesPageComponent],
imports: [
ActivitiesPageRoutingModule,
CommonModule,
GfActivitiesTableModule,
GfCreateOrUpdateActivityDialogModule,
GfImportActivitiesDialogModule,
MatButtonModule,
MatSnackBarModule,
RouterModule
],
providers: [ImportActivitiesService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ActivitiesPageModule {}

View File

@ -14,6 +14,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -27,21 +28,25 @@ import {
takeUntil takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@Component({ @Component({
host: { class: 'h-100' }, host: { class: 'h-100' },
selector: 'gf-create-or-update-transaction-dialog', selector: 'gf-create-or-update-activity-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-transaction-dialog.scss'], styleUrls: ['./create-or-update-activity-dialog.scss'],
templateUrl: 'create-or-update-transaction-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class CreateOrUpdateTransactionDialog implements OnDestroy { export class CreateOrUpdateActivityDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass); public assetClasses = Object.keys(AssetClass).map((assetClass) => {
public assetSubClasses = Object.keys(AssetSubClass); return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
@ -55,10 +60,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>, public dialogRef: MatDialogRef<CreateOrUpdateActivityDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@Inject(MAT_DATE_LOCALE) private locale: string @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}

View File

@ -4,17 +4,17 @@
(keyup.enter)="activityForm.valid && onSubmit()" (keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1> <h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1> <h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option value="BUY" i18n>BUY</mat-option> <mat-option i18n value="BUY">BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option> <mat-option i18n value="DIVIDEND">DIVIDEND</mat-option>
<mat-option value="ITEM" i18n>ITEM</mat-option> <mat-option i18n value="ITEM">ITEM</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option> <mat-option i18n value="SELL">SELL</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -156,8 +156,8 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option <mat-option
*ngFor="let assetClass of assetClasses" *ngFor="let assetClass of assetClasses"
[value]="assetClass" [value]="assetClass.id"
>{{ assetClass }}</mat-option >{{ assetClass.label }}</mat-option
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -171,8 +171,8 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option <mat-option
*ngFor="let assetSubClass of assetSubClasses" *ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass" [value]="assetSubClass.id"
>{{ assetSubClass }}</mat-option >{{ assetSubClass.label }}</mat-option
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

View File

@ -13,10 +13,10 @@ import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component'; import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
@NgModule({ @NgModule({
declarations: [CreateOrUpdateTransactionDialog], declarations: [CreateOrUpdateActivityDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfSymbolModule, GfSymbolModule,
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfCreateOrUpdateTransactionDialogModule {} export class GfCreateOrUpdateActivityDialogModule {}

View File

@ -2,7 +2,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams { export interface CreateOrUpdateActivityDialogParams {
accountId: string; accountId: string;
accounts: Account[]; accounts: Account[];
activity: Activity; activity: Activity;

View File

@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { isArray } from 'lodash';
import { Subject } from 'rxjs';
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-import-activities-dialog',
styleUrls: ['./import-activities-dialog.scss'],
templateUrl: 'import-activities-dialog.html'
})
export class ImportActivitiesDialog implements OnDestroy {
public details: any[] = [];
public errorMessages: string[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
private importActivitiesService: ImportActivitiesService,
private snackBar: MatSnackBar
) {}
public ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public onImport() {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Importing data...`);
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
console.log(fileContent);
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
await this.importActivitiesService.importJson({
content: content.activities
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
await this.importActivitiesService.importCsv({
fileContent,
userAccounts: this.data.user.accounts
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
};
input.click();
}
public onReset() {
this.details = [];
this.errorMessages = [];
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.errorMessages = error?.error?.message;
for (const message of this.errorMessages) {
if (message.includes('activities.')) {
let [index] = message.split(' ');
index = index.replace('activities.', '');
[index] = index.split('.');
this.details.push(activities[index]);
} else {
this.details.push('');
}
}
this.changeDetectorRef.markForCheck();
}
private handleImportSuccess() {
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
undefined,
{
duration: 3000
}
);
this.dialogRef.close();
}
}

View File

@ -0,0 +1,71 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="errorMessages.length === 0 ? 'Import Activities' : 'Import Activities Error'"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<ng-container *ngIf="errorMessages.length === 0">
<div class="d-flex justify-content-center flex-column">
<button
class="py-3"
color="primary"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
</button>
<p class="mb-0 mt-4 text-center">
<span class="mr-1" i18n>The following file formats are supported:</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</p>
</div>
</ng-container>
<ng-container *ngIf="errorMessages.length > 0">
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-2">
<button mat-button (click)="onReset()">
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<span i18n>Back</span>
</button>
</div>
</ng-container>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>

View File

@ -6,10 +6,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { ImportTransactionDialog } from './import-transaction-dialog.component'; import { ImportActivitiesDialog } from './import-activities-dialog.component';
@NgModule({ @NgModule({
declarations: [ImportTransactionDialog], declarations: [ImportActivitiesDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfDialogFooterModule, GfDialogFooterModule,
@ -20,4 +20,4 @@ import { ImportTransactionDialog } from './import-transaction-dialog.component';
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfImportTransactionDialogModule {} export class GfImportActivitiesDialogModule {}

View File

@ -1,6 +1,10 @@
:host { :host {
display: block; display: block;
a {
color: rgba(var(--palette-primary-500), 1);
}
.mat-expansion-panel { .mat-expansion-panel {
background: none; background: none;
box-shadow: none; box-shadow: none;

View File

@ -0,0 +1,6 @@
import { User } from '@ghostfolio/common/interfaces';
export interface ImportActivitiesDialogParams {
deviceType: string;
user: User;
}

View File

@ -19,6 +19,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -174,7 +175,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({ assetClassFilters.push({
id: assetClass, id: assetClass,
label: assetClass, label: translate(assetClass),
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}); });
} }

View File

@ -35,6 +35,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[]; public investmentsByMonth: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean; public isLoadingBenchmarkComparator: boolean;
public isLoadingInvestmentChart: boolean;
public mode: GroupBy = 'month'; public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [ public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' } { label: $localize`Monthly`, value: 'month' }
@ -125,6 +126,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private update() { private update() {
this.isLoadingBenchmarkComparator = true; this.isLoadingBenchmarkComparator = true;
this.isLoadingInvestmentChart = true;
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
@ -156,6 +158,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
this.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems(); this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -125,6 +125,7 @@
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingBenchmarkComparator"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange" [range]="user?.settings?.dateRange"
></gf-investment-chart> ></gf-investment-chart>

View File

@ -3,7 +3,13 @@
<div class="col-lg"> <div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> <h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div> <div>
<h4 class="mb-3" i18n>Calculator</h4> <h4 class="align-items-center d-flex mb-3">
<span i18n>Calculator</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-fire-calculator <gf-fire-calculator
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -18,7 +24,13 @@
</div> </div>
</div> </div>
<div> <div>
<h4 i18n>4% Rule</h4> <h4 class="align-items-center d-flex">
<span i18n>4% Rule</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div *ngIf="isLoading"> <div *ngIf="isLoading">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator'; import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -13,6 +14,7 @@ import { FirePageComponent } from './fire-page.component';
CommonModule, CommonModule,
FirePageRoutingModule, FirePageRoutingModule,
GfFireCalculatorModule, GfFireCalculatorModule,
GfPremiumIndicatorModule,
GfValueModule, GfValueModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -13,6 +13,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource } from '@prisma/client'; import { AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -130,7 +131,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({ assetClassFilters.push({
id: assetClass, id: assetClass,
label: assetClass, label: translate(assetClass),
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}); });
} }

View File

@ -40,13 +40,7 @@
</div> </div>
<div class="col-xs-12 col-md-6 mb-3"> <div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex"> <h4 i18n>Allocations</h4>
<span i18n>Allocations</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n> <div class="flex-grow-1" i18n>
Check the allocations of your portfolio by account, asset class, Check the allocations of your portfolio by account, asset class,
currency, sector and region. currency, sector and region.
@ -65,13 +59,7 @@
</div> </div>
<div class="col-xs-12 col-md-6 mb-3"> <div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex"> <h4 i18n>Analysis</h4>
<span i18n>Analysis</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n> <div class="flex-grow-1" i18n>
Ghostfolio Analysis visualizes your portfolio and shows your top and Ghostfolio Analysis visualizes your portfolio and shows your top and
bottom performers. bottom performers.
@ -90,13 +78,7 @@
</div> </div>
<div class="col-xs-12 col-md-6 mb-3"> <div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex"> <h4>X-ray</h4>
<span>X-ray</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n> <div class="flex-grow-1" i18n>
Ghostfolio X-ray uses static analysis to identify potential issues and Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio. risks in your portfolio.
@ -111,13 +93,7 @@
</div> </div>
<div class="col-xs-12 col-md-6 mb-3"> <div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex"> <h4>FIRE</h4>
<span i18n>FIRE</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n> <div class="flex-grow-1" i18n>
Ghostfolio FIRE calculates metrics for the Ghostfolio FIRE calculates metrics for the
<i>Financial Independence, Retire Early</i> lifestyle. <i>Financial Independence, Retire Early</i> lifestyle.

View File

@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { PortfolioPageRoutingModule } from './portfolio-page-routing.module'; import { PortfolioPageRoutingModule } from './portfolio-page-routing.module';
import { PortfolioPageComponent } from './portfolio-page.component'; import { PortfolioPageComponent } from './portfolio-page.component';
@ -12,7 +11,6 @@ import { PortfolioPageComponent } from './portfolio-page.component';
declarations: [PortfolioPageComponent], declarations: [PortfolioPageComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfPremiumIndicatorModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
PortfolioPageRoutingModule, PortfolioPageRoutingModule,

View File

@ -14,21 +14,39 @@
> >
</p> </p>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0">Currency Cluster Risks</h4> <h4 class="align-items-center d-flex m-0">
<span>Currency Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" [hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
></gf-rules> ></gf-rules>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0">Account Cluster Risks</h4> <h4 class="align-items-center d-flex m-0">
<span>Account Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" [hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
></gf-rules> ></gf-rules>
</div> </div>
<div> <div>
<h4 class="m-0">Fees</h4> <h4 class="align-items-center d-flex m-0">
<span>Fees</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" [hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules" [rules]="feeRules"

View File

@ -1,13 +1,19 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RulesModule } from '@ghostfolio/client/components/rules/rules.module'; import { RulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { ReportPageRoutingModule } from './report-page-routing.module'; import { ReportPageRoutingModule } from './report-page-routing.module';
import { ReportPageComponent } from './report-page.component'; import { ReportPageComponent } from './report-page.component';
@NgModule({ @NgModule({
declarations: [ReportPageComponent], declarations: [ReportPageComponent],
imports: [CommonModule, ReportPageRoutingModule, RulesModule], imports: [
CommonModule,
GfPremiumIndicatorModule,
ReportPageRoutingModule,
RulesModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class ReportPageModule {} export class ReportPageModule {}

View File

@ -1,50 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { ImportTransactionDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-import-transaction-dialog',
styleUrls: ['./import-transaction-dialog.scss'],
templateUrl: 'import-transaction-dialog.html'
})
export class ImportTransactionDialog implements OnDestroy {
public details: any[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
public dialogRef: MatDialogRef<ImportTransactionDialog>
) {}
public ngOnInit() {
for (const message of this.data.messages) {
if (message.includes('activities.')) {
let [index] = message.split(' ');
index = index.replace('activities.', '');
[index] = index.split('.');
this.details.push(this.data.activities[index]);
} else {
this.details.push('');
}
}
}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,36 +0,0 @@
<gf-dialog-header
mat-dialog-title
title="Import Activities Error"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of data.messages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>

View File

@ -1,5 +0,0 @@
export interface ImportTransactionDialogParams {
activities: any[];
deviceType: string;
messages: string[];
}

View File

@ -1,30 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
import { TransactionsPageComponent } from './transactions-page.component';
@NgModule({
declarations: [TransactionsPageComponent],
exports: [],
imports: [
CommonModule,
GfActivitiesTableModule,
GfCreateOrUpdateTransactionDialogModule,
GfImportTransactionDialogModule,
MatButtonModule,
MatSnackBarModule,
RouterModule,
TransactionsPageRoutingModule
],
providers: [ImportTransactionsService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TransactionsPageModule {}

View File

@ -32,8 +32,6 @@
} }
.intro-container { .intro-container {
.intro { display: none;
background-image: url('/assets/intro-dark.jpg') !important;
}
} }
} }

View File

@ -35,7 +35,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { translate } from '@ghostfolio/ui/i18n';
import {
AssetClass,
AssetSubClass,
DataSource,
Order as OrderModel
} from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy } from 'lodash'; import { cloneDeep, groupBy } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -232,6 +238,19 @@ export class DataService {
response.summary.firstOrderDate response.summary.firstOrderDate
); );
} }
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].assetClass = translate(
response.holdings[symbol].assetClass
);
response.holdings[symbol].assetSubClass = translate(
response.holdings[symbol].assetSubClass
);
}
}
return response; return response;
}) })
); );
@ -285,6 +304,20 @@ export class DataService {
} }
} }
if (data.SymbolProfile) {
if (data.SymbolProfile.assetClass) {
data.SymbolProfile.assetClass = <AssetClass>(
translate(data.SymbolProfile.assetClass)
);
}
if (data.SymbolProfile.assetSubClass) {
data.SymbolProfile.assetSubClass = <AssetSubClass>(
translate(data.SymbolProfile.assetSubClass)
);
}
}
return data; return data;
}) })
); );

View File

@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Account, DataSource, Type } from '@prisma/client'; import { Account, DataSource, Type } from '@prisma/client';
import { parse } from 'date-fns'; import { isMatch, parse, parseISO } from 'date-fns';
import { isFinite } from 'lodash'; import { isFinite } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
@ -11,7 +11,7 @@ import { catchError } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ImportTransactionsService { export class ImportActivitiesService {
private static ACCOUNT_KEYS = ['account', 'accountid']; private static ACCOUNT_KEYS = ['account', 'accountid'];
private static CURRENCY_KEYS = ['ccy', 'currency']; private static CURRENCY_KEYS = ['ccy', 'currency'];
private static DATA_SOURCE_KEYS = ['datasource']; private static DATA_SOURCE_KEYS = ['datasource'];
@ -90,7 +90,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.ACCOUNT_KEYS) { for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
if (item[key]) { if (item[key]) {
return userAccounts.find((account) => { return userAccounts.find((account) => {
return ( return (
@ -115,7 +115,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.CURRENCY_KEYS) { for (const key of ImportActivitiesService.CURRENCY_KEYS) {
if (item[key]) { if (item[key]) {
return item[key]; return item[key];
} }
@ -130,7 +130,7 @@ export class ImportTransactionsService {
private parseDataSource({ item }: { item: any }) { private parseDataSource({ item }: { item: any }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.DATA_SOURCE_KEYS) { for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
if (item[key]) { if (item[key]) {
return DataSource[item[key].toUpperCase()]; return DataSource[item[key].toUpperCase()];
} }
@ -151,15 +151,17 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
let date: string; let date: string;
for (const key of ImportTransactionsService.DATE_KEYS) { for (const key of ImportActivitiesService.DATE_KEYS) {
if (item[key]) { if (item[key]) {
try { if (isMatch(item[key], 'dd-MM-yyyy')) {
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString(); date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString();
} catch {} } else if (isMatch(item[key], 'dd/MM/yyyy')) {
try {
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString(); date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
} catch {} } else {
try {
date = parseISO(item[key]).toISOString();
} catch {}
}
if (date) { if (date) {
return date; return date;
@ -184,7 +186,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.FEE_KEYS) { for (const key of ImportActivitiesService.FEE_KEYS) {
if (isFinite(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
@ -207,7 +209,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.QUANTITY_KEYS) { for (const key of ImportActivitiesService.QUANTITY_KEYS) {
if (isFinite(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
@ -230,7 +232,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.SYMBOL_KEYS) { for (const key of ImportActivitiesService.SYMBOL_KEYS) {
if (item[key]) { if (item[key]) {
return item[key]; return item[key];
} }
@ -253,7 +255,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.TYPE_KEYS) { for (const key of ImportActivitiesService.TYPE_KEYS) {
if (item[key]) { if (item[key]) {
switch (item[key].toLowerCase()) { switch (item[key].toLowerCase()) {
case 'buy': case 'buy':
@ -287,7 +289,7 @@ export class ImportTransactionsService {
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) { for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
if (isFinite(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ $mat-css-light-theme-selector: '.is-light-theme';
} }
:root { :root {
--dark-background: rgb(39, 39, 39); --dark-background: rgb(25, 25, 25);
--font-family-sans-serif: Roboto, 'Helvetica Neue', sans-serif; --font-family-sans-serif: Roboto, 'Helvetica Neue', sans-serif;
--light-background: rgb(255, 255, 255); --light-background: rgb(255, 255, 255);
} }
@ -90,6 +90,10 @@ body {
} }
} }
.mat-paginator {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
.svgMap-tooltip { .svgMap-tooltip {
background: var(--dark-background); background: var(--dark-background);
@ -102,6 +106,15 @@ body {
.mat-select-placeholder { .mat-select-placeholder {
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
} }
&.mat-select-disabled {
.mat-select-placeholder {
color: rgba(
var(--palette-foreground-disabled-text-dark),
var(--palette-foreground-disabled-text-dark-alpha)
);
}
}
} }
} }
} }
@ -211,6 +224,14 @@ ngx-skeleton-loader {
} }
} }
.mat-paginator {
background-color: rgba(var(--palette-foreground-base-light), 0.02);
.mat-paginator-page-size {
display: none;
}
}
.no-min-width { .no-min-width {
min-width: unset !important; min-width: unset !important;
} }
@ -239,4 +260,13 @@ ngx-skeleton-loader {
.mat-select-placeholder { .mat-select-placeholder {
color: rgba(var(--dark-primary-text)); color: rgba(var(--dark-primary-text));
} }
&.mat-select-disabled {
.mat-select-placeholder {
color: rgba(
var(--palette-foreground-disabled-text),
var(--palette-foreground-disabled-text-alpha)
);
}
}
} }

28
apps/ui-e2e/project.json Normal file
View File

@ -0,0 +1,28 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}

22
libs/common/project.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/common/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
},
"tags": []
}

View File

@ -40,6 +40,7 @@ export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_PAGE_SIZE = 50;
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE'; export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = { export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {

57
libs/ui/project.json Normal file
View File

@ -0,0 +1,57 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"generators": {
"@schematics/angular:component": {
"style": "scss"
}
},
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"targets": {
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"executor": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"],
"options": {
"outputDir": "dist/storybook/ui",
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": []
}

View File

@ -18,6 +18,7 @@ import {
} from '@angular/material/autocomplete'; } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips'; import { MatChipInputEvent } from '@angular/material/chips';
import { Filter, FilterGroup } from '@ghostfolio/common/interfaces'; import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -136,7 +137,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
for (const type of Object.keys(filterGroupsMap)) { for (const type of Object.keys(filterGroupsMap)) {
filterGroups.push({ filterGroups.push({
name: <Filter['type']>type, name: <Filter['type']>translate(type),
filters: filterGroupsMap[type] filters: filterGroupsMap[type]
}); });
} }

View File

@ -9,10 +9,10 @@
<div class="activities"> <div class="activities">
<table <table
class="gf-table w-100" class="gf-table w-100"
mat-table
matSort matSort
matSortActive="date" matSortActive="date"
matSortDirection="desc" matSortDirection="desc"
mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="count"> <ng-container matColumnDef="count">
@ -27,7 +27,11 @@
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell px-1 text-right"
mat-cell mat-cell
> >
{{ dataSource.data.length - i }} {{
dataSource.data.length > pageSize
? dataSource.data.length - pageSize * pageIndex - i
: dataSource.data.length - i
}}
</td> </td>
<td <td
*matFooterCellDef *matFooterCellDef
@ -51,7 +55,7 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Type</ng-container> <ng-container i18n>Type</ng-container>
</th> </th>
<td *matCellDef="let element" mat-cell class="px-1"> <td *matCellDef="let element" class="px-1" mat-cell>
<div <div
class="d-inline-flex p-1 type-badge" class="d-inline-flex p-1 type-badge"
[ngClass]="{ [ngClass]="{
@ -389,6 +393,10 @@
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
mat-row mat-row
[ngClass]="{
'cursor-pointer':
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
}"
(click)=" (click)="
hasPermissionToOpenDetails && hasPermissionToOpenDetails &&
!row.isDraft && !row.isDraft &&
@ -398,10 +406,6 @@
symbol: row.SymbolProfile.symbol symbol: row.SymbolProfile.symbol
}) })
" "
[ngClass]="{
'cursor-pointer':
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
}"
></tr> ></tr>
<tr <tr
*matFooterRowDef="displayedColumns" *matFooterRowDef="displayedColumns"
@ -411,6 +415,18 @@
</table> </table>
</div> </div>
<mat-paginator
showFirstLastButtons="true"
[ngClass]="{
'd-none':
isLoading ||
dataSource.data.length === 0 ||
dataSource.data.length <= pageSize
}"
[pageSize]="pageSize"
(page)="onChangePage($event)"
></mat-paginator>
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading" *ngIf="isLoading"
animation="pulse" animation="pulse"

View File

@ -8,10 +8,12 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -37,6 +39,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() hasPermissionToImportActivities: boolean; @Input() hasPermissionToImportActivities: boolean;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() locale: string; @Input() locale: string;
@Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showSymbolColumn = true; @Input() showSymbolColumn = true;
@ -47,6 +50,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() exportDrafts = new EventEmitter<string[]>(); @Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public allFilters: Filter[]; public allFilters: Filter[];
@ -59,6 +63,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public isAfter = isAfter; public isAfter = isAfter;
public isLoading = true; public isLoading = true;
public isUUID = isUUID; public isUUID = isUUID;
public pageIndex = 0;
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
@ -119,12 +124,20 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
} }
return contains; return contains;
}; };
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.updateFilters(); this.updateFilters();
} }
} }
public onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
}
public onCloneActivity(aActivity: OrderWithAccount) { public onCloneActivity(aActivity: OrderWithAccount) {
this.activityToClone.emit(aActivity); this.activityToClone.emit(aActivity);
} }
@ -231,6 +244,21 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
return Object.values(fieldValueMap); return Object.values(fieldValueMap);
} }
private getPaginatedData() {
if (this.dataSource.data.length > this.pageSize) {
const sortedData = this.dataSource.sortData(
this.dataSource.filteredData,
this.dataSource.sort
);
return sortedData.slice(
this.pageIndex * this.pageSize,
(this.pageIndex + 1) * this.pageSize
);
}
return this.dataSource.filteredData;
}
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] { private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
const fieldValueMap: { [id: string]: Filter } = {}; const fieldValueMap: { [id: string]: Filter } = {};
@ -243,8 +271,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
private getTotalFees() { private getTotalFees() {
let totalFees = new Big(0); let totalFees = new Big(0);
const paginatedData = this.getPaginatedData();
for (const activity of this.dataSource.filteredData) { for (const activity of paginatedData) {
if (isNumber(activity.feeInBaseCurrency)) { if (isNumber(activity.feeInBaseCurrency)) {
totalFees = totalFees.plus(activity.feeInBaseCurrency); totalFees = totalFees.plus(activity.feeInBaseCurrency);
} else { } else {
@ -257,8 +285,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
private getTotalValue() { private getTotalValue() {
let totalValue = new Big(0); let totalValue = new Big(0);
const paginatedData = this.getPaginatedData();
for (const activity of this.dataSource.filteredData) { for (const activity of paginatedData) {
if (isNumber(activity.valueInBaseCurrency)) { if (isNumber(activity.valueInBaseCurrency)) {
if (activity.type === 'BUY' || activity.type === 'ITEM') { if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency); totalValue = totalValue.plus(activity.valueInBaseCurrency);

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@ -26,6 +27,7 @@ import { ActivitiesTableComponent } from './activities-table.component';
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,

38
libs/ui/src/lib/i18n.ts Normal file
View File

@ -0,0 +1,38 @@
import '@angular/localize/init';
const locales = {
ACCOUNT: $localize`Account`,
ASSET_CLASS: $localize`Asset Class`,
EMERGENCY_FUND: $localize`Emergency Fund`,
OTHER: $localize`Other`,
SYMBOL: $localize`Symbol`,
TAG: $localize`Tag`,
// enum AssetClass
CASH: $localize`Cash`,
COMMODITY: $localize`Commodity`,
EQUITY: $localize`Equity`,
FIXED_INCOME: $localize`Fixed Income`,
REAL_ESTATE: $localize`Real Estate`,
// enum AssetSubClass
BOND: $localize`Bond`,
CRYPTOCURRENCY: $localize`Cryptocurrency`,
ETF: $localize`ETF`,
MUTUALFUND: $localize`Mutual Fund`,
PRECIOUS_METAL: $localize`Precious Metal`,
PRIVATE_EQUITY: $localize`Private Equity`,
STOCK: $localize`Stock`,
// Continents
Africa: $localize`Africa`,
Asia: $localize`Asia`,
Europe: $localize`Europe`,
'North America': $localize`North America`,
Oceania: $localize`Oceania`,
'South America': $localize`South America`
};
export function translate(aKey: string) {
return locales[aKey] ?? aKey;
}

View File

@ -1,3 +1,5 @@
import '@angular/localize/init';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Meta, Story, moduleMetadata } from '@storybook/angular'; import { Meta, Story, moduleMetadata } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

View File

@ -15,6 +15,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getTextColor } from '@ghostfolio/common/helper'; import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { ChartConfiguration, Tooltip } from 'chart.js'; import { ChartConfiguration, Tooltip } from 'chart.js';
@ -365,12 +366,12 @@ export class PortfolioProportionChartComponent
let symbol = context.chart.data.labels?.[labelIndex] ?? ''; let symbol = context.chart.data.labels?.[labelIndex] ?? '';
if (symbol === this.OTHER_KEY) { if (symbol === this.OTHER_KEY) {
symbol = 'Other'; symbol = $localize`Other`;
} else if (symbol === UNKNOWN_KEY) { } else if (symbol === UNKNOWN_KEY) {
symbol = 'No data available'; symbol = $localize`No data available`;
} }
const name = this.positions[<string>symbol]?.name; const name = translate(this.positions[<string>symbol]?.name);
let sum = 0; let sum = 0;
for (const item of context.dataset.data) { for (const item of context.dataset.data) {
@ -380,7 +381,7 @@ export class PortfolioProportionChartComponent
const percentage = (context.parsed * 100) / sum; const percentage = (context.parsed * 100) / sum;
if (<number>context.raw === Number.MAX_SAFE_INTEGER) { if (<number>context.raw === Number.MAX_SAFE_INTEGER) {
return 'No data available'; return $localize`No data available`;
} else if (this.isInPercent) { } else if (this.isInPercent) {
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`]; return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else { } else {

View File

@ -44,7 +44,7 @@
class="mb-0 text-truncate value" class="mb-0 text-truncate value"
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }" [ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
> >
{{ formattedValue | titlecase }} {{ formattedValue }}
</div> </div>
</ng-container> </ng-container>
</div> </div>

38
nx.json
View File

@ -1,14 +1,4 @@
{ {
"implicitDependencies": {
"angular.json": "*",
"package.json": {
"dependencies": "*",
"devDependencies": "*"
},
"tsconfig.base.json": "*",
".eslintrc.json": "*",
"nx.json": "*"
},
"affected": { "affected": {
"defaultBase": "origin/main" "defaultBase": "origin/main"
}, },
@ -46,7 +36,33 @@
"$schema": "./node_modules/nx/schemas/nx-schema.json", "$schema": "./node_modules/nx/schemas/nx-schema.json",
"targetDefaults": { "targetDefaults": {
"build": { "build": {
"dependsOn": ["^build"] "dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"e2e": {
"inputs": ["default", "^production"]
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
},
"build-storybook": {
"inputs": ["default", "^production", "{workspaceRoot}/.storybook/**/*"]
} }
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": [
"{workspaceRoot}/angular.json",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/nx.json"
],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/.storybook/**/*",
"!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)"
]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.205.2", "version": "1.209.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -28,7 +28,7 @@
"database:validate": "prisma validate", "database:validate": "prisma validate",
"dep-graph": "nx dep-graph", "dep-graph": "nx dep-graph",
"e2e": "ng e2e", "e2e": "ng e2e",
"extract-locales": "ng extract-i18n client --output-path ./apps/client/src/locales", "extract-locales": "nx run client:extract-i18n --output-path ./apps/client/src/locales",
"format": "nx format:write", "format": "nx format:write",
"format:check": "nx format:check", "format:check": "nx format:check",
"format:write": "nx format:write", "format:write": "nx format:write",
@ -40,15 +40,15 @@
"postinstall": "prisma generate && ngcc --properties es2020 browser module main", "postinstall": "prisma generate && ngcc --properties es2020 browser module main",
"replace-placeholders-in-build": "node ./replace.build.js", "replace-placeholders-in-build": "node ./replace.build.js",
"start": "node dist/apps/api/main", "start": "node dist/apps/api/main",
"start:client": "ng serve client --configuration=development-en --hmr -o", "start:client": "nx run client:serve --configuration=development-en --hmr -o",
"start:prod": "yarn database:migrate && yarn database:seed && node main", "start:prod": "yarn database:migrate && yarn database:seed && node main",
"start:server": "nx serve api --watch", "start:server": "nx run api:serve --watch",
"start:storybook": "nx run ui:storybook", "start:storybook": "nx run ui:storybook",
"test": "nx test", "test": "nx test",
"test:single": "nx test --test-file portfolio-calculator-novn-buy-and-sell-partially.spec.ts", "test:single": "nx test --test-file portfolio-calculator-novn-buy-and-sell.spec.ts",
"ts-node": "ts-node", "ts-node": "ts-node",
"update": "nx migrate latest", "update": "nx migrate latest",
"watch:server": "nx build api --watch", "watch:server": "nx run api:build --watch",
"watch:test": "nx test --watch", "watch:test": "nx test --watch",
"workspace-generator": "nx workspace-generator" "workspace-generator": "nx workspace-generator"
}, },
@ -72,15 +72,15 @@
"@dfinity/principal": "0.12.1", "@dfinity/principal": "0.12.1",
"@dinero.js/currencies": "2.0.0-alpha.8", "@dinero.js/currencies": "2.0.0-alpha.8",
"@nestjs/bull": "0.5.5", "@nestjs/bull": "0.5.5",
"@nestjs/common": "9.0.7", "@nestjs/common": "9.1.4",
"@nestjs/config": "2.2.0", "@nestjs/config": "2.2.0",
"@nestjs/core": "9.0.7", "@nestjs/core": "9.1.4",
"@nestjs/jwt": "9.0.0", "@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0", "@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.0.7", "@nestjs/platform-express": "9.1.4",
"@nestjs/schedule": "2.1.0", "@nestjs/schedule": "2.1.0",
"@nestjs/serve-static": "3.0.0", "@nestjs/serve-static": "3.0.0",
"@nrwl/angular": "14.6.4", "@nrwl/angular": "15.0.0",
"@prisma/client": "4.4.0", "@prisma/client": "4.4.0",
"@simplewebauthn/browser": "5.2.1", "@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1", "@simplewebauthn/server": "5.2.1",
@ -131,24 +131,24 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "14.2.1", "@angular-devkit/build-angular": "14.2.1",
"@angular-eslint/eslint-plugin": "14.0.3", "@angular-eslint/eslint-plugin": "14.0.4",
"@angular-eslint/eslint-plugin-template": "14.0.3", "@angular-eslint/eslint-plugin-template": "14.0.4",
"@angular-eslint/template-parser": "14.0.3", "@angular-eslint/template-parser": "14.0.4",
"@angular/cli": "14.2.1", "@angular/cli": "14.2.1",
"@angular/compiler-cli": "14.2.0", "@angular/compiler-cli": "14.2.0",
"@angular/language-service": "14.2.0", "@angular/language-service": "14.2.0",
"@angular/localize": "14.2.0", "@angular/localize": "14.2.0",
"@nestjs/schematics": "9.0.1", "@nestjs/schematics": "9.0.3",
"@nestjs/testing": "9.0.7", "@nestjs/testing": "9.1.4",
"@nrwl/cli": "14.6.4", "@nrwl/cli": "15.0.0",
"@nrwl/cypress": "14.6.4", "@nrwl/cypress": "15.0.0",
"@nrwl/eslint-plugin-nx": "14.6.4", "@nrwl/eslint-plugin-nx": "15.0.0",
"@nrwl/jest": "14.6.4", "@nrwl/jest": "15.0.0",
"@nrwl/nest": "14.6.4", "@nrwl/nest": "15.0.0",
"@nrwl/node": "14.6.4", "@nrwl/node": "15.0.0",
"@nrwl/nx-cloud": "14.6.1", "@nrwl/nx-cloud": "15.0.0",
"@nrwl/storybook": "14.6.4", "@nrwl/storybook": "15.0.0",
"@nrwl/workspace": "14.6.4", "@nrwl/workspace": "15.0.0",
"@simplewebauthn/typescript-types": "5.2.1", "@simplewebauthn/typescript-types": "5.2.1",
"@storybook/addon-essentials": "6.5.9", "@storybook/addon-essentials": "6.5.9",
"@storybook/angular": "6.5.9", "@storybook/angular": "6.5.9",
@ -179,7 +179,7 @@
"jest": "28.1.3", "jest": "28.1.3",
"jest-environment-jsdom": "28.1.1", "jest-environment-jsdom": "28.1.1",
"jest-preset-angular": "12.2.2", "jest-preset-angular": "12.2.2",
"nx": "14.6.4", "nx": "15.0.0",
"prettier": "2.7.1", "prettier": "2.7.1",
"prettier-plugin-organize-attributes": "0.0.5", "prettier-plugin-organize-attributes": "0.0.5",
"replace-in-file": "6.2.0", "replace-in-file": "6.2.0",
@ -187,7 +187,7 @@
"ts-jest": "28.0.8", "ts-jest": "28.0.8",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"tslib": "2.0.0", "tslib": "2.0.0",
"typescript": "4.7.3" "typescript": "4.8.4"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@ -200,5 +200,8 @@
}, },
"prisma": { "prisma": {
"seed": "node prisma/seed.js" "seed": "node prisma/seed.js"
},
"resolutions": {
"rxjs": "7.5.6"
} }
} }

View File

@ -0,0 +1,28 @@
{
"meta": {
"date": "2022-07-21T21:28:05.857Z",
"version": "dev"
},
"activities": [
{
"fee": 0,
"quantity": 2,
"type": "SELL",
"unitPrice": 85.73,
"currency": "CHF",
"dataSource": "YAHOO",
"date": "2022-04-07T22:00:00.000Z",
"symbol": "NOVN.SW"
},
{
"fee": 0,
"quantity": 2,
"type": "BUY",
"unitPrice": 75.8,
"currency": "CHF",
"dataSource": "YAHOO",
"date": "2022-03-06T23:00:00.000Z",
"symbol": "NOVN.SW"
}
]
}

1423
yarn.lock

File diff suppressed because it is too large Load Diff