Compare commits

...

105 Commits

Author SHA1 Message Date
f332aea9b4 Release 1.185.0 (#1207) 2022-08-30 20:53:26 +02:00
7a9fd18407 Feature/improve markets overview (#1206)
* Improve markets overview

* Update changelog
2022-08-30 20:52:13 +02:00
ca08d3154a Bugfix/disable language selector for demo user (#1204)
* Disable language selector for demo user

* Update changelog
2022-08-28 21:11:27 +02:00
01d4ae8757 Feature/move build pipeline from travis to GitHub actions (#1203)
* Remove travis configurations

* Update changelog
2022-08-28 21:10:25 +02:00
43ce2786c1 Fetch all history for all tags and branches (#1202) 2022-08-28 11:01:54 +02:00
de2092c4d2 Release 1.184.2 (#1201) 2022-08-28 10:09:59 +02:00
435a180e54 Release 1.184.1 (#1200) 2022-08-28 09:44:09 +02:00
0ad30ffabe Add version to docker tag (#1199) 2022-08-28 09:43:06 +02:00
0cc5e558f1 Release 1.184.0 (#1198) 2022-08-28 09:12:03 +02:00
63b183cc6f Feature/finalize GitHub action to create arm64 docker image (#1197)
* Change order and refactor secrets

* Update changelog
2022-08-28 09:10:18 +02:00
10bae24c5c Build arm64 docker image and use github actions (#1134)
* Setup build pipeline for arm64 docker images using GitHub Actions
2022-08-27 14:37:22 +02:00
0e29278e96 Feature/add alias to access (#1193)
* Add alias to access

* Update changelog
2022-08-27 11:29:09 +02:00
2db46e5bbf Feature/support localization in date fns (#1195)
* Add locale to date-fns (formatDistanceToNow)

* Update changelog
2022-08-27 10:54:59 +02:00
e757e90e5a Improve translation (#1196) 2022-08-27 10:42:54 +02:00
184ddc6209 Bugfix/fix missing assets in local development (#1194)
* Fix missing assets in local development

* Update changelog
2022-08-27 10:29:50 +02:00
e3662a143c Improve localization (#1191)
* Improve localization

* Update changelog
2022-08-26 20:10:59 +02:00
25afd7e07b Remove database:baseline (#1192) 2022-08-26 18:08:05 +02:00
7fceaa1350 Add language to urls (#1187) 2022-08-26 17:52:42 +02:00
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
225 changed files with 11621 additions and 2289 deletions

36
.github/workflows/build-code.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Build code
on:
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node_version:
- 16
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check formatting
run: yarn format:check
- name: Execute tests
run: yarn test
- name: Build application
run: yarn build:all

49
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Docker image CD
on:
push:
tags:
- '*.*.*'
pull_request:
branches:
- 'main'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

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

@ -1,30 +0,0 @@
language: node_js
git:
depth: false
node_js:
- 14
services:
- docker
cache: yarn
if: (type = pull_request) OR (tag IS present)
jobs:
include:
- stage: Install dependencies
if: type = pull_request
script: yarn --frozen-lockfile
- stage: Check formatting
if: type = pull_request
script: yarn format:check
- stage: Execute tests
if: type = pull_request
script: yarn test
- stage: Build application
if: type = pull_request
script: yarn build:all
- stage: Build and publish docker image
if: tag IS present
script: ./publish-docker-image.sh

View File

@ -5,6 +5,226 @@ 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.185.0 - 30.08.2022
### Added
- Added a skeleton loader to the market mood component in the markets overview
### Changed
- Moved the build pipeline from _Travis_ to _GitHub Actions_
- Increased the caching of the benchmarks
### Fixed
- Disabled the language selector for the demo user
## 1.184.2 - 28.08.2022
### Added
- Added the alias to the `Access` database schema
- Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the missing assets during the local development
## 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 ## 1.169.0 - 14.07.2022
### Added ### Added

View File

@ -1,7 +1,6 @@
FROM node:14-alpine as builder FROM --platform=$BUILDPLATFORM node:16-slim as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding # Only add basic files without the application itself to avoid rebuilding
@ -10,9 +9,16 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl git RUN apt update && apt install -y \
git \
g++ \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
@ -45,8 +51,12 @@ 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-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
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,13 +12,11 @@
<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/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/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">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a> <img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p> </p>
@ -33,7 +31,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -81,6 +79,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 +112,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 +121,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 +133,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 +144,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)
@ -258,7 +257,7 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**. If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License

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

@ -42,14 +42,16 @@ export class AccessController {
return accessesWithGranteeUser.map((access) => { return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) { if (access.GranteeUser) {
return { return {
granteeAlias: access.GranteeUser?.alias, alias: access.alias,
grantee: access.GranteeUser?.id,
id: access.id, id: access.id,
type: 'RESTRICTED_VIEW' type: 'RESTRICTED_VIEW'
}; };
} }
return { return {
granteeAlias: 'Public', alias: access.alias,
grantee: 'Public',
id: access.id, id: access.id,
type: 'PUBLIC' type: 'PUBLIC'
}; };
@ -71,6 +73,10 @@ export class AccessController {
} }
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}); });
} }

View File

@ -1 +1,11 @@
export class CreateAccessDto {} import { IsOptional, IsString } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsString()
granteeUserId?: string;
}

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

@ -1,30 +1,20 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service'; import { BenchmarkService } from './benchmark.service';
@Controller('benchmark') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor( public constructor(private readonly benchmarkService: BenchmarkService) {}
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get() @Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> { public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return { return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets) benchmarks: await this.benchmarkService.getBenchmarks()
}; };
} }
} }

View File

@ -1,10 +1,13 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import Big from 'big.js'; import Big from 'big.js';
import ms from 'ms';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
@ -13,25 +16,32 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getBenchmarks( public async getBenchmarks({ useCache = true } = {}): Promise<
benchmarkAssets: UniqueAsset[] BenchmarkResponse['benchmarks']
): Promise<BenchmarkResponse['benchmarks']> { > {
let benchmarks: BenchmarkResponse['benchmarks']; let benchmarks: BenchmarkResponse['benchmarks'];
try { if (useCache) {
benchmarks = JSON.parse( try {
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) benchmarks = JSON.parse(
); await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) { if (benchmarks) {
return benchmarks; return benchmarks;
} }
} catch {} } catch {}
}
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const promises: Promise<number>[] = []; const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([ const [quotes, assetProfiles] = await Promise.all([
@ -48,9 +58,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);
.div(allTimeHigh)
.minus(1); if (allTimeHigh) {
performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
}
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
@ -72,7 +86,8 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks) JSON.stringify(benchmarks),
ms('4 hours') / 1000
); );
return benchmarks; return benchmarks;

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

@ -332,7 +332,7 @@ export class PortfolioCalculator {
} }
const investments = []; const investments = [];
let currentDate = parseDate(this.orders[0].date); let currentDate: Date;
let investmentByMonth = new Big(0); let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) { for (const [index, order] of this.orders.entries()) {
@ -340,27 +340,34 @@ export class PortfolioCalculator {
isSameMonth(parseDate(order.date), currentDate) && isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate) isSameYear(parseDate(order.date), currentDate)
) { ) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus( investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
); );
} else {
// New month: Store previous month and reset
if (index === this.orders.length - 1) { if (currentDate) {
investments.push({ investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT), date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth investment: investmentByMonth
}); });
} }
} else {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
currentDate = parseDate(order.date); currentDate = parseDate(order.date);
investmentByMonth = order.quantity investmentByMonth = order.quantity
.mul(order.unitPrice) .mul(order.unitPrice)
.mul(this.getFactor(order.type)); .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; return investments;

View File

@ -349,6 +349,7 @@ export class PortfolioController {
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
alias: access.alias,
holdings: {} holdings: {}
}; };

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,14 +56,13 @@ export class UserService {
} }
return { return {
alias,
id, id,
permissions, permissions,
subscription, subscription,
tags, tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.alias,
id: accessItem.id id: accessItem.id
}; };
}), }),

