Compare commits
106 Commits
Author | SHA1 | Date | |
---|---|---|---|
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 |
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal 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
49
.github/workflows/docker-image.yml
vendored
Normal 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
|
30
.travis.yml
30
.travis.yml
@ -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
|
|
204
CHANGELOG.md
204
CHANGELOG.md
@ -5,6 +5,210 @@ 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.187.0 - 03.09.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported units in the line chart component
|
||||||
|
- Added a new chart calculation engine (experimental)
|
||||||
|
|
||||||
|
## 1.186.2 - 03.09.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Decreased the rate limiter duration of queue jobs from 5 to 4 seconds
|
||||||
|
- Removed the alias from the `User` database schema
|
||||||
|
- Upgraded `angular` from version `14.1.0` to `14.2.0`
|
||||||
|
- Upgraded `Nx` from version `14.5.1` to `14.6.4`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the environment variables `REDIS_HOST`, `REDIS_PASSWORD` and `REDIS_PORT` in the Redis configuration
|
||||||
|
- Handled errors in the portfolio calculation if there is no internet connection
|
||||||
|
- Fixed the _GitHub_ contributors count on the about page
|
||||||
|
|
||||||
|
## 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
|
## 1.171.0 - 22.07.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
20
Dockerfile
20
Dockerfile
@ -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" ]
|
||||||
|
47
README.md
47
README.md
@ -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,25 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
### 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` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
@ -97,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
@ -114,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
@ -134,7 +135,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
@ -258,7 +259,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
|
||||||
|
|
||||||
|
50
angular.json
50
angular.json
@ -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": {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
export default {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
|
@ -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 } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1 +1,11 @@
|
|||||||
export class CreateAccessDto {}
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccessDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
alias?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
granteeUserId?: string;
|
||||||
|
}
|
||||||
|
@ -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')
|
||||||
|
@ -11,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 {
|
||||||
@ -63,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 =
|
||||||
@ -84,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' }],
|
||||||
@ -102,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 (
|
||||||
@ -113,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
|
||||||
|
@ -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 {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
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 { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -62,9 +63,17 @@ 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`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ export class WebAuthService {
|
|||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
userName: user.alias,
|
userName: '',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
attestationType: 'indirect',
|
attestationType: 'indirect',
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
|
@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,15 +16,17 @@ 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'];
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
try {
|
try {
|
||||||
benchmarks = JSON.parse(
|
benchmarks = JSON.parse(
|
||||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
@ -31,7 +36,12 @@ export class BenchmarkService {
|
|||||||
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);
|
||||||
|
|
||||||
|
if (allTimeHigh) {
|
||||||
|
performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||||
.div(allTimeHigh)
|
.div(allTimeHigh)
|
||||||
.minus(1);
|
.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;
|
||||||
|
@ -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,
|
||||||
|
81
apps/api/src/app/frontend.middleware.ts
Normal file
81
apps/api/src/app/frontend.middleware.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -48,6 +48,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -58,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,
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
@ -13,7 +12,10 @@ import {
|
|||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
encodeDataSource,
|
||||||
|
extractNumberFromString
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
@ -21,6 +23,7 @@ import { permissions } from '@ghostfolio/common/permissions';
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -30,7 +33,6 @@ export class InfoService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@ -143,17 +145,21 @@ export class InfoService {
|
|||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
'https://github.com/ghostfolio/ghostfolio',
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'string',
|
||||||
200,
|
200,
|
||||||
{
|
{}
|
||||||
'User-Agent': 'request'
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const contributors = await get();
|
const html = await get();
|
||||||
return contributors?.length;
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
return extractNumberFromString(
|
||||||
|
$(
|
||||||
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
|
).text()
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Type
|
Type
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
@ -13,25 +14,33 @@ import {
|
|||||||
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()
|
||||||
|
@ -143,6 +143,11 @@ 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;
|
||||||
@ -225,9 +230,10 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SymbolProfileOverrides: {
|
OR: [
|
||||||
is: null
|
{ SymbolProfileOverrides: { is: null } },
|
||||||
}
|
{ SymbolProfileOverrides: { assetClass: null } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -316,6 +322,10 @@ export class OrderService {
|
|||||||
delete data.Account;
|
delete data.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
data.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Type
|
Type
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -27,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;
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
isBefore,
|
isBefore,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isSameYear,
|
isSameYear,
|
||||||
|
isWithinInterval,
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
set
|
set
|
||||||
@ -167,13 +168,21 @@ export class PortfolioCalculator {
|
|||||||
this.transactionPoints = transactionPoints;
|
this.transactionPoints = transactionPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
public async getCurrentPositions(
|
||||||
if (!this.transactionPoints?.length) {
|
start: Date,
|
||||||
|
end = new Date(Date.now())
|
||||||
|
): Promise<CurrentPositions> {
|
||||||
|
const transactionPointsBeforeEndDate =
|
||||||
|
this.transactionPoints?.filter((transactionPoint) => {
|
||||||
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
if (!transactionPointsBeforeEndDate.length) {
|
||||||
return {
|
return {
|
||||||
currentValue: new Big(0),
|
currentValue: new Big(0),
|
||||||
hasErrors: false,
|
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: new Big(0),
|
grossPerformancePercentage: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
positions: [],
|
positions: [],
|
||||||
@ -182,39 +191,38 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastTransactionPoint =
|
const lastTransactionPoint =
|
||||||
this.transactionPoints[this.transactionPoints.length - 1];
|
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
|
||||||
|
|
||||||
// use Date.now() to use the mock for today
|
|
||||||
const today = new Date(Date.now());
|
|
||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = this.transactionPoints.length;
|
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
const dates = [];
|
const dates = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
dates.push(resetHours(start));
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
dataGatheringItems.push({
|
dataGatheringItems.push({
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
symbol: item.symbol
|
symbol: item.symbol
|
||||||
});
|
});
|
||||||
currencies[item.symbol] = item.currency;
|
currencies[item.symbol] = item.currency;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
|
||||||
if (
|
if (
|
||||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
|
||||||
firstTransactionPoint === null
|
firstTransactionPoint === null
|
||||||
) {
|
) {
|
||||||
firstTransactionPoint = this.transactionPoints[i];
|
firstTransactionPoint = transactionPointsBeforeEndDate[i];
|
||||||
firstIndex = i;
|
firstIndex = i;
|
||||||
}
|
}
|
||||||
if (firstTransactionPoint !== null) {
|
if (firstTransactionPoint !== null) {
|
||||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
dates.push(
|
||||||
|
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dates.push(resetHours(today));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const marketSymbols = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
@ -241,7 +249,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayString = format(today, DATE_FORMAT);
|
const endDateString = format(end, DATE_FORMAT);
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
if (firstIndex > 0) {
|
||||||
firstIndex--;
|
firstIndex--;
|
||||||
@ -254,7 +262,7 @@ export class PortfolioCalculator {
|
|||||||
const errors: ResponseError['errors'] = [];
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
for (const item of lastTransactionPoint.items) {
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
@ -264,6 +272,7 @@ export class PortfolioCalculator {
|
|||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercentage
|
netPerformancePercentage
|
||||||
} = this.getSymbolMetrics({
|
} = this.getSymbolMetrics({
|
||||||
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
start,
|
start,
|
||||||
symbol: item.symbol
|
symbol: item.symbol
|
||||||
@ -432,10 +441,15 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let minNetPerformance = new Big(0);
|
||||||
|
let maxNetPerformance = new Big(0);
|
||||||
|
|
||||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
timelinePeriodPromises
|
timelinePeriodPromises
|
||||||
);
|
);
|
||||||
const minNetPerformance = timelineInfoInterfaces
|
|
||||||
|
try {
|
||||||
|
minNetPerformance = timelineInfoInterfaces
|
||||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
.filter((performance) => performance !== null)
|
.filter((performance) => performance !== null)
|
||||||
.reduce((minPerformance, current) => {
|
.reduce((minPerformance, current) => {
|
||||||
@ -446,7 +460,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxNetPerformance = timelineInfoInterfaces
|
maxNetPerformance = timelineInfoInterfaces
|
||||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||||
.filter((performance) => performance !== null)
|
.filter((performance) => performance !== null)
|
||||||
.reduce((maxPerformance, current) => {
|
.reduce((maxPerformance, current) => {
|
||||||
@ -456,6 +470,7 @@ export class PortfolioCalculator {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const timelinePeriods = timelineInfoInterfaces.map(
|
const timelinePeriods = timelineInfoInterfaces.map(
|
||||||
(timelineInfo) => timelineInfo.timelinePeriods
|
(timelineInfo) => timelineInfo.timelinePeriods
|
||||||
@ -694,10 +709,12 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getSymbolMetrics({
|
private getSymbolMetrics({
|
||||||
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
start,
|
start,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
end: Date;
|
||||||
marketSymbolMap: {
|
marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
};
|
};
|
||||||
@ -720,13 +737,12 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||||
const endDate = new Date(Date.now());
|
|
||||||
|
|
||||||
const unitPriceAtStartDate =
|
const unitPriceAtStartDate =
|
||||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
const unitPriceAtEndDate =
|
const unitPriceAtEndDate =
|
||||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!unitPriceAtEndDate ||
|
!unitPriceAtEndDate ||
|
||||||
@ -779,7 +795,7 @@ export class PortfolioCalculator {
|
|||||||
orders.push({
|
orders.push({
|
||||||
symbol,
|
symbol,
|
||||||
currency: null,
|
currency: null,
|
||||||
date: format(endDate, DATE_FORMAT),
|
date: format(end, DATE_FORMAT),
|
||||||
dataSource: null,
|
dataSource: null,
|
||||||
fee: new Big(0),
|
fee: new Big(0),
|
||||||
itemType: 'end',
|
itemType: 'end',
|
||||||
|
@ -35,7 +35,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors,
|
||||||
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -110,6 +111,26 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('chart')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@Version('2')
|
||||||
|
public async getChartV2(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('range') range
|
||||||
|
): Promise<PortfolioChart> {
|
||||||
|
const historicalDataContainer = await this.portfolioService.getChartV2(
|
||||||
|
impersonationId,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: historicalDataContainer.items,
|
||||||
|
hasError: false,
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@ -349,6 +370,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
|
alias: access.alias,
|
||||||
holdings: {}
|
holdings: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
|
addDays,
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
endOfToday,
|
endOfToday,
|
||||||
format,
|
format,
|
||||||
@ -71,7 +72,7 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -85,6 +86,7 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
|
private static readonly MAX_CHART_ITEMS = 250;
|
||||||
private baseCurrency: string;
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -327,10 +329,10 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
||||||
lastItem?.netPerformance
|
lastItem?.netPerformance ?? 0
|
||||||
);
|
);
|
||||||
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
||||||
lastItem?.netPerformance
|
lastItem?.netPerformance ?? 0
|
||||||
);
|
);
|
||||||
if (isAllTimeHigh && isAllTimeLow) {
|
if (isAllTimeHigh && isAllTimeLow) {
|
||||||
isAllTimeHigh = false;
|
isAllTimeHigh = false;
|
||||||
@ -354,6 +356,78 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getChartV2(
|
||||||
|
aImpersonationId: string,
|
||||||
|
aDateRange: DateRange = 'max'
|
||||||
|
): Promise<HistoricalDataContainer> {
|
||||||
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
|
const { portfolioOrders, transactionPoints } =
|
||||||
|
await this.getTransactionPoints({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: this.request.user.Settings.currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
if (transactionPoints.length === 0) {
|
||||||
|
return {
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||||
|
|
||||||
|
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||||
|
const step = Math.round(
|
||||||
|
daysInMarket / Math.min(daysInMarket, PortfolioService.MAX_CHART_ITEMS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
|
let currentEndDate = startDate;
|
||||||
|
|
||||||
|
while (isBefore(currentEndDate, endDate)) {
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
startDate,
|
||||||
|
currentEndDate
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
date: format(currentEndDate, DATE_FORMAT),
|
||||||
|
value: currentPositions.netPerformancePercentage.toNumber() * 100
|
||||||
|
});
|
||||||
|
|
||||||
|
currentEndDate = addDays(currentEndDate, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (last(items)?.date !== format(today, DATE_FORMAT)) {
|
||||||
|
// Add today
|
||||||
|
const { netPerformancePercentage } =
|
||||||
|
await portfolioCalculator.getCurrentPositions(startDate, today);
|
||||||
|
items.push({
|
||||||
|
date: format(today, DATE_FORMAT),
|
||||||
|
value: netPerformancePercentage.toNumber() * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false,
|
||||||
|
items: items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aUserId: string,
|
aUserId: string,
|
||||||
@ -466,7 +540,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
holdings[item.symbol] = {
|
holdings[item.symbol] = {
|
||||||
markets,
|
markets,
|
||||||
allocationCurrent: value.div(totalValue).toNumber(),
|
allocationCurrent: totalValue.eq(0)
|
||||||
|
? 0
|
||||||
|
: value.div(totalValue).toNumber(),
|
||||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
@ -478,7 +554,7 @@ export class PortfolioService {
|
|||||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||||
investment: item.investment.toNumber(),
|
investment: item.investment.toNumber(),
|
||||||
marketPrice: item.marketPrice,
|
marketPrice: item.marketPrice,
|
||||||
marketState: dataProviderResponse.marketState,
|
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||||
name: symbolProfile.name,
|
name: symbolProfile.name,
|
||||||
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
||||||
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CacheModule.registerAsync({
|
CacheModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => ({
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
|
return <CacheManagerOptions>{
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
port: configurationService.get('REDIS_PORT'),
|
port: configurationService.get('REDIS_PORT'),
|
||||||
store: redisStore,
|
store: redisStore,
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
})
|
};
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
ConfigurationModule
|
ConfigurationModule
|
||||||
],
|
],
|
||||||
|
@ -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')
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -5,10 +5,18 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExperimentalFeatures?: boolean;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
@ -43,7 +43,7 @@ export class UserService {
|
|||||||
include: {
|
include: {
|
||||||
User: true
|
User: true
|
||||||
},
|
},
|
||||||
orderBy: { User: { alias: 'asc' } },
|
orderBy: { alias: 'asc' },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
let tags = await this.tagService.getByUser(id);
|
let tags = await this.tagService.getByUser(id);
|
||||||
@ -62,7 +62,7 @@ export class UserService {
|
|||||||
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
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@ -98,7 +98,6 @@ export class UserService {
|
|||||||
const {
|
const {
|
||||||
accessToken,
|
accessToken,
|
||||||
Account,
|
Account,
|
||||||
alias,
|
|
||||||
authChallenge,
|
authChallenge,
|
||||||
createdAt,
|
createdAt,
|
||||||
id,
|
id,
|
||||||
@ -116,7 +115,6 @@ export class UserService {
|
|||||||
const user: UserWithSettings = {
|
const user: UserWithSettings = {
|
||||||
accessToken,
|
accessToken,
|
||||||
Account,
|
Account,
|
||||||
alias,
|
|
||||||
authChallenge,
|
authChallenge,
|
||||||
createdAt,
|
createdAt,
|
||||||
id,
|
id,
|
||||||
|
@ -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('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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 }),
|
||||||
|
@ -17,7 +17,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
|
|||||||
imports: [
|
imports: [
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
limiter: {
|
limiter: {
|
||||||
duration: ms('5 seconds'),
|
duration: ms('4 seconds'),
|
||||||
max: 1
|
max: 1
|
||||||
},
|
},
|
||||||
name: DATA_GATHERING_QUEUE
|
name: DATA_GATHERING_QUEUE
|
||||||
|
@ -168,6 +168,7 @@ export class DataProviderService {
|
|||||||
const response: {
|
const response: {
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
} = {};
|
} = {};
|
||||||
|
const startTimeTotal = performance.now();
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
|
|
||||||
@ -176,25 +177,59 @@ export class DataProviderService {
|
|||||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
itemsGroupedByDataSource
|
itemsGroupedByDataSource
|
||||||
)) {
|
)) {
|
||||||
|
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
return dataGatheringItem.symbol;
|
return dataGatheringItem.symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise = Promise.resolve(
|
const maximumNumberOfSymbolsPerRequest =
|
||||||
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||||
|
Number.MAX_SAFE_INTEGER;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < symbols.length;
|
||||||
|
i += maximumNumberOfSymbolsPerRequest
|
||||||
|
) {
|
||||||
|
const startTimeDataSource = performance.now();
|
||||||
|
|
||||||
|
const symbolsChunk = symbols.slice(
|
||||||
|
i,
|
||||||
|
i + maximumNumberOfSymbolsPerRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then((result) => {
|
promise.then((result) => {
|
||||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
for (const [symbol, dataProviderResponse] of Object.entries(
|
||||||
|
result
|
||||||
|
)) {
|
||||||
response[symbol] = dataProviderResponse;
|
response[symbol] = dataProviderResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
||||||
|
(performance.now() - startTimeDataSource) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
Logger.debug('------------------------------------------------');
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${items.length} quotes in ${(
|
||||||
|
(performance.now() - startTimeTotal) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
|
Logger.debug('================================================');
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,11 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
extractNumberFromString,
|
||||||
|
getYesterday
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
@ -16,8 +20,6 @@ import { addDays, format, isBefore } from 'date-fns';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
@ -77,7 +79,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
const html = await get();
|
const html = await get();
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const value = this.extractNumberFromString($(selector).text());
|
const value = extractNumberFromString($(selector).text());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
@ -175,15 +177,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNumberFromString(aString: string): number {
|
|
||||||
try {
|
|
||||||
const [numberString] = aString.match(
|
|
||||||
GhostfolioScraperApiService.NUMERIC_REGEXP
|
|
||||||
);
|
|
||||||
return parseFloat(numberString.trim());
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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');
|
||||||
|
@ -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) {
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
extends PrismaClient
|
extends PrismaClient
|
||||||
implements OnModuleInit, OnModuleDestroy {
|
implements OnModuleInit, OnModuleDestroy
|
||||||
async onModuleInit() {
|
{
|
||||||
|
public async onModuleInit() {
|
||||||
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'PrismaService');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
public async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -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[] = [];
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
export default {
|
export default {
|
||||||
displayName: 'client',
|
displayName: 'client',
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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) {
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -3,17 +3,27 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSort } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
|
public activeFilters: Filter[] = [];
|
||||||
|
public allFilters: Filter[] = [
|
||||||
|
AssetSubClass.BOND,
|
||||||
|
AssetSubClass.COMMODITY,
|
||||||
|
AssetSubClass.CRYPTOCURRENCY,
|
||||||
|
AssetSubClass.ETF,
|
||||||
|
AssetSubClass.MUTUALFUND,
|
||||||
|
AssetSubClass.PRECIOUS_METAL,
|
||||||
|
AssetSubClass.PRIVATE_EQUITY,
|
||||||
|
AssetSubClass.STOCK
|
||||||
|
].map((id) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
});
|
||||||
public currentDataSource: DataSource;
|
public currentDataSource: DataSource;
|
||||||
public currentSymbol: string;
|
public currentSymbol: string;
|
||||||
|
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||||
|
new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public marketData: AdminMarketDataItem[] = [];
|
public deviceType: string;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public displayedColumns = [
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'assetClass',
|
||||||
|
'assetSubClass',
|
||||||
|
'date',
|
||||||
|
'activityCount',
|
||||||
|
'marketDataItemCount',
|
||||||
|
'countriesCount',
|
||||||
|
'sectorsCount',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
public filters$ = new Subject<Filter[]>();
|
||||||
|
public isLoading = false;
|
||||||
|
public placeholder = '';
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -35,8 +80,29 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (
|
||||||
|
params['assetProfileDialog'] &&
|
||||||
|
params['dataSource'] &&
|
||||||
|
params['dateOfFirstActivity'] &&
|
||||||
|
params['symbol']
|
||||||
|
) {
|
||||||
|
this.openAssetProfileDialog({
|
||||||
|
dataSource: params['dataSource'],
|
||||||
|
dateOfFirstActivity: params['dateOfFirstActivity'],
|
||||||
|
symbol: params['symbol']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -51,7 +117,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminMarketData();
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((filters) => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.activeFilters = filters;
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
return this.dataService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -75,54 +165,60 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onOpenAssetProfileDialog({
|
||||||
if (withRefresh) {
|
dataSource,
|
||||||
this.fetchAdminMarketData();
|
dateOfFirstActivity,
|
||||||
this.fetchAdminMarketDataBySymbol({
|
symbol
|
||||||
dataSource: this.currentDataSource,
|
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||||
symbol: this.currentSymbol
|
this.router.navigate([], {
|
||||||
|
queryParams: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetProfileDialog: true,
|
||||||
|
dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
|
|
||||||
this.marketDataDetails = [];
|
|
||||||
|
|
||||||
if (this.currentSymbol === symbol) {
|
|
||||||
this.currentDataSource = undefined;
|
|
||||||
this.currentSymbol = '';
|
|
||||||
} else {
|
|
||||||
this.currentDataSource = dataSource;
|
|
||||||
this.currentSymbol = symbol;
|
|
||||||
|
|
||||||
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminMarketData() {
|
private openAssetProfileDialog({
|
||||||
this.dataService
|
dataSource,
|
||||||
.fetchAdminMarketData()
|
dateOfFirstActivity,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe((user) => {
|
||||||
this.marketData = marketData;
|
this.user = user;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
const dialogRef = this.dialog.open(AssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <AssetProfileDialogParams>{
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
|
symbol,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
dialogRef
|
||||||
this.adminService
|
.afterClosed()
|
||||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe(() => {
|
||||||
this.marketDataDetails = marketData;
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,108 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table class="gf-table w-100">
|
<gf-activities-filter
|
||||||
<thead>
|
[allFilters]="allFilters"
|
||||||
<tr class="mat-header-row">
|
[isLoading]="isLoading"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
[placeholder]="placeholder"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
(valueChanged)="filters$.next($event)"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
></gf-activities-filter>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<div class="row">
|
||||||
</tr>
|
<div class="col">
|
||||||
</thead>
|
<table
|
||||||
<tbody>
|
class="gf-table w-100"
|
||||||
<ng-container *ngFor="let item of marketData; let i = index">
|
matSort
|
||||||
<tr
|
matSortActive="symbol"
|
||||||
class="cursor-pointer mat-row"
|
matSortDirection="asc"
|
||||||
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
mat-table
|
||||||
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
<ng-container matColumnDef="symbol">
|
||||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container i18n>Symbol</ng-container>
|
||||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
</ng-container>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetSubClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Sub Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetSubClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="date">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>First Activity</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activityCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Activity Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.activityCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.marketDataItemCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="countriesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.countriesCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="sectorsCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.sectorsCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -36,44 +113,35 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Data
|
<ng-container i18n>Gather Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Profile Data
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="item.activityCount !== 0"
|
[disabled]="element.activityCount !== 0"
|
||||||
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Delete Profile Data
|
<ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
|
||||||
<td class="p-1" colspan="6">
|
|
||||||
<gf-admin-market-data-detail
|
|
||||||
[dataSource]="item.dataSource"
|
|
||||||
[dateOfFirstActivity]="item.date"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[marketData]="marketDataDetails"
|
|
||||||
[symbol]="item.symbol"
|
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
|
||||||
></gf-admin-market-data-detail>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tbody>
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="cursor-pointer"
|
||||||
|
mat-row
|
||||||
|
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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 {}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface AssetProfileDialogParams {
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<div class="align-items-center d-flex flex-row">
|
<div class="position-relative">
|
||||||
|
<div class="align-items-center d-flex flex-row" [hidden]="!fearAndGreedIndex">
|
||||||
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
|
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="h4 mb-0">
|
<div class="h4 mb-0">
|
||||||
@ -10,4 +11,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="d-block" i18n>Current Market Mood</small>
|
<small class="d-block" i18n>Current Market Mood</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<ngx-skeleton-loader
|
||||||
|
*ngIf="!fearAndGreedIndex"
|
||||||
|
animation="pulse"
|
||||||
|
class="position-absolute w-100"
|
||||||
|
[theme]="{
|
||||||
|
height: '100%'
|
||||||
|
}"
|
||||||
|
></ngx-skeleton-loader>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
ngx-skeleton-loader {
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -285,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>
|
||||||
|
@ -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;
|
||||||
}),
|
}),
|
||||||
|
@ -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 (
|
||||||
|
@ -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;
|
||||||
|
@ -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%'
|
||||||
|
@ -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;
|
||||||
@ -106,7 +106,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.isLoadingPerformance = true;
|
this.isLoadingPerformance = true;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchChart({ range: this.dateRange })
|
.fetchChart({
|
||||||
|
range: this.dateRange,
|
||||||
|
version: this.user?.settings?.isExperimentalFeatures ? 2 : 1
|
||||||
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((chartData) => {
|
.subscribe((chartData) => {
|
||||||
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
|
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="position-absolute"
|
class="position-absolute"
|
||||||
symbol="Performance"
|
symbol="Performance"
|
||||||
[currency]="user?.settings?.baseCurrency"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[hidden]="historicalDataItems?.length === 0"
|
[hidden]="historicalDataItems?.length === 0"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
@ -24,6 +23,7 @@
|
|||||||
[showLoader]="false"
|
[showLoader]="false"
|
||||||
[showXAxis]="false"
|
[showXAxis]="false"
|
||||||
[showYAxis]="false"
|
[showYAxis]="false"
|
||||||
|
[unit]="user?.settings?.isExperimentalFeatures ? '%' : user?.settings?.baseCurrency"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
.chart-container {
|
.chart-container {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 67rem;
|
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) {
|
||||||
|
@ -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)"
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
@ -212,10 +249,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
private getTooltipPluginConfiguration() {
|
private getTooltipPluginConfiguration() {
|
||||||
return {
|
return {
|
||||||
...getTooltipOptions(
|
...getTooltipOptions({
|
||||||
this.isInPercent ? undefined : this.currency,
|
locale: this.isInPercent ? undefined : this.locale,
|
||||||
this.isInPercent ? undefined : this.locale
|
unit: this.isInPercent ? undefined : this.currency
|
||||||
),
|
}),
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
position: <unknown>'top',
|
position: <unknown>'top',
|
||||||
xAlign: 'center',
|
xAlign: 'center',
|
||||||
|
@ -25,14 +25,14 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
src="./assets/icons/internet-computer.svg"
|
src="../assets/icons/internet-computer.svg"
|
||||||
style="height: 0.75rem"
|
style="height: 0.75rem"
|
||||||
/><span i18n>Sign in with Internet Identity</span>
|
/><span i18n>Sign in with Internet Identity</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="/api/v1/auth/google" mat-stroked-button
|
<a href="../api/v1/auth/google" mat-stroked-button
|
||||||
><img
|
><img
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
src="./assets/icons/google.svg"
|
src="../assets/icons/google.svg"
|
||||||
style="height: 1rem"
|
style="height: 1rem"
|
||||||
/><span i18n>Sign in with Google</span></a
|
/><span i18n>Sign in with Google</span></a
|
||||||
>
|
>
|
||||||
@ -49,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>
|
||||||
|
@ -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());
|
||||||
|
@ -23,124 +23,136 @@
|
|||||||
class="mb-4"
|
class="mb-4"
|
||||||
benchmarkLabel="Average Unit Price"
|
benchmarkLabel="Average Unit Price"
|
||||||
[benchmarkDataItems]="benchmarkDataItems"
|
[benchmarkDataItems]="benchmarkDataItems"
|
||||||
[currency]="SymbolProfile?.currency"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[showGradient]="true"
|
[showGradient]="true"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
[symbol]="data.symbol"
|
[symbol]="data.symbol"
|
||||||
|
[unit]="SymbolProfile?.currency"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
|
|
||||||
<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>
|
||||||
|
@ -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,8 +133,8 @@
|
|||||||
*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>
|
||||||
|
|
||||||
|
@ -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[];
|
||||||
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
@ -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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
@ -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({
|
||||||
|
@ -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>
|
||||||
|
@ -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({
|
||||||
|
@ -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>
|
||||||
|
@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
|||||||
import { AccountPageComponent } from './account-page.component';
|
import { AccountPageComponent } from './account-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: AccountPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: $localize`My Ghostfolio`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -53,6 +53,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToDeleteAccess: boolean;
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public language = document.documentElement.lang;
|
||||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
|
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
@ -162,6 +163,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.user = user;
|
this.user = user;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
if (aKey === 'language') {
|
||||||
|
if (aValue) {
|
||||||
|
window.location.href = `../${aValue}/account`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `../`;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -217,8 +226,26 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onRedeemCoupon() {
|
public onRedeemCoupon() {
|
||||||
let couponCode = prompt('Please enter your coupon code:');
|
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||||
couponCode = couponCode?.trim();
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
if (couponCode) {
|
if (couponCode) {
|
||||||
@ -227,17 +254,21 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeSubject),
|
takeUntil(this.unsubscribeSubject),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
|
this.snackBar.open(
|
||||||
|
'😞 ' + $localize`Could not redeem coupon code`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
duration: 3000
|
duration: 3000
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.snackBarRef = this.snackBar.open(
|
this.snackBarRef = this.snackBar.open(
|
||||||
'✅ Coupon code has been redeemed',
|
'✅' + $localize`Coupon code has been redeemed`,
|
||||||
'Reload',
|
$localize`Reload`,
|
||||||
{
|
{
|
||||||
duration: 3000
|
duration: 3000
|
||||||
}
|
}
|
||||||
@ -283,7 +314,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.registerDevice();
|
this.registerDevice();
|
||||||
} else {
|
} else {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
'Do you really want to remove this sign in method?'
|
$localize`Do you really want to remove this sign in method?`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
@ -303,6 +334,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
||||||
data: {
|
data: {
|
||||||
access: {
|
access: {
|
||||||
|
alias: '',
|
||||||
type: 'PUBLIC'
|
type: 'PUBLIC'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -318,7 +350,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
if (access) {
|
if (access) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.postAccess({})
|
.postAccess({ alias: access.alias })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
@ -31,11 +31,10 @@
|
|||||||
<ng-container *ngIf="hasPermissionForSubscription">
|
<ng-container *ngIf="hasPermissionForSubscription">
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onCheckout(priceId)"
|
(click)="onCheckout(priceId)"
|
||||||
>
|
>
|
||||||
Upgrade
|
<ng-container i18n>Upgrade</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div *ngIf="price" class="mt-1">
|
<div *ngIf="price" class="mt-1">
|
||||||
<ng-container *ngIf="coupon"
|
<ng-container *ngIf="coupon"
|
||||||
@ -91,8 +90,8 @@
|
|||||||
<div class="d-flex mt-4 py-1">
|
<div class="d-flex mt-4 py-1">
|
||||||
<form #changeUserSettingsForm="ngForm" class="w-100">
|
<form #changeUserSettingsForm="ngForm" class="w-100">
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
|
<div class="align-items-center d-flex pt-1 pt-1 w-50">
|
||||||
Base Currency
|
<ng-container i18n>Base Currency</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -111,11 +110,31 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center d-flex mb-2">
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Language</div>
|
||||||
|
<div class="hint-text text-muted" i18n>Beta</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-select
|
||||||
|
name="language"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
[value]="language"
|
||||||
|
(selectionChange)="onChangeUserSetting('language', $event.value)"
|
||||||
|
>
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
|
<mat-option value="de">Deutsch</mat-option>
|
||||||
|
<mat-option value="en">English</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="align-items-center d-flex mb-2">
|
<div class="align-items-center d-flex mb-2">
|
||||||
<div class="pr-1 w-50">
|
<div class="pr-1 w-50">
|
||||||
<div i18n>Locale</div>
|
<div i18n>Locale</div>
|
||||||
<div class="hint-text text-muted" i18n>
|
<div class="hint-text text-muted">
|
||||||
Date and number format
|
<ng-container i18n>Date and number format</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
@ -137,8 +156,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
|
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||||
View Mode
|
<ng-container i18n>View Mode</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<div class="align-items-center d-flex overflow-hidden">
|
<div class="align-items-center d-flex overflow-hidden">
|
||||||
@ -169,6 +188,22 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
|
||||||
|
class="align-items-center d-flex mt-4 py-1"
|
||||||
|
>
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Experimental Features</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-slide-toggle
|
||||||
|
color="primary"
|
||||||
|
[checked]="user.settings.isExperimentalFeatures"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
(change)="onExperimentalFeaturesChange($event)"
|
||||||
|
></mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
<div class="pr-1 w-50" i18n>User ID</div>
|
<div class="pr-1 w-50" i18n>User ID</div>
|
||||||
<div class="pl-1 w-50">{{ user?.id }}</div>
|
<div class="pl-1 w-50">{{ user?.id }}</div>
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
||||||
<h1 i18n mat-dialog-title>Grant access</h1>
|
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Alias</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
name="alias"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="data.access.alias"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
@ -14,12 +25,11 @@
|
|||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccessForm.form.valid"
|
[disabled]="!addAccessForm.form.valid"
|
||||||
[mat-dialog-close]="data"
|
[mat-dialog-close]="data"
|
||||||
>
|
>
|
||||||
Save
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
|
||||||
@ -16,6 +17,7 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.com
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
]
|
]
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user