Compare commits

...

97 Commits

Author SHA1 Message Date
7c8530483c Release 1.183.0 (#1189) 2022-08-24 20:55:38 +02:00
539d3ff754 Feature/add asset sub class filter (#1188)
* Add asset sub class filter

* Update changelog
2022-08-24 20:53:50 +02:00
9d28b63da6 Release 1.182.0 (#1186) 2022-08-23 21:40:57 +02:00
24abbd85e6 Feature/move asset profile details to dialog (#1185)
* Introduce asset profile dialog

* Update changelog
2022-08-23 21:39:04 +02:00
b6f395fd3b Feature/improve i18n (#1183)
* Improve i18n

* Update changelog
2022-08-22 19:57:48 +02:00
04d894cf88 Release 1.181.2 (#1182) 2022-08-21 21:42:05 +02:00
b4d2c4109e Release 1.181.1 (#1181) 2022-08-21 20:58:51 +02:00
823093f4d7 Release 1.181.0 (#1180) 2022-08-21 18:08:54 +02:00
56bf422407 Consider language from user settings (#1179) 2022-08-21 18:06:31 +02:00
df0e9ad03b Bugfix/fix division by zero in benchmarks calculation (#1177)
* Fix division by zero error

* Update changelog
2022-08-21 17:03:03 +02:00
0e3702c2be Feature/improve german translation (#1178)
* Simplify and translate locales

* Add support for translated labels

* Update changelog
2022-08-21 17:02:43 +02:00
11136ae4f8 Eliminate duplicate locales (#1176) 2022-08-20 14:01:15 +02:00
2e6a7d5a91 Extract locales (#1175) 2022-08-20 11:00:53 +02:00
83845c256a Feature/add language selector (#1174)
* Add language selector

* Add translations (german)

* Update changelog
2022-08-20 10:55:27 +02:00
34c9703716 Remove locale (#1173) 2022-08-20 10:25:53 +02:00
48903238c5 Feature/improve documentation of database migration (#1172)
* Improve documentation

* Update changelog
2022-08-20 10:22:02 +02:00
57a14bd945 automate database setup and upgrade (#1163)
* Automate database setup and schema upgrade
2022-08-20 08:54:50 +02:00
4fd0622114 Fix build:dev script (#1171) 2022-08-19 20:39:21 +02:00
52f0fb5ab8 Release 1.180.1 (#1170) 2022-08-19 18:24:41 +02:00
20195b2b1a Release 1.180.0 (#1169) 2022-08-18 21:15:17 +02:00
7fa4e6ebd2 Feature/resolve feature graphic of blog post (#1168)
* Resolve feature graphic of blog post

* Update changelog
2022-08-18 21:13:39 +02:00
d8531ddfcb Bugfix/fix links to blog posts (#1167)
* Fix links

* Update changelog
2022-08-18 21:11:10 +02:00
70d670b711 Bugfix/fix license (#1160)
* Fix license

* Update changelog
2022-08-17 23:23:39 +02:00
27b0663a80 Add translations (#1159) 2022-08-16 22:16:15 +02:00
874dfb0235 Improve links (#1158) 2022-08-16 21:52:37 +02:00
072db0d558 Add translations (#1157) 2022-08-16 21:40:51 +02:00
12e692429a Regenerate xlf (#1156) 2022-08-16 21:30:12 +02:00
e22b8b78b8 Feature/tag route titles with template literal strings (#1155)
* Tagged template literal strings

* Update changelog
2022-08-16 21:03:05 +02:00
dc5052f7dc Feature/set up language localization for german (#1153)
* Set up language localization for German

* Update changelog
2022-08-16 20:58:08 +02:00
335553e891 Feature/tag template literal strings (#1152)
* Tagged template literal strings

* Update changelog
2022-08-16 20:53:14 +02:00
d480ad1023 Extract locales (#1151) 2022-08-15 19:56:42 +02:00
7320751056 Feature/set up ng extract i18n merge (#1149)
* Set up ng-extract-i18n-merge

* Update changelog
2022-08-15 19:52:43 +02:00
108c0c13c4 Release 1.179.5 (#1150) 2022-08-15 18:17:57 +02:00
053a5cc5b5 Release 1.179.4 (#1148) 2022-08-14 10:10:54 +02:00
c456a8bcfe Release/1.179.3 (#1147)
* Clean up

* Release 1.179.3
2022-08-13 20:33:43 +02:00
6fcecb5bc6 Release 1.179.2 (#1145) 2022-08-13 13:39:37 +02:00
e4e0a7d9f0 Release 1.179.1 (#1144) 2022-08-13 12:16:39 +02:00
c7173761a3 Release 1.179.0 (#1143) 2022-08-13 10:44:38 +02:00
185e130d9f Feature/add blog post 500 stars on GitHub (#1138)
* Add blog post

* Update changelog
2022-08-13 10:42:56 +02:00
81245635af Feature/setup i18n (#1139)
* Setup i18n

* Update changelog
2022-08-13 10:29:36 +02:00
55182ac1af Feature/reduce maximum width of performance chart (#1137)
* Reduce maximum width

* Update changelog
2022-08-10 17:26:34 +02:00
0b446a30ae Release 1.178.0 (#1136) 2022-08-09 21:28:02 +02:00
c5e6602102 Improve filter by asset class (#1135) 2022-08-09 21:25:07 +02:00
573038f407 Feature/add default values for countries and sectors (#1133)
* Add default values

* Add database validation script

* Update changelog
2022-08-09 19:31:13 +02:00
dbc38e705e Feature/add url to symbol profile overrides (#1132)
* Add url to symbol profile overrides

* Improve filter by asset class

* Update changelog
2022-08-09 19:29:26 +02:00
f127e7c61a Feature/improve styling of benchmarks (#1131)
* Harmonize benchmark table styling

* Update changelog
2022-08-09 19:28:13 +02:00
4ccabde251 Feature/simplify exchange rate service initialization (#1128)
* Simplify initialization

* Update changelog
2022-08-08 19:25:38 +02:00
86ae88f90f Release 1.177.0 (#1127) 2022-08-04 13:38:25 +02:00
69bc1d67e1 Bugfix/fix database connection error handling (#1125)
* Fix database connection error handling

* Update changelog
2022-08-04 13:36:32 +02:00
03942aecda Feature/upgrade to prisma 4.1.1 (#1126)
* Upgrade prisma to 4.1.1

* Update changelog
2022-08-04 13:35:58 +02:00
7ec9170c0d baseline prisma bdd at first setup (#1124)
* Baseline database (migrations) in setup

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2022-08-04 13:34:32 +02:00
51431a7fb2 Feature/add ghostfolio as default to data sources (#1122)
* Add GHOSTFOLIO

* Update changelog
2022-08-03 21:36:12 +02:00
4adda6783d Feature/add agplv3 logo to landing page (#1121)
* Add AGPLv3 logo

* Update changelog
2022-08-02 22:14:27 +02:00
d5cd4c0dea Feature/upgrade nx to version 14.5.1 (#1120)
* Upgrade Nx including angular and nestjs

* Update changelog
2022-08-01 19:56:44 +02:00
34be10d755 Release 1.176.2 (#1119) 2022-07-31 08:16:30 +02:00
51f586e160 Feature/upgrade node from 14 to 16 (#1118)
* Upgrade to Node.js 16

* Update changelog
2022-07-31 08:14:52 +02:00
ff64a00196 Release 1.176.1 (#1117) 2022-07-31 00:19:40 +02:00
148f6f8762 Feature/refactor env variables access (#1116)
* Refactor env variables access

* Update changelog
2022-07-31 00:18:09 +02:00
bf2c4d1e9e Release 1.176.0 (#1115) 2022-07-30 21:27:35 +02:00
eee1f1c722 Feature/add page titles (#1114)
* Add page titles

* Update changelog
2022-07-30 21:26:08 +02:00
9f2a49a1c7 Feature/introduce max number of symbols per data provider request (#1111)
* Introduce maximum number of symbols per request

* Set log level settings

* Update changelog
2022-07-30 15:48:45 +02:00
44058b2d7a Add descriptions (#1113) 2022-07-30 15:46:58 +02:00
23634f3404 Add environment variables (#1112) 2022-07-30 15:21:14 +02:00
f93dab6086 Add FAQ (#1108) 2022-07-29 19:25:41 +02:00
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
216 changed files with 12049 additions and 3092 deletions

3
.gitignore vendored
View File

@ -24,15 +24,16 @@
# misc # misc
/.angular/cache /.angular/cache
.env.prod
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log npm-debug.log
yarn-error.log
testem.log testem.log
/typings /typings
yarn-error.log
# System Files # System Files
.DS_Store .DS_Store

View File

@ -2,7 +2,7 @@ language: node_js
git: git:
depth: false depth: false
node_js: node_js:
- 14 - 16
services: services:
- docker - docker

View File

@ -5,6 +5,222 @@ 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.183.0 - 24.08.2022
### Added
- Added a filter by asset sub class for the asset profiles in the admin control
### Changed
- Improved the language localization for German (`de`)
## 1.182.0 - 23.08.2022
### Changed
- Improved the language localization for German (`de`)
- Extended and made the columns of the asset profiles sortable in the admin control
- Moved the asset profile details in the admin control panel to a dialog
## 1.181.2 - 21.08.2022
### Added
- Added a language selector to the account page
- Added support for translated labels in the value component
### Changed
- Integrated the commands `database:setup` and `database:migrate` into the container start
### Fixed
- Fixed a division by zero error in the benchmarks calculation
### Todo
- Apply manual data migration (`yarn database:migrate`) is not needed anymore
## 1.180.1 - 18.08.2022
### Added
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
- Set up language localization for German (`de`)
- Resolved the feature graphic of the blog post
### Changed
- Tagged template literal strings in components for localization with `$localize`
### Fixed
- Fixed the license component in the about page
- Fixed the links to the blog posts
## 1.179.5 - 15.08.2022
### Added
- Set up i18n support
- Added a blog post: _500 Stars on GitHub_
### Changed
- Reduced the maximum width of the performance chart on the home page
## 1.178.0 - 09.08.2022
### Added
- Added `url` to the symbol profile overrides model for manual adjustments
- Added default values for `countries` and `sectors` of the symbol profile overrides model
### Changed
- Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.177.0 - 04.08.2022
### Added
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
- Added the `AGPLv3` logo to the landing page
### Changed
- Refactored the initialization of the exchange rate service
- Upgraded `angular` from version `14.0.2` to `14.1.0`
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
### Fixed
- Handled database connection errors (do not exit process)
## 1.176.2 - 31.07.2022
### Added
- Added page titles
### Changed
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
- Changed the log level settings
- Refactored the access of the environment variables in the bootstrap function (api)
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
### Todo
- Upgrade to `Node.js` 16+
## 1.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Support a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022 ## 1.167.0 - 07.07.2022
### Added ### Added

View File

@ -1,4 +1,4 @@
FROM node:14-alpine as builder FROM node:16-alpine as builder
# Build application and add additional files # Build application and add additional files
@ -45,8 +45,8 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:14-alpine FROM node:16-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE 3333 EXPOSE 3333
CMD [ "node", "main" ] CMD [ "yarn", "start:prod" ]

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software</strong> <strong>Open Source Wealth Management Software</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting ## Self-hosting
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
### Run with Docker Compose ### Run with Docker Compose
#### Prerequisites #### Prerequisites
@ -97,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
``` ```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
#### b. Build and run environment #### b. Build and run environment
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
@ -114,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
``` ```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
#### Fetch Historical Data #### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps: Open http://localhost:3333 in your browser and accomplish these steps:
@ -134,7 +135,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community) ### Run with _Unraid_ (Community)
@ -145,7 +146,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 16+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone) - A local copy of this Git repository (clone)

View File

@ -77,41 +77,45 @@
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [ "assets": [
"apps/client/src/assets",
{ {
"glob": "assetlinks.json", "glob": "assetlinks.json",
"input": "apps/client/src/assets", "input": "apps/client/src/assets",
"output": "./.well-known" "output": "./../.well-known"
}, },
{ {
"glob": "CHANGELOG.md", "glob": "CHANGELOG.md",
"input": "", "input": "",
"output": "./assets" "output": "./../assets"
}, },
{ {
"glob": "LICENSE", "glob": "LICENSE",
"input": "", "input": "",
"output": "./assets" "output": "./../assets"
}, },
{ {
"glob": "robots.txt", "glob": "robots.txt",
"input": "apps/client/src/assets", "input": "apps/client/src/assets",
"output": "./" "output": "./../"
}, },
{ {
"glob": "sitemap.xml", "glob": "sitemap.xml",
"input": "apps/client/src/assets", "input": "apps/client/src/assets",
"output": "./" "output": "./../"
}, },
{ {
"glob": "**/*", "glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons", "input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons" "output": "./../ionicons"
}, },
{ {
"glob": "**/*.js", "glob": "**/*.js",
"input": "node_modules/ionicons/dist/", "input": "node_modules/ionicons/dist/",
"output": "./" "output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": ["apps/client/src/styles.scss"],
@ -124,6 +128,14 @@
"namedChunks": true "namedChunks": true
}, },
"configurations": { "configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -162,15 +174,24 @@
"proxyConfig": "apps/client/proxy.conf.json" "proxyConfig": "apps/client/proxy.conf.json"
}, },
"configurations": { "configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
} }
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": { "options": {
"browserTarget": "client:build" "browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": ["messages.de.xlf"]
} }
}, },
"lint": { "lint": {
@ -188,6 +209,15 @@
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
} }
}, },
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
}
},
"sourceLocale": "en"
},
"tags": [] "tags": []
}, },
"client-e2e": { "client-e2e": {

View File

@ -8,7 +8,8 @@ import {
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,6 +23,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -226,7 +228,9 @@ export class AdminController {
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
): Promise<AdminMarketData> {
if ( if (
!hasPermission( !hasPermission(
this.request.user.permissions, this.request.user.permissions,
@ -239,7 +243,18 @@ export class AdminController {
); );
} }
return this.adminService.getMarketData(); const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData(filters);
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')

View File

@ -1,6 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -12,11 +11,13 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client'; import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@ -24,7 +25,6 @@ export class AdminService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -65,14 +65,27 @@ export class AdminService {
}; };
} }
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const marketData = await this.prismaService.marketData.groupBy({ const marketData = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
const currencyPairsToGather: AdminMarketDataItem[] = let currencyPairsToGather: AdminMarketDataItem[] = [];
this.exchangeRateDataService
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
const marketDataItemCount = const marketDataItemCount =
@ -86,17 +99,24 @@ export class AdminService {
return { return {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol symbol,
countriesCount: 0,
sectorsCount: 0
}; };
}); });
}
const symbolProfilesToGather: AdminMarketDataItem[] = ( const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
where,
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
_count: { _count: {
select: { Order: true } select: { Order: true }
}, },
assetClass: true,
assetSubClass: true,
countries: true,
dataSource: true, dataSource: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -104,10 +124,14 @@ export class AdminService {
take: 1 take: 1
}, },
scraperConfiguration: true, scraperConfiguration: true,
sectors: true,
symbol: true symbol: true
} }
}) })
).map((symbolProfile) => { ).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount = const marketDataItemCount =
marketData.find((marketDataItem) => { marketData.find((marketDataItem) => {
return ( return (
@ -115,10 +139,17 @@ export class AdminService {
marketDataItem.symbol === symbolProfile.symbol marketDataItem.symbol === symbolProfile.symbol
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return { return {
countriesCount,
marketDataItemCount, marketDataItemCount,
sectorsCount,
activityCount: symbolProfile._count.Order, activityCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date, date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol symbol: symbolProfile.symbol
@ -174,7 +205,6 @@ export class AdminService {
_count: { _count: {
select: { Account: true, Order: true } select: { Account: true, Order: true }
}, },
alias: true,
Analytics: { Analytics: {
select: { select: {
activityCount: true, activityCount: true,
@ -194,7 +224,7 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => { ({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration; const engagement = Analytics.activityCount / daysSinceRegistration;
@ -206,7 +236,6 @@ export class AdminService {
: undefined; : undefined;
return { return {
alias,
createdAt, createdAt,
engagement, engagement,
id, id,

View File

@ -1,6 +1,17 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
@Controller() @Controller()
export class AppController { export class AppController {
public constructor() {} public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {
this.initialize();
}
private async initialize() {
try {
await this.exchangeRateDataService.initialize();
} catch {}
}
} }

View File

@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -23,6 +23,7 @@ import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
@ -82,4 +83,10 @@ import { UserModule } from './user/user.module';
controllers: [AppController], controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -1,5 +1,7 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
Controller, Controller,
@ -31,7 +33,9 @@ export class AuthController {
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) { public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken = await this.authService.validateAnonymousLogin(
accessToken accessToken
@ -59,9 +63,34 @@ export class AuthController {
const jwt: string = req.user.jwt; const jwt: string = req.user.jwt;
if (jwt) { if (jwt) {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`); res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else { } else {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
} }
} }

View File

@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,7 +14,7 @@ export class AuthService {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
public async validateAnonymousLogin(accessToken: string) { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +27,7 @@ export class AuthService {
}); });
if (user) { if (user) {
const jwt: string = this.jwtService.sign({ const jwt = this.jwtService.sign({
id: user.id id: user.id
}); });
@ -40,6 +41,33 @@ export class AuthService {
}); });
} }
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({ public async validateOAuthLogin({
provider, provider,
thirdPartyId thirdPartyId
@ -57,13 +85,14 @@ export class AuthService {
}); });
} }
const jwt: string = this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id
}); });
} catch (error) {
return jwt; throw new InternalServerErrorException(
} catch (err) { 'validateOAuthLogin',
throw new InternalServerErrorException('validateOAuthLogin', err.message); error.message
);
} }
} }
} }

View File

@ -48,9 +48,13 @@ export class BenchmarkService {
benchmarks = allTimeHighs.map((allTimeHigh, index) => { benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol]; const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice) let performancePercentFromAllTimeHigh = new Big(0);
if (allTimeHigh) {
performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh) .div(allTimeHigh)
.minus(1); .minus(1);
}
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
comment: true,
date: true, date: true,
fee: true, fee: true,
id: true, id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
comment,
date, date,
fee, fee,
id, id,
@ -50,6 +52,7 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
comment,
fee, fee,
id, id,
quantity, quantity,

View File

@ -0,0 +1,81 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
public indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
public constructor(
private readonly configurationService: ConfigurationService
) {}
public use(req: Request, res: Response, next: NextFunction) {
let featureGraphicPath = 'assets/cover.png';
if (
req.path === '/en/blog/2022/08/500-stars-on-github' ||
req.path === '/en/blog/2022/08/500-stars-on-github/'
) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
}
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
// Skip
next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
res.send(
this.interpolate(this.indexHtmlDe, {
featureGraphicPath,
languageCode: 'de',
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
res.send(
this.interpolate(this.indexHtmlEn, {
featureGraphicPath,
languageCode: DEFAULT_LANGUAGE_CODE,
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -34,8 +34,20 @@ export class ImportController {
); );
} }
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try { try {
return await this.importService.import({ return await this.importService.import({
maxActivitiesToImport,
activities: importData.activities, activities: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -17,9 +17,11 @@ export class ImportService {
public async import({ public async import({
activities, activities,
maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const activity of activities) { for (const activity of activities) {
@ -32,7 +34,11 @@ export class ImportService {
} }
} }
await this.validateActivities({ activities, userId }); await this.validateActivities({
activities,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map( const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
@ -42,6 +48,7 @@ export class ImportService {
for (const { for (const {
accountId, accountId,
comment,
currency, currency,
dataSource, dataSource,
date, date,
@ -52,6 +59,7 @@ export class ImportService {
unitPrice unitPrice
} of activities) { } of activities) {
await this.orderService.createOrder({ await this.orderService.createOrder({
comment,
fee, fee,
quantity, quantity,
type, type,
@ -81,19 +89,15 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activities, activities,
maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string; userId: string;
}) { }) {
if ( if (activities?.length > maxActivitiesToImport) {
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT') throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
} }
const existingActivities = await this.orderService.orders({ const existingActivities = await this.orderService.orders({

View File

@ -1,30 +1,46 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto { export class CreateOrderDto {
@IsString()
@IsOptional() @IsOptional()
@IsString()
accountId?: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional() @IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional() @IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@ -39,6 +55,10 @@ export class CreateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true }) @IsEnum(Type, { each: true })
type: Type; type: Type;

View File

@ -16,6 +16,7 @@ import {
DataSource, DataSource,
Order, Order,
Prisma, Prisma,
Tag,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
@ -71,6 +72,7 @@ export class OrderService {
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[];
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
@ -80,6 +82,8 @@ export class OrderService {
return account.isDefault === true; return account.isDefault === true;
}); });
const tags = data.tags ?? [];
let Account = { let Account = {
connect: { connect: {
id_userId: { id_userId: {
@ -139,9 +143,15 @@ export class OrderService {
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags;
delete data.userId; delete data.userId;
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
@ -150,7 +160,12 @@ export class OrderService {
data: { data: {
...orderData, ...orderData,
Account, Account,
isDraft isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
} }
}); });
} }
@ -215,9 +230,10 @@ export class OrderService {
}) })
}, },
{ {
SymbolProfileOverrides: { OR: [
is: null { SymbolProfileOverrides: { is: null } },
} { SymbolProfileOverrides: { assetClass: null } }
]
} }
] ]
}, },
@ -298,6 +314,7 @@ export class OrderService {
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[];
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
@ -305,6 +322,12 @@ export class OrderService {
delete data.Account; delete data.Account;
} }
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
@ -331,11 +354,17 @@ export class OrderService {
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags;
return this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}, },
where where
}); });

View File

@ -1,11 +1,20 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional() @IsOptional()
@ -20,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;
@ -41,6 +57,10 @@ export class UpdateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString() @IsString()
type: Type; type: Type;

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22') parseDate('2021-11-22')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('0') totalInvestment: new Big('0')
}); });
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
}); });
}); });
}); });

View File

@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30') parseDate('2021-11-30')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('273.2') totalInvestment: new Big('273.2')
}); });
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
}); });
}); });
}); });

View File

@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
new Date() new Date()
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
positions: [], positions: [],
totalInvestment: new Big(0) totalInvestment: new Big(0)
}); });
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
}); });
}); });
}); });

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07') parseDate('2022-03-07')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('75.80') totalInvestment: new Big('75.80')
}); });
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
}); });
}); });
}); });

View File

@ -14,8 +14,11 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth,
isSameYear,
max, max,
min min,
set
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash'; import { first, flatten, isNumber, sortBy } from 'lodash';
@ -323,6 +326,53 @@ export class PortfolioCalculator {
}); });
} }
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
}
return investments;
}
public async calculateTimeline( public async calculateTimeline(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
endDate: string endDate: string

View File

@ -20,7 +20,12 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -217,7 +222,8 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string @Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -229,9 +235,16 @@ export class PortfolioController {
); );
} }
let investments = await this.portfolioService.getInvestments( let investments: InvestmentItem[];
impersonationId
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
); );
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if ( if (
impersonationId || impersonationId ||

View File

@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy,
Market, Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -64,6 +65,7 @@ import {
max, max,
parse, parse,
parseISO, parseISO,
set,
setDayOfYear, setDayOfYear,
startOfDay, startOfDay,
subDays, subDays,
@ -183,7 +185,8 @@ export class PortfolioService {
} }
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -204,21 +207,49 @@ export class PortfolioService {
return []; return [];
} }
const investments = portfolioCalculator.getInvestments().map((item) => { let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() investment: item.investment.toNumber()
}; };
}); });
// Add investment of current month
const dateOfCurrentMonth = format(
set(new Date(), { date: 1 }),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
});
if (investmentOfCurrentMonth.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today // Add investment of today
const investmentOfToday = investments.filter((investment) => { const investmentOfToday = investments.filter(({ date }) => {
return investment.date === format(new Date(), DATE_FORMAT); return date === format(new Date(), DATE_FORMAT);
}); });
if (investmentOfToday.length <= 0) { if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => { const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(investment.date), new Date()); return isBefore(parseDate(date), new Date());
}); });
const lastInvestment = pastInvestments[pastInvestments.length - 1]; const lastInvestment = pastInvestments[pastInvestments.length - 1];
@ -227,6 +258,7 @@ export class PortfolioService {
investment: lastInvestment?.investment ?? 0 investment: lastInvestment?.investment ?? 0
}); });
} }
}
return sortBy(investments, (investment) => { return sortBy(investments, (investment) => {
return investment.date; return investment.date;

View File

@ -1,6 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config'; import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces'; import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -93,7 +96,11 @@ export class SubscriptionController {
'SubscriptionController' 'SubscriptionController'
); );
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
);
} }
@Post('stripe/checkout-session') @Post('stripe/checkout-session')

View File

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription } from '@prisma/client';
@ -33,7 +34,9 @@ export class SubscriptionService {
userId: string; userId: string;
}) { }) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`, cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId, client_reference_id: userId,
line_items: [ line_items: [
{ {

View File

@ -9,6 +9,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
@IsString()
@IsOptional()
language?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
locale?: string; locale?: string;

View File

@ -36,14 +36,7 @@ export class UserService {
} }
public async getUser( public async getUser(
{ { Account, id, permissions, Settings, subscription }: UserWithSettings,
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const access = await this.prismaService.access.findMany({ const access = await this.prismaService.access.findMany({
@ -63,7 +56,6 @@ export class UserService {
} }
return { return {
alias,
id, id,
permissions, permissions,
subscription, subscription,

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
{ {
"LUNA1": "Terra", "LUNA1": "Terra",
"UNI1": "Uniswap" "LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
} }

View File

@ -1,11 +1,24 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, {
logger:
NODE_ENV === 'production'
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors(); app.enableCors();
app.enableVersioning({ app.enableVersioning({
defaultVersion: '1', defaultVersion: '1',
@ -20,11 +33,11 @@ async function bootstrap() {
}) })
); );
const host = process.env.HOST || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const port = process.env.PORT || 3333; const PORT = configService.get<number>('PORT') || 3333;
await app.listen(port, host, () => { await app.listen(PORT, HOST, () => {
logLogo(); logLogo();
Logger.log(`Listening at http://${host}:${port}`); Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log(''); Logger.log('');
}); });
} }

View File

@ -15,7 +15,9 @@ export class ConfigurationService {
BASE_CURRENCY: str({ default: 'USD' }), BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: [DataSource.YAHOO] }), DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
@ -33,8 +35,8 @@ export class ConfigurationService {
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }), HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }), REDIS_HOST: host({ default: 'localhost' }),

View File

@ -168,6 +168,7 @@ export class DataProviderService {
const response: { const response: {
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
} = {}; } = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
@ -176,25 +177,59 @@ export class DataProviderService {
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
)) { )) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol; return dataGatheringItem.symbol;
}); });
const promise = Promise.resolve( const maximumNumberOfSymbolsPerRequest =
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols) dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const startTimeDataSource = performance.now();
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
); );
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push( promises.push(
promise.then((result) => { promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) { for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
response[symbol] = dataProviderResponse; response[symbol] = dataProviderResponse;
} }
Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`
);
}) })
); );
} }
}
await Promise.all(promises); await Promise.all(promises);
Logger.debug('------------------------------------------------');
Logger.debug(
`Fetched ${items.length} quotes in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`
);
Logger.debug('================================================');
return response; return response;
} }

View File

@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
} }
public getMaxNumberOfSymbolsPerRequest() {
// It is not recommended using more than 15-20 tickers per request
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
return 20;
}
public getName(): DataSource { public getName(): DataSource {
return DataSource.EOD_HISTORICAL_DATA; return DataSource.EOD_HISTORICAL_DATA;
} }

View File

@ -20,6 +20,8 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; // TODO: Return only one symbol }>; // TODO: Return only one symbol
getMaxNumberOfSymbolsPerRequest?(): number;
getName(): DataSource; getName(): DataSource;
getQuotes( getQuotes(

View File

@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace( let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency this.baseCurrency
); );
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', ''); return symbol.replace('=X', '');
} }
@ -203,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource { public getName(): DataSource {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
@ -261,6 +270,16 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); Logger.error(error, 'YahooFinanceService');

View File

@ -22,9 +22,7 @@ export class ExchangeRateDataService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) { ) {}
this.initialize();
}
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
@ -122,15 +120,6 @@ export class ExchangeRateDataService {
return 0; return 0;
} }
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1; let factor = 1;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency !== aToCurrency) {

View File

@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_ID: string; GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string; GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number; PORT: number;
RAKUTEN_RAPID_API_KEY: string; RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;

View File

@ -1,15 +1,25 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy { implements OnModuleInit, OnModuleDestroy
async onModuleInit() { {
public async onModuleInit() {
try {
await this.$connect(); await this.$connect();
} catch (error) {
Logger.error(error, 'PrismaService');
}
} }
async onModuleDestroy() { public async onModuleDestroy() {
await this.$disconnect(); await this.$disconnect();
} }
} }

View File

@ -115,9 +115,16 @@ export class SymbolProfileService {
} }
item.name = item.SymbolProfileOverrides?.name ?? item.name; item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ?? if (
item.sectors; (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = item.SymbolProfileOverrides
.sectors as unknown as Sector[];
}
item.url = item.SymbolProfileOverrides?.url ?? item.url;
delete item.SymbolProfileOverrides; delete item.SymbolProfileOverrides;
} }

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
@ -53,30 +54,56 @@ const routes: Routes = [
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
}, },
{ {
path: 'de/blog/2021/07/hallo-ghostfolio', path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () => loadChildren: () =>
import( import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module' './pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule) ).then((m) => m.HalloGhostfolioPageModule)
}, },
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
}, },
{ {
path: 'en/blog/2021/07/hello-ghostfolio', path: 'faq',
loadChildren: () => loadChildren: () =>
import( import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
}, },
{ {
path: 'features', path: 'features',
@ -215,7 +242,10 @@ const routes: Routes = [
} }
) )
], ],
providers: [ModulePreloadService], providers: [
ModulePreloadService,
{ provide: TitleStrategy, useClass: PageTitleStrategy }
],
exports: [RouterModule] exports: [RouterModule]
}) })
export class AppRoutingModule {} export class AppRoutingModule {}

View File

@ -24,8 +24,8 @@
class="cursor-pointer d-inline-block info-message px-3 py-2" class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span i18n>You are using the Live Demo.</span> <span>You are using the Live Demo.</span>
<span class="a ml-2" i18n>Create Account</span> <span class="a ml-2">Create Account</span>
</div></a </div></a
> >
<div <div

View File

@ -21,8 +21,10 @@
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'"> <ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon> <ion-icon class="mr-1" name="link-outline"></ion-icon>
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank" <a
>{{ baseUrl }}/p/{{ element.id }}</a href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
> >
</ng-container> </ng-container>
</td> </td>
@ -41,8 +43,8 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
Revoke <ng-container i18n>Revoke</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -8,6 +8,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
@Component({ @Component({
@ -24,6 +25,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public baseUrl = window.location.origin; public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; public dataSource: MatTableDataSource<Access>;
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public displayedColumns = []; public displayedColumns = [];
public constructor() {} public constructor() {}
@ -44,7 +46,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public onDeleteAccess(aId: string) { public onDeleteAccess(aId: string) {
const confirmation = confirm( const confirmation = confirm(
'Do you really want to revoke this granted access?' $localize`Do you really want to revoke this granted access?`
); );
if (confirmation) { if (confirmation) {

View File

@ -1,3 +1,7 @@
:host { :host {
display: block; display: block;
.mat-dialog-content {
max-height: unset;
}
} }

View File

@ -21,18 +21,10 @@
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value size="medium" [value]="accountType">Account Type</gf-value>
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value size="medium" [value]="platformName">Platform</gf-value>
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
</div> </div>
</div> </div>
@ -50,7 +42,6 @@
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="false" [showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()" (export)="onExport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -19,13 +19,8 @@
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">
<th <th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
*matHeaderCellDef <ng-container i18n>Currency</ng-container>
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }} {{ element.currency }}
@ -36,13 +31,8 @@
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
<th <th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
*matHeaderCellDef <ng-container i18n>Platform</ng-container>
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Platform
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
@ -81,10 +71,9 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell mat-header-cell
> >
Cash Balance <ng-container i18n>Cash Balance</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
@ -116,10 +105,9 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell mat-header-cell
> >
Value <ng-container i18n>Value</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
@ -151,10 +139,9 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right" class="d-lg-none d-xl-none px-1 text-right"
i18n
mat-header-cell mat-header-cell
> >
Value <ng-container i18n>Value</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"

View File

@ -69,7 +69,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
public onDeleteAccount(aId: string) { public onDeleteAccount(aId: string) {
const confirmation = confirm('Do you really want to delete this account?'); const confirmation = confirm(
$localize`Do you really want to delete this account?`
);
if (confirmation) { if (confirmation) {
this.accountDeleted.emit(aId); this.accountDeleted.emit(aId);

View File

@ -24,7 +24,7 @@
<table class="gf-table w-100"> <table class="gf-table w-100">
<thead> <thead>
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th> <th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th> <th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> <th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> <th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
@ -105,19 +105,18 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onViewData(job.data)"> <button mat-menu-item (click)="onViewData(job.data)">
View Data <ng-container i18n>View Data</ng-container>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="job.stacktrace?.length <= 0" [disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)" (click)="onViewStacktrace(job.stacktrace)"
> >
View Stacktrace <ng-container i18n>View Stacktrace</ng-container>
</button> </button>
<button i18n mat-menu-item (click)="onDeleteJob(job.id)"> <button mat-menu-item (click)="onDeleteJob(job.id)">
Delete Job <ng-container i18n>Delete Job</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -43,8 +43,8 @@
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button (click)="onCancel()">Cancel</button>
<button color="primary" i18n mat-flat-button (click)="onUpdate()"> <button color="primary" mat-flat-button (click)="onUpdate()">
Save <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>
</form> </form>

View File

@ -3,17 +3,27 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.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';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client'; import { AssetSubClass, DataSource } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
public allFilters: Filter[] = [
AssetSubClass.BOND,
AssetSubClass.COMMODITY,
AssetSubClass.CRYPTOCURRENCY,
AssetSubClass.ETF,
AssetSubClass.MUTUALFUND,
AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK
].map((id) => {
return {
id,
label: id,
type: 'ASSET_SUB_CLASS'
};
});
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = []; public deviceType: string;
public marketDataDetails: MarketData[] = []; public displayedColumns = [
'symbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activityCount',
'marketDataItemCount',
'countriesCount',
'sectorsCount',
'actions'
];
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public placeholder = '';
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -35,8 +80,29 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
params['dataSource'] &&
params['dateOfFirstActivity'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
dateOfFirstActivity: params['dateOfFirstActivity'],
symbol: params['symbol']
});
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -51,7 +117,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.fetchAdminMarketData(); this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
@ -75,54 +165,60 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onMarketDataChanged(withRefresh: boolean = false) { public onOpenAssetProfileDialog({
if (withRefresh) { dataSource,
this.fetchAdminMarketData(); dateOfFirstActivity,
this.fetchAdminMarketDataBySymbol({ symbol
dataSource: this.currentDataSource, }: UniqueAsset & { dateOfFirstActivity: string }) {
symbol: this.currentSymbol this.router.navigate([], {
queryParams: {
dataSource,
symbol,
assetProfileDialog: true,
dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
}
}); });
} }
}
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
this.currentDataSource = undefined;
this.currentSymbol = '';
} else {
this.currentDataSource = dataSource;
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchAdminMarketData() { private openAssetProfileDialog({
this.dataService dataSource,
.fetchAdminMarketData() dateOfFirstActivity,
symbol
}: {
dataSource: DataSource;
dateOfFirstActivity: string;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => { .subscribe((user) => {
this.marketData = marketData; this.user = user;
this.changeDetectorRef.markForCheck(); const dialogRef = this.dialog.open(AssetProfileDialog, {
autoFocus: false,
data: <AssetProfileDialogParams>{
dataSource,
dateOfFirstActivity,
symbol,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
}
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) { dialogRef
this.adminService .afterClosed()
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => { .subscribe(() => {
this.marketDataDetails = marketData; this.router.navigate(['.'], { relativeTo: this.route });
});
this.changeDetectorRef.markForCheck();
}); });
} }
} }

View File

@ -1,31 +1,108 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<table class="gf-table w-100"> <gf-activities-filter
<thead> [allFilters]="allFilters"
<tr class="mat-header-row"> [isLoading]="isLoading"
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> [placeholder]="placeholder"
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> (valueChanged)="filters$.next($event)"
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th> ></gf-activities-filter>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th> </div>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th> </div>
<th class="mat-header-cell px-1 py-2"></th> <div class="row">
</tr> <div class="col">
</thead> <table
<tbody> class="gf-table w-100"
<ng-container *ngFor="let item of marketData; let i = index"> matSort
<tr matSortActive="symbol"
class="cursor-pointer mat-row" matSortDirection="asc"
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })" mat-table
[dataSource]="dataSource"
> >
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td> <ng-container matColumnDef="symbol">
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<td class="mat-cell px-1 py-2"> <ng-container i18n>Symbol</ng-container>
{{ (item.date | date: defaultDateFormat) ?? '' }} </th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td> </td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td> </ng-container>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2"> <ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="assetClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
</td>
</ng-container>
<ng-container matColumnDef="assetSubClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }}
</td>
</ng-container>
<ng-container matColumnDef="activityCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activity Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.marketDataItemCount }}
</td>
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.sectorsCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<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
@ -36,44 +113,35 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button <button
i18n
mat-menu-item mat-menu-item
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})" (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
> >
Gather Data <ng-container i18n>Gather Data</ng-container>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})" (click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
> >
Gather Profile Data <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="item.activityCount !== 0" [disabled]="element.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
Delete Profile Data <ng-container i18n>Delete</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</td>
</tr>
</ng-container> </ng-container>
</tbody>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
></tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -2,17 +2,23 @@ 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 { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { AdminMarketDataComponent } from './admin-market-data.component'; import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
@NgModule({ @NgModule({
declarations: [AdminMarketDataComponent], declarations: [AdminMarketDataComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfAdminMarketDataDetailModule, GfActivitiesFilterModule,
GfAssetProfileDialogModule,
MatButtonModule, MatButtonModule,
MatMenuModule MatMenuModule,
MatSortModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-asset-profile-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'asset-profile-dialog.html',
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
public marketDataDetails: MarketData[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
public dialogRef: MatDialogRef<AssetProfileDialog>,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
) {}
public ngOnInit(): void {
this.initialize();
}
public onClose(): void {
this.dialogRef.close();
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
private initialize() {
this.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
});
}
}

View File

@ -0,0 +1,24 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="data.symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail
[dataSource]="data.dataSource"
[dateOfFirstActivity]="data.dateOfFirstActivity"
[locale]="data.locale"
[marketData]="marketDataDetails"
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { AssetProfileDialog } from './asset-profile-dialog.component';
@NgModule({
declarations: [AssetProfileDialog],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,
MatDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAssetProfileDialogModule {}

View File

@ -0,0 +1,9 @@
import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams {
dateOfFirstActivity: string;
dataSource: DataSource;
deviceType: string;
locale: string;
symbol: string;
}

View File

@ -103,7 +103,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onAddCurrency() { public onAddCurrency() {
const currency = prompt('Please add a currency:'); const currency = prompt($localize`Please add a currency:`);
if (currency) { if (currency) {
const currencies = uniq([...this.customCurrencies, currency]); const currencies = uniq([...this.customCurrencies, currency]);
@ -116,7 +116,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onDeleteCoupon(aCouponCode: string) { public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?'); const confirmation = confirm(
$localize`Do you really want to delete this coupon?`
);
if (confirmation === true) { if (confirmation === true) {
const coupons = this.coupons.filter((coupon) => { const coupons = this.coupons.filter((coupon) => {
@ -127,7 +129,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onDeleteCurrency(aCurrency: string) { public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?'); const confirmation = confirm(
$localize`Do you really want to delete this currency?`
);
if (confirmation === true) { if (confirmation === true) {
const currencies = this.customCurrencies.filter((currency) => { const currencies = this.customCurrencies.filter((currency) => {
@ -142,7 +146,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onFlushCache() { public onFlushCache() {
const confirmation = confirm('Do you really want to flush the cache?'); const confirmation = confirm(
$localize`Do you really want to flush the cache?`
);
if (confirmation === true) { if (confirmation === true) {
this.cacheService this.cacheService
@ -190,7 +196,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onSetSystemMessage() { public onSetSystemMessage() {
const systemMessage = prompt('Please set your system message:'); const systemMessage = prompt($localize`Please set your system message:`);
if (systemMessage) { if (systemMessage) {
this.putSystemMessage(systemMessage); this.putSystemMessage(systemMessage);

View File

@ -8,7 +8,7 @@
<div class="w-50">{{ userCount }}</div> <div class="w-50">{{ userCount }}</div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div> <div class="w-50" i18n>Activity Count</div>
<div class="w-50"> <div class="w-50">
<ng-container *ngIf="transactionCount"> <ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number {{ transactionCount }} ({{ transactionCount / userCount | number
@ -17,7 +17,7 @@
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div> <div class="w-50" i18n>Data Management</div>
<div class="w-50"> <div class="w-50">
<div class="overflow-hidden"> <div class="overflow-hidden">
<div class="mb-2"> <div class="mb-2">

View File

@ -55,7 +55,9 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
} }
public onDeleteUser(aId: string) { public onDeleteUser(aId: string) {
const confirmation = confirm('Do you really want to delete this user?'); const confirmation = confirm(
$localize`Do you really want to delete this user?`
);
if (confirmation) { if (confirmation) {
this.dataService this.dataService

View File

@ -7,17 +7,17 @@
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th> <th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th> <th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right">
Registration <ng-container i18n>Registration</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right">
Accounts <ng-container i18n>Accounts</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right">
Activities <ng-container i18n>Activities</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right">
Engagement per Day <ng-container i18n>Engagement per Day</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th> <th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
@ -29,11 +29,10 @@
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block" <span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span >{{ userItem.id }}</span
> >
<span class="d-inline-block d-sm-none" <span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) + >{{ (userItem.id | slice:0:5) + '...' }}</span
'...' }}</span
> >
<gf-premium-indicator <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="userItem?.subscription?.type === 'Premium'"

View File

@ -124,13 +124,11 @@
: 'radio-button-on-outline' : 'radio-button-on-outline'
" "
></ion-icon> ></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span> <span i18n>Me</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
class="align-items-center d-flex" class="align-items-center d-flex"
disabled="false"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
@ -287,17 +285,16 @@
mat-flat-button mat-flat-button
><ion-icon name="logo-github"></ion-icon ><ion-icon name="logo-github"></ion-icon
></a> ></a>
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()"> <button class="mx-1" mat-flat-button (click)="openLoginDialog()">
Sign In <ng-container i18n>Sign in</ng-container>
</button> </button>
<a <a
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode" *ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[routerLink]="['/register']" [routerLink]="['/register']"
>Get Started ><ng-container i18n>Get started</ng-container>
</a> </a>
</ng-container> </ng-container>
</mat-toolbar> </mat-toolbar>

View File

@ -109,7 +109,7 @@ export class HeaderComponent implements OnChanges {
data: { data: {
accessToken: '', accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin, hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
title: 'Sign in' title: $localize`Sign in`
}, },
width: '30rem' width: '30rem'
}); });
@ -123,7 +123,7 @@ export class HeaderComponent implements OnChanges {
.loginAnonymous(data?.accessToken) .loginAnonymous(data?.accessToken)
.pipe( .pipe(
catchError(() => { catchError(() => {
alert('Oops! Incorrect Security Token.'); alert($localize`Oops! Incorrect Security Token.`);
return EMPTY; return EMPTY;
}), }),

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
@ -9,7 +10,6 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
@ -27,7 +27,7 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
@ -47,7 +47,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (

View File

@ -21,6 +21,8 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[]; public benchmarks: Benchmark[];
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public fearLabel = $localize`Fear`;
public greedLabel = $localize`Greed`;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;

View File

@ -9,13 +9,13 @@
class="mb-3" class="mb-3"
symbol="Fear & Greed Index" symbol="Fear & Greed Index"
yMax="100" yMax="100"
yMaxLabel="Greed"
yMin="0" yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData" [historicalDataItems]="historicalData"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[yMaxLabel]="greedLabel"
[yMinLabel]="fearLabel"
></gf-line-chart> ></gf-line-chart>
<gf-fear-and-greed-index <gf-fear-and-greed-index
class="d-flex justify-content-center" class="d-flex justify-content-center"
@ -34,6 +34,7 @@
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading" *ngIf="isLoading"
animation="pulse" animation="pulse"
class="px-2 py-3"
[theme]="{ [theme]="{
height: '1.5rem', height: '1.5rem',
width: '100%' width: '100%'

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
@ -6,7 +7,6 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { import {
PortfolioPerformance, PortfolioPerformance,
UniqueAsset, UniqueAsset,
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public errors: UniqueAsset[]; public errors: UniqueAsset[];
public hasError: boolean; public hasError: boolean;

View File

@ -2,19 +2,8 @@
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
<div class="row w-100"> <div class="row w-100">
<div class="chart-container col"> <div class="col p-0">
<gf-line-chart <div class="chart-container mx-auto position-relative">
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
<div <div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0" *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100" class="align-items-center d-flex h-100 justify-content-center w-100"
@ -23,6 +12,20 @@
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> <gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div> </div>
</div> </div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div> </div>
</div> </div>
<div class="overview-container row mt-1"> <div class="overview-container row mt-1">

View File

@ -5,7 +5,8 @@
.chart-container { .chart-container {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
max-height: 50vh; height: auto;
max-width: 50rem;
// Fallback for aspect-ratio (using padding hack) // Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) { @supports not (aspect-ratio: 16 / 9) {

View File

@ -1,3 +1,7 @@
:host { :host {
display: block; display: block;
ngx-skeleton-loader {
height: 100%;
}
} }

View File

@ -13,7 +13,7 @@ import {
getTooltipPositionerMapTop, getTooltipPositionerMapTop,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import {
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
@ -22,7 +22,10 @@ import {
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types';
import { import {
BarController,
BarElement,
Chart, Chart,
LineController, LineController,
LineElement, LineElement,
@ -31,6 +34,7 @@ import {
TimeScale, TimeScale,
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({ @Component({
@ -42,9 +46,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string; @Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string; @Input() locale: string;
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -53,6 +59,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
BarController,
BarElement,
LinearScale, LinearScale,
LineController, LineController,
LineElement, LineElement,
@ -78,7 +87,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
if (this.investments?.length > 0) { if (!this.groupBy && this.investments?.length > 0) {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.investments[0];
this.investments.unshift({ this.investments.unshift({
@ -102,17 +111,18 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
const data = { const data = {
labels: this.investments.map((position) => { labels: this.investments.map((investmentItem) => {
return position.date; return investmentItem.date;
}), }),
datasets: [ datasets: [
{ {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2, borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => { data: this.investments.map((position) => {
return position.investment; return position.investment;
}), }),
label: 'Investment', label: $localize`Deposit`,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context: unknown) =>
this.isInFuture( this.isInFuture(
@ -137,6 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animation: false,
elements: { elements: {
line: { line: {
tension: 0 tension: 0
@ -150,6 +161,39 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
interaction: { intersect: false, mode: 'index' }, interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: <unknown>{ plugins: <unknown>{
annotation: {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: 'Savings Rate',
display: true,
font: { size: '10px', weight: 'normal' },
padding: {
x: 4,
y: 2
},
position: 'start'
},
scaleID: 'y',
type: 'line',
value: this.savingsRate
}
: undefined,
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: { legend: {
display: false display: false
}, },
@ -164,6 +208,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`, borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor()}, 0.8)`, color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false
}, },
@ -178,8 +223,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`, borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`, color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false,
drawBorder: false
}, },
position: 'right',
ticks: { ticks: {
callback: (value: number) => { callback: (value: number) => {
return transformTickToAbbreviation(value); return transformTickToAbbreviation(value);
@ -192,13 +239,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
}, },
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)], plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line' type: this.groupBy ? 'bar' : 'line'
}); });
}
}
this.isLoading = false; this.isLoading = false;
} }
}
}
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration() {
return { return {

View File

@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { import {
STAY_SIGNED_IN, STAY_SIGNED_IN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@Component({ @Component({
selector: 'gf-login-with-access-token-dialog', selector: 'gf-login-with-access-token-dialog',
@ -16,7 +19,10 @@ export class LoginWithAccessTokenDialog {
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>, public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService private internetIdentityService: InternetIdentityService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {} ) {}
ngOnInit() {} ngOnInit() {}
@ -31,4 +37,14 @@ export class LoginWithAccessTokenDialog {
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.dialogRef.close();
this.router.navigate(['/']);
} catch {}
}
} }

View File

@ -5,16 +5,7 @@
></gf-dialog-header> ></gf-dialog-header>
<div mat-dialog-content> <div mat-dialog-content>
<div> <div class="align-items-center d-flex flex-column">
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center">
<a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a
>
</div>
<div class="my-3 text-center text-muted" i18n>or</div>
</ng-container>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Security Token</mat-label> <mat-label i18n>Security Token</mat-label>
<textarea <textarea
@ -24,6 +15,29 @@
[(ngModel)]="data.accessToken" [(ngModel)]="data.accessToken"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">
<button
class="mb-2"
mat-stroked-button
(click)="onLoginWithInternetIdentity()"
>
<img
class="mr-2"
src="../assets/icons/internet-computer.svg"
style="height: 0.75rem"
/><span i18n>Sign in with Internet Identity</span>
</button>
<a href="../api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="../assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Sign in with Google</span></a
>
</div>
</ng-container>
</div> </div>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
@ -35,12 +49,11 @@
<div> <div>
<button <button
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[disabled]="!data.accessToken" [disabled]="!data.accessToken"
[mat-dialog-close]="data" [mat-dialog-close]="data"
> >
Sign in <ng-container i18n>Sign in</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@ -12,4 +12,12 @@
} }
} }
} }
.mat-form-field {
::ng-deep {
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
}
} }

View File

@ -45,7 +45,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() { public onEditEmergencyFund() {
const emergencyFundInput = prompt( const emergencyFundInput = prompt(
'Please enter the amount of your emergency fund:', $localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund.toString() this.summary.emergencyFund.toString()
); );
const emergencyFund = parseFloat(emergencyFundInput?.trim()); const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -35,112 +35,124 @@
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Change" i18n
size="medium" size="medium"
[colorizeSign]="true" [colorizeSign]="true"
[currency]="data.baseCurrency" [currency]="data.baseCurrency"
[locale]="data.locale" [locale]="data.locale"
[value]="netPerformance" [value]="netPerformance"
></gf-value> >Change</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Performance" i18n
size="medium" size="medium"
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="netPerformancePercent" [value]="netPerformancePercent"
></gf-value> >Performance</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Average Unit Price" i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"
[value]="averagePrice" [value]="averagePrice"
></gf-value> >Average Unit Price</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Market Price" i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"
[value]="marketPrice" [value]="marketPrice"
></gf-value> >Market Price</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Minimum Price" i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="minPrice" [value]="minPrice"
></gf-value> >Minimum Price</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Maximum Price" i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="maxPrice" [value]="maxPrice"
></gf-value> >Maximum Price</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Quantity" i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[precision]="quantityPrecision" [precision]="quantityPrecision"
[value]="quantity" [value]="quantity"
></gf-value> >Quantity</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Investment" i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [currency]="data.baseCurrency"
[locale]="data.locale" [locale]="data.locale"
[value]="investment" [value]="investment"
></gf-value> >Investment</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="First Buy Date" i18n
size="medium" size="medium"
[isDate]="true" [isDate]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="firstBuyDate" [value]="firstBuyDate"
></gf-value> >First Buy Date</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n
size="medium" size="medium"
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"
></gf-value> >Transactions</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Asset Class" i18n
size="medium" size="medium"
[hidden]="!SymbolProfile?.assetClass" [hidden]="!SymbolProfile?.assetClass"
[value]="SymbolProfile?.assetClass" [value]="SymbolProfile?.assetClass"
></gf-value> >Asset Class</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Asset Sub Class" i18n
size="medium" size="medium"
[hidden]="!SymbolProfile?.assetSubClass" [hidden]="!SymbolProfile?.assetSubClass"
[value]="SymbolProfile?.assetSubClass" [value]="SymbolProfile?.assetSubClass"
></gf-value> >Asset Sub Class</gf-value
>
</div> </div>
<ng-container <ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0" *ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
@ -150,22 +162,24 @@
> >
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3"> <div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value <gf-value
label="Sector" i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.sectors[0].name" [value]="SymbolProfile.sectors[0].name"
></gf-value> >Sector</gf-value
>
</div> </div>
<div <div
*ngIf="SymbolProfile?.countries?.length === 1" *ngIf="SymbolProfile?.countries?.length === 1"
class="col-6 mb-3" class="col-6 mb-3"
> >
<gf-value <gf-value
label="Country" i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.countries[0].name" [value]="SymbolProfile.countries[0].name"
></gf-value> >Country</gf-value
>
</div> </div>
</ng-container> </ng-container>
<ng-template #charts> <ng-template #charts>

View File

@ -18,8 +18,8 @@
</ng-container> </ng-container>
<ng-container matColumnDef="symbol"> <ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
Symbol <ng-container i18n>Symbol</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span> <span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
@ -30,11 +30,10 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1" class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Name <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<ng-container *ngIf="element.name !== element.symbol">{{ <ng-container *ngIf="element.name !== element.symbol">{{
@ -47,11 +46,10 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1" class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Value <ng-container i18n>Value</ng-container>
</th> </th>
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element"> <td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -68,11 +66,10 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Allocation <ng-container i18n>Allocation</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -89,10 +86,9 @@
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell mat-header-cell
> >
Performance <ng-container i18n>Performance</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -137,9 +133,20 @@
*ngIf="dataSource.data.length > pageSize && !isLoading" *ngIf="dataSource.data.length > pageSize && !isLoading"
class="my-3 text-center" class="my-3 text-center"
> >
<button i18n mat-stroked-button (click)="onShowAllPositions()"> <button mat-stroked-button (click)="onShowAllPositions()">
Show all <ng-container i18n>Show all</ng-container>
</button> </button>
</div> </div>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator> <mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

View File

@ -27,6 +27,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToShowValues = true; @Input() hasPermissionToShowValues = true;
@Input() locale: string; @Input() locale: string;
@Input() pageSize = Number.MAX_SAFE_INTEGER; @Input() pageSize = Number.MAX_SAFE_INTEGER;

View File

@ -17,6 +17,14 @@ import { ToggleOption } from '@ghostfolio/common/types';
styleUrls: ['./toggle.component.scss'] styleUrls: ['./toggle.component.scss']
}) })
export class ToggleComponent implements OnChanges, OnInit { export class ToggleComponent implements OnChanges, OnInit {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ label: $localize`Today`, value: '1d' },
{ label: $localize`YTD`, value: 'ytd' },
{ label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' },
{ label: $localize`Max`, value: 'max' }
];
@Input() defaultValue: string; @Input() defaultValue: string;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() options: ToggleOption[]; @Input() options: ToggleOption[];

View File

@ -21,6 +21,7 @@ export class AuthGuard implements CanActivate {
'/de/blog', '/de/blog',
'/demo', '/demo',
'/en/blog', '/en/blog',
'/faq',
'/features', '/features',
'/markets', '/markets',
'/p', '/p',
@ -71,7 +72,13 @@ export class AuthGuard implements CanActivate {
}) })
) )
.subscribe((user) => { .subscribe((user) => {
if ( const userLanguage = user?.settings?.language;
if (userLanguage && document.documentElement.lang !== userLanguage) {
window.location.href = `../${userLanguage}`;
resolve(false);
return;
} else if (
state.url.startsWith('/home') && state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN user.settings.viewMode === ViewMode.ZEN
) { ) {

View File

@ -56,14 +56,18 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (!this.snackBarRef) { if (!this.snackBarRef) {
if (this.info.isReadOnlyMode) { if (this.info.isReadOnlyMode) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'This feature is currently unavailable. Please try again later.', $localize`This feature is currently unavailable.` +
' ' +
$localize`Please try again later.`,
undefined, undefined,
{ duration: 6000 } { duration: 6000 }
); );
} else { } else {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.', $localize`This feature requires a subscription.`,
this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined, this.hasPermissionForSubscription
? $localize`Upgrade Plan`
: undefined,
{ duration: 6000 } { duration: 6000 }
); );
} }
@ -79,8 +83,10 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) { } else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
if (!this.snackBarRef) { if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'Oops! Something went wrong. Please try again later.', $localize`Oops! Something went wrong.` +
'Okay', ' ' +
$localize`Please try again later.`,
$localize`Okay`,
{ duration: 6000 } { duration: 6000 }
); );

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] } {
canActivate: [AuthGuard],
component: AboutPageComponent,
path: '',
title: $localize`About`
}
]; ];
@NgModule({ @NgModule({

View File

@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
<div class="about-container"> <div class="about-container">
<p> <p>
Ghostfolio is a lightweight wealth management application for Ghostfolio is a lightweight wealth management application for
@ -21,7 +21,7 @@
<ng-container *ngIf="version"> <ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}. This instance is running Ghostfolio {{ version }}.
</ng-container> </ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n <ng-container *ngIf="hasPermissionForStatistics"
>Check the system status at >Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status" <a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a >status.ghostfol.io</a
@ -102,33 +102,36 @@
<div *ngIf="hasPermissionForStatistics" class="mb-5 row"> <div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3> <h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
<mat-card> <mat-card>
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
label="Active Users" i18n
size="large" size="large"
subLabel="(Last 24 hours)" subLabel="(Last 24 hours)"
[value]="statistics?.activeUsers1d ?? '-'" [value]="statistics?.activeUsers1d ?? '-'"
></gf-value> >Active Users</gf-value
>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
label="New Users" i18n
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[value]="statistics?.newUsers30d ?? '-'" [value]="statistics?.newUsers30d ?? '-'"
></gf-value> >New Users</gf-value
>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
label="Active Users" i18n
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[value]="statistics?.activeUsers30d ?? '-'" [value]="statistics?.activeUsers30d ?? '-'"
></gf-value> >Active Users</gf-value
>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a <a
@ -136,10 +139,11 @@
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<gf-value <gf-value
label="Users in Slack community" i18n
size="large" size="large"
[value]="statistics?.slackCommunityUsers ?? '-'" [value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value> >Users in Slack community</gf-value
>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -148,10 +152,11 @@
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<gf-value <gf-value
label="Contributors on GitHub" i18n
size="large" size="large"
[value]="statistics?.gitHubContributors ?? '-'" [value]="statistics?.gitHubContributors ?? '-'"
></gf-value> >Contributors on GitHub</gf-value
>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -160,10 +165,11 @@
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<gf-value <gf-value
label="Stars on GitHub" i18n
size="large" size="large"
[value]="statistics?.gitHubStargazers ?? '-'" [value]="statistics?.gitHubStargazers ?? '-'"
></gf-value> >Stars on GitHub</gf-value
>
</a> </a>
</div> </div>
</div> </div>
@ -173,34 +179,40 @@
</div> </div>
<div class="row"> <div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
mat-stroked-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div <div
class="col-md-4 col-xs-12 my-2" class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }" [ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
> >
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
i18n
mat-stroked-button mat-stroked-button
[routerLink]="['/about', 'changelog']" [routerLink]="['/about', 'changelog']"
>Changelog & License</a >Changelog & License</a
> >
</div> </div>
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2"> <div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
i18n
mat-stroked-button mat-stroked-button
[routerLink]="['/about', 'privacy-policy']" [routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a >Privacy Policy</a
> >
</div> </div>
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2"> <div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[routerLink]="['/blog']" [routerLink]="['/blog']"
>Blog</a >Blog</a

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ChangelogPageComponent } from './changelog-page.component'; import { ChangelogPageComponent } from './changelog-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] } {
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: $localize`Changelog & License`
}
]; ];
@NgModule({ @NgModule({

View File

@ -4,7 +4,7 @@
<h3 class="mb-3 text-center" i18n>Changelog</h3> <h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog"> <mat-card class="changelog">
<mat-card-content> <mat-card-content>
<markdown [src]="'assets/CHANGELOG.md'"></markdown> <markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -15,7 +15,7 @@
<h3 class="mb-3 text-center" i18n>License</h3> <h3 class="mb-3 text-center" i18n>License</h3>
<mat-card> <mat-card>
<mat-card-content> <mat-card-content>
<markdown [src]="'assets/LICENSE'"></markdown> <markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component'; import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] } {
canActivate: [AuthGuard],
component: PrivacyPolicyPageComponent,
path: '',
title: $localize`Privacy Policy`
}
]; ];
@NgModule({ @NgModule({

View File

@ -2,7 +2,7 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3> <h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
<markdown [src]="'assets/privacy-policy.md'"></markdown> <markdown [src]="'../assets/privacy-policy.md'"></markdown>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountPageComponent } from './account-page.component'; import { AccountPageComponent } from './account-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] } {
canActivate: [AuthGuard],
component: AccountPageComponent,
path: '',
title: $localize`My Ghostfolio`
}
]; ];
@NgModule({ @NgModule({

View File

@ -53,6 +53,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public hasPermissionToDeleteAccess: boolean; public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang;
public locales = ['de', 'de-CH', 'en-GB', 'en-US']; public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number; public price: number;
public priceId: string; public priceId: string;
@ -162,6 +163,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.user = user; this.user = user;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
if (aKey === 'language') {
if (aValue) {
window.location.href = `../${aValue}/account`;
} else {
window.location.href = `../`;
}
}
}); });
}); });
} }
@ -218,7 +227,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
} }
public onRedeemCoupon() { public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:'); let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();
if (couponCode) { if (couponCode) {
@ -227,17 +236,21 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.pipe( .pipe(
takeUntil(this.unsubscribeSubject), takeUntil(this.unsubscribeSubject),
catchError(() => { catchError(() => {
this.snackBar.open('😞 Could not redeem coupon code', undefined, { this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
{
duration: 3000 duration: 3000
}); }
);
return EMPTY; return EMPTY;
}) })
) )
.subscribe(() => { .subscribe(() => {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'✅ Coupon code has been redeemed', '✅' + $localize`Coupon code has been redeemed`,
'Reload', $localize`Reload`,
{ {
duration: 3000 duration: 3000
} }
@ -283,7 +296,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.registerDevice(); this.registerDevice();
} else { } else {
const confirmation = confirm( const confirmation = confirm(
'Do you really want to remove this sign in method?' $localize`Do you really want to remove this sign in method?`
); );
if (confirmation) { if (confirmation) {

View File

@ -8,10 +8,6 @@
<div class="col"> <div class="col">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<div *ngIf="user.alias" class="d-flex py-1">
<div class="pr-1 w-50" i18n>Alias</div>
<div class="pl-1 w-50">{{ user.alias }}</div>
</div>
<div <div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription" *ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
class="d-flex py-1" class="d-flex py-1"
@ -35,11 +31,10 @@
<ng-container *ngIf="hasPermissionForSubscription"> <ng-container *ngIf="hasPermissionForSubscription">
<button <button
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
(click)="onCheckout(priceId)" (click)="onCheckout(priceId)"
> >
Upgrade <ng-container i18n>Upgrade</ng-container>
</button> </button>
<div *ngIf="price" class="mt-1"> <div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
@ -95,8 +90,8 @@
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100"> <form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2"> <div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n> <div class="align-items-center d-flex pt-1 pt-1 w-50">
Base Currency <ng-container i18n>Base Currency</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -115,11 +110,30 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Language</div>
<div class="hint-text text-muted" i18n>Beta</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="language"
[value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2"> <div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Locale</div> <div i18n>Locale</div>
<div class="hint-text text-muted" i18n> <div class="hint-text text-muted">
Date and number format <ng-container i18n>Date and number format</ng-container>
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
@ -141,8 +155,8 @@
</div> </div>
</div> </div>
<div class="d-flex"> <div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n> <div class="align-items-center d-flex pr-1 pt-1 w-50">
View Mode <ng-container i18n>View Mode</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden"> <div class="align-items-center d-flex overflow-hidden">

View File

@ -14,12 +14,11 @@
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[disabled]="!addAccessForm.form.valid" [disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data" [mat-dialog-close]="data"
> >
Save <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>
</form> </form>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountsPageComponent } from './accounts-page.component'; import { AccountsPageComponent } from './accounts-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] } {
canActivate: [AuthGuard],
component: AccountsPageComponent,
path: '',
title: $localize`Accounts`
}
]; ];
@NgModule({ @NgModule({

View File

@ -66,12 +66,11 @@
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[disabled]="!addAccountForm.form.valid" [disabled]="!addAccountForm.form.valid"
[mat-dialog-close]="data" [mat-dialog-close]="data"
> >
Save <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>
</form> </form>

View File

@ -10,8 +10,6 @@ import { AdminPageComponent } from './admin-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
path: '',
component: AdminPageComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' }, { path: '', redirectTo: 'overview', pathMatch: 'full' },
@ -19,7 +17,10 @@ const routes: Routes = [
{ path: 'market-data', component: AdminMarketDataComponent }, { path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent }, { path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent } { path: 'users', component: AdminUsersComponent }
] ],
component: AdminPageComponent,
path: '',
title: $localize`Admin Control`
} }
]; ];

View File

@ -4,8 +4,8 @@ import { RouterModule, Routes } from '@angular/router';
import { AuthPageComponent } from './auth-page.component'; import { AuthPageComponent } from './auth-page.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AuthPageComponent }, { component: AuthPageComponent, path: '' },
{ path: ':jwt', component: AuthPageComponent } { component: AuthPageComponent, path: ':jwt' }
]; ];
@NgModule({ @NgModule({

View File

@ -28,6 +28,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
const jwt = params['jwt']; const jwt = params['jwt'];
this.tokenStorageService.saveToken( this.tokenStorageService.saveToken(
jwt, jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true' this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'

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