View File

@ -2,5 +2,6 @@
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"UNI1": "Uniswap" "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();
promises.push( const symbolsChunk = symbols.slice(
promise.then((result) => { i,
for (const [symbol, dataProviderResponse] of Object.entries(result)) { i + maximumNumberOfSymbolsPerRequest
response[symbol] = dataProviderResponse; );
}
}) const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
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

@ -208,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource { public getName(): DataSource {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
@ -266,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() { {
await this.$connect(); public async onModuleInit() {
try {
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

@ -7,7 +7,7 @@ import { Module } from '@nestjs/common';
@Module({ @Module({
exports: [TwitterBotService], exports: [TwitterBotService],
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule], imports: [BenchmarkModule, ConfigurationModule, SymbolModule],
providers: [TwitterBotService] providers: [TwitterBotService]
}) })
export class TwitterBotModule {} export class TwitterBotModule {}

View File

@ -1,9 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
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 { import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -11,7 +9,6 @@ import {
resolveFearAndGreedIndex, resolveFearAndGreedIndex,
resolveMarketCondition resolveMarketCondition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { isWeekend } from 'date-fns'; import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@ -23,7 +20,6 @@ export class TwitterBotService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) { ) {
this.twitterClient = new TwitterApi({ this.twitterClient = new TwitterApi({
@ -82,14 +78,9 @@ export class TwitterBotService {
} }
private async getBenchmarkListing(aMax: number) { private async getBenchmarkListing(aMax: number) {
const benchmarkAssets: UniqueAsset[] = const benchmarks = await this.benchmarkService.getBenchmarks({
((await this.propertyService.getByKey( useCache: false
PROPERTY_BENCHMARKS });
)) as UniqueAsset[]) ?? [];
const benchmarks = await this.benchmarkService.getBenchmarks(
benchmarkAssets
);
const benchmarkListing: string[] = []; const benchmarkListing: string[] = [];

View File

@ -2,5 +2,13 @@
"/api": { "/api": {
"target": "http://localhost:3333", "target": "http://localhost:3333",
"secure": false "secure": false
},
"/assets": {
"target": "http://localhost:3333",
"secure": false
},
"/ionicons": {
"target": "http://localhost:3333",
"secure": false
} }
} }

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,37 +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: 'en/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: 'features', path: 'features',
@ -222,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

@ -1,8 +1,15 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.alias }}
</td>
</ng-container>
<ng-container matColumnDef="grantee">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }} {{ element.grantee }}
</td> </td>
</ng-container> </ng-container>
@ -21,8 +28,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 +50,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() {}
@ -31,7 +33,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['granteeAlias', 'type', 'details']; this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');
@ -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

@ -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,28 +165,19 @@ 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,
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) { dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
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() {
@ -104,25 +185,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
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(() => {
.subscribe(({ marketData }) => { this.router.navigate(['.'], { relativeTo: this.route });
this.marketDataDetails = marketData; });
this.changeDetectorRef.markForCheck();
}); });
} }
} }

View File

@ -1,79 +1,147 @@
<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"
>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td>
</ng-container>
<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
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
> >
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td> <ion-icon name="ellipsis-vertical"></ion-icon>
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td> </button>
<td class="mat-cell px-1 py-2"> <mat-menu #accountMenu="matMenu" xPosition="before">
{{ (item.date | date: defaultDateFormat) ?? '' }} <button
</td> mat-menu-item
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td> (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td> >
<td class="mat-cell px-1 py-2"> <ng-container i18n>Gather Data</ng-container>
<button </button>
class="mx-1 no-min-width px-2" <button
mat-button mat-menu-item
[matMenuTriggerFor]="accountMenu" (click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
(click)="$event.stopPropagation()" >
> <ng-container i18n>Gather Profile Data</ng-container>
<ion-icon name="ellipsis-vertical"></ion-icon> </button>
</button> <button
<mat-menu #accountMenu="matMenu" xPosition="before"> mat-menu-item
<button [disabled]="element.activityCount !== 0"
i18n (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
mat-menu-item >
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})" <ng-container i18n>Delete</ng-container>
> </button>
Gather Data </mat-menu>
</button> </td>
<button </ng-container>
i18n
mat-menu-item <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})" <tr
> *matRowDef="let row; columns: displayedColumns"
Gather Profile Data class="cursor-pointer"
</button> mat-row
<button (click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
i18n ></tr>
mat-menu-item
[disabled]="item.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
>
Delete Profile Data
</button>
</mat-menu>
</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>
</tbody>
</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

@ -1,13 +1,23 @@
<div class="align-items-center d-flex flex-row"> <div class="position-relative">
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="align-items-center d-flex flex-row" [hidden]="!fearAndGreedIndex">
<div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div class="h4 mb-0"> <div>
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <div class="h4 mb-0">
<small class="text-muted" <span class="mr-2">{{ fearAndGreedIndexText }}</span>
><strong>{{ fearAndGreedIndex }}</strong <small class="text-muted"
>/100</small ><strong>{{ fearAndGreedIndex }}</strong
> >/100</small
>
</div>
<small class="d-block" i18n>Current Market Mood</small>
</div> </div>
<small class="d-block" i18n>Current Market Mood</small>
</div> </div>
<ngx-skeleton-loader
*ngIf="!fearAndGreedIndex"
animation="pulse"
class="position-absolute w-100"
[theme]="{
height: '100%'
}"
></ngx-skeleton-loader>
</div> </div>

View File

@ -1,3 +1,8 @@
:host { :host {
display: block; display: block;
ngx-skeleton-loader {
bottom: 0;
top: 0;
}
} }

View File

@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { FearAndGreedIndexComponent } from './fear-and-greed-index.component'; import { FearAndGreedIndexComponent } from './fear-and-greed-index.component';
@NgModule({ @NgModule({
declarations: [FearAndGreedIndexComponent], declarations: [FearAndGreedIndexComponent],
exports: [FearAndGreedIndexComponent], exports: [FearAndGreedIndexComponent],
imports: [CommonModule] imports: [CommonModule, NgxSkeletonLoaderModule]
}) })
export class GfFearAndGreedIndexModule {} export class GfFearAndGreedIndexModule {}

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,18 +9,17 @@
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"
[fearAndGreedIndex]="fearAndGreedIndex" [fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index> ></gf-fear-and-greed-index>
</div> </div>
</div> </div>
@ -34,6 +33,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,26 +2,29 @@
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" <div
symbol="Performance" *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
[currency]="user?.settings?.baseCurrency" class="align-items-center d-flex h-100 justify-content-center w-100"
[historicalDataItems]="historicalDataItems" >
[locale]="user?.settings?.locale" <div class="d-flex justify-content-center">
[ngClass]="{ 'pr-3': deviceType === 'mobile' }" <gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
[showGradient]="true" </div>
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</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>

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

@ -10,6 +10,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings" [hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
[isLoading]="isLoading" [isLoading]="isLoading"
[language]="user?.settings?.language"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[summary]="summary" [summary]="summary"
(emergencyFundChanged)="onChangeEmergencyFund($event)" (emergencyFundChanged)="onChangeEmergencyFund($event)"

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,
@ -34,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({
@ -49,6 +50,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@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;
@ -57,6 +59,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
BarController, BarController,
BarElement, BarElement,
LinearScale, LinearScale,
@ -119,7 +122,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
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(
@ -158,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
}, },
@ -172,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
}, },

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

@ -7,6 +7,7 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { getDateFnsLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary } from '@ghostfolio/common/interfaces'; import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -20,6 +21,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() language: string;
@Input() locale: string; @Input() locale: string;
@Input() summary: PortfolioSummary; @Input() summary: PortfolioSummary;
@ -34,7 +36,9 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if (this.summary.firstOrderDate) { if (this.summary.firstOrderDate) {
this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate); this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate, {
locale: getDateFnsLocale(this.language)
});
} else { } else {
this.timeInMarket = '-'; this.timeInMarket = '-';
} }
@ -45,7 +49,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>

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