Compare commits

..

63 Commits

Author SHA1 Message Date
0d5bc3f51b Release 1.190.0 (#1241) 2022-09-10 13:36:39 +02:00
aece76d98f Feature/add date range component to benchmark comparator (#1240)
* Add date range component

* Update changelog
2022-09-10 13:33:37 +02:00
fc4bb71184 Feature/migrate date range setting to user settings (#1239)
* Migrate date range to user settings

* Refactor currency and view mode in the user user settings

* Update changelog
2022-09-10 11:38:06 +02:00
20bc7ef99c Feature/improve benchmark comparator on mobile (#1238)
* Improve layout for mobile

* Update changelog
2022-09-09 19:44:36 +02:00
7a733ae49b Release 1.189.0 (#1237) 2022-09-08 21:06:37 +02:00
376ce88492 Bugfix/fix benchmark chart (#1236)
* Fix benchmark chart

* Distinguish between currency and unit in tooltip

* Update changelog
2022-09-08 21:05:19 +02:00
c4d83aabe7 Setup tests (#1234) 2022-09-08 17:32:55 +02:00
d4e2cec77e Release 1.188.0 (#1233) 2022-09-06 20:40:59 +02:00
75db7bf79a Bugfix/fix asset profile details dialog (#1232)
* Fix dialog for assets without (first) activity

* Update changelog
2022-09-06 20:39:45 +02:00
3ad99c9991 Add data management (#1230)
* Add data management for benchmarks
2022-09-06 20:39:27 +02:00
00e402d286 Add translations 2022-09-04 09:48:18 +02:00
4ac0484025 Update changelog 2022-09-04 09:45:29 +02:00
75d61bff6d Setup benchmark comparator 2022-09-04 09:45:22 +02:00
0de28d733e Release 1.187.0 (#1227) 2022-09-03 21:42:23 +02:00
3b2f13850c Feature/improve chart calculation (#1226)
* Improve chart calculation

* Update changelog
2022-09-03 21:41:06 +02:00
0cc42ffd7c Add end date parameter (#1224)
* Add end date parameter
2022-09-03 21:28:57 +02:00
3ccb812ac3 Release 1.186.2 (#1223) 2022-09-03 11:37:14 +02:00
0a8549db3e Release 1.186.1 (#1222) 2022-09-03 11:19:31 +02:00
c95e90ff31 Release 1.186.0 (#1221) 2022-09-03 10:28:20 +02:00
b59af0d864 Feature/upgrade nx to version 14.6.4 (#1220)
* Upgrade nx and angular

* Update changelog
2022-09-03 10:26:56 +02:00
408bdbd187 Bugfix/fix GitHub contributors count (#1219)
* Fix GitHub contributors count

* Update changelog
2022-09-03 10:06:16 +02:00
a3bfa46fb0 Feature/remove alias from user (#1218)
* Remove alias

* Update changelog
2022-09-03 09:47:18 +02:00
8cb1b3f925 Feature/decrease rate limiter duration (#1217)
* Decrease rate limiter duration

* Update changelog
2022-09-03 09:09:57 +02:00
15c650f951 Bugfix/fix blog post link (#1216)
* Fix link

* Update sitemap.xml
2022-09-03 09:09:37 +02:00
c198bd78da Add architectures (#1205) 2022-09-03 08:32:01 +02:00
35963580bc Bugfix/improve error handling in portfolio calculations (#1215)
* Improve error handling

* Update changelog
2022-09-03 08:31:47 +02:00
cf2c5bad02 Bugfix/change environment variables redis host and port to mandatory (#1211)
* Change REDIS_HOST and REDIS_PORT to mandatory

* Update changelog
2022-09-01 15:35:37 +02:00
f332aea9b4 Release 1.185.0 (#1207) 2022-08-30 20:53:26 +02:00
7a9fd18407 Feature/improve markets overview (#1206)
* Improve markets overview

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

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

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

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

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

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

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

* Update changelog
2022-08-26 20:10:59 +02:00
25afd7e07b Remove database:baseline (#1192) 2022-08-26 18:08:05 +02:00
7fceaa1350 Add language to urls (#1187) 2022-08-26 17:52:42 +02:00
7c8530483c Release 1.183.0 (#1189) 2022-08-24 20:55:38 +02:00
539d3ff754 Feature/add asset sub class filter (#1188)
* Add asset sub class filter

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

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

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

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

* Add support for translated labels

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

* Add translations (german)

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

* Update changelog
2022-08-20 10:22:02 +02:00
57a14bd945 automate database setup and upgrade (#1163)
* Automate database setup and schema upgrade
2022-08-20 08:54:50 +02:00
4fd0622114 Fix build:dev script (#1171) 2022-08-19 20:39:21 +02:00
152 changed files with 5599 additions and 4312 deletions

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

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

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

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

View File

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

View File

@ -5,6 +5,128 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.190.0 - 10.09.2022
### Added
- Added the date range component to the benchmark comparator
### Changed
- Improved the mobile layout of the benchmark comparator
- Migrated the date range setting from the locale storage to the user settings
- Refactored the `currency` and `view mode` in the user settings
## 1.189.0 - 08.09.2022
### Changed
- Distinguished between currency and unit in the chart tooltip
### Fixed
- Fixed the benchmark chart in the benchmark comparator (experimental)
## 1.188.0 - 06.09.2022
### Added
- Added a benchmark comparator (experimental)
### Fixed
- Improved the asset profile details dialog for assets without a (first) activity in the admin control panel
## 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

View File

@ -1,7 +1,6 @@
FROM node:16-alpine as builder
FROM --platform=$BUILDPLATFORM node:16-slim as builder
# Build application and add additional files
WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding
@ -10,9 +9,16 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
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
# 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
# Image to run, copy everything needed from builder
FROM node:16-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
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]
CMD [ "yarn", "start:prod" ]

View File

@ -12,13 +12,11 @@
<strong>Open Source Wealth Management Software</strong>
</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/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<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>
<a href="#contributing">
<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">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p>
@ -33,7 +31,7 @@
## 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.
@ -81,6 +79,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## 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 |
@ -94,9 +94,9 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
| `REDIS_PORT` | | The port where _Redis_ is running |
### Run with Docker Compose
@ -114,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
```
##### 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
Run the following commands to build and start the Docker images:
@ -131,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
```
##### 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
Open http://localhost:3333 in your browser and accomplish these steps:
@ -150,8 +134,8 @@ Open http://localhost:3333 in your browser and accomplish these steps:
#### Upgrade Version
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. 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`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community)
@ -275,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.
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

View File

@ -128,6 +128,10 @@
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
@ -170,6 +174,9 @@
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},

View File

@ -1,3 +1,4 @@
/* eslint-disable */
export default {
displayName: 'api',

View File

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

View File

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

View File

@ -8,7 +8,8 @@ import {
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,6 +23,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -226,7 +228,9 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
): Promise<AdminMarketData> {
if (
!hasPermission(
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')

View File

@ -11,11 +11,13 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable()
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({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
@ -84,17 +99,24 @@ export class AdminService {
return {
dataSource,
marketDataItemCount,
symbol
symbol,
countriesCount: 0,
sectorsCount: 0
};
});
}
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
countries: true,
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
@ -102,10 +124,14 @@ export class AdminService {
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
})
).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
@ -113,10 +139,17 @@ export class AdminService {
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return {
countriesCount,
marketDataItemCount,
sectorsCount,
activityCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol

View File

@ -54,7 +54,7 @@ export class WebAuthService {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {

View File

@ -1,30 +1,48 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import {
BenchmarkMarketDataDetails,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import {
Controller,
Get,
Param,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
public constructor(private readonly benchmarkService: BenchmarkService) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
}

View File

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
],
providers: [BenchmarkService]

View File

@ -0,0 +1,15 @@
import { BenchmarkService } from './benchmark.service';
describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService;
beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
});
it('calculateChangeInPercentage', async () => {
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
});
});

View File

@ -1,10 +1,21 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
BenchmarkMarketDataDetails,
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
import { format } from 'date-fns';
import ms from 'ms';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class BenchmarkService {
@ -13,25 +24,37 @@ export class BenchmarkService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
return new Big(currentValue).div(baseValue).minus(1).toNumber();
}
public async getBenchmarks({ useCache = true } = {}): Promise<
BenchmarkResponse['benchmarks']
> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (useCache) {
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
if (benchmarks) {
return benchmarks;
}
} catch {}
}
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
@ -46,11 +69,16 @@ export class BenchmarkService {
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const { marketPrice } = quotes[benchmarkAssets[index].symbol] ?? {};
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh,
marketPrice
);
}
return {
marketCondition: this.getMarketCondition(
@ -64,7 +92,7 @@ export class BenchmarkService {
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
performancePercent: performancePercentFromAllTimeHigh
}
}
};
@ -72,13 +100,82 @@ export class BenchmarkService {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
JSON.stringify(benchmarks),
ms('4 hours') / 1000
);
return benchmarks;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
public async getBenchmarkAssetProfiles(): Promise<UniqueAsset[]> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
benchmarkAssets
);
return assetProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async getMarketDataBySymbol({
dataSource,
startDate,
symbol
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
gte: startDate
}
}
})
]);
marketDataItems.push({
...currentSymbolItem,
createdAt: new Date(),
date: new Date(),
id: uuidv4()
});
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
return {
marketData: marketDataItems.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
})
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

View File

@ -1,5 +1,6 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { InfoService } from './info.service';
@ -8,6 +9,7 @@ export class InfoController {
public constructor(private readonly infoService: InfoService) {}
@Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> {
return this.infoService.get();
}

View File

@ -1,3 +1,4 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -16,6 +17,7 @@ import { InfoService } from './info.service';
@Module({
controllers: [InfoController],
imports: [
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -1,6 +1,6 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -13,7 +13,10 @@ import {
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -21,6 +24,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { subDays } from 'date-fns';
@Injectable()
@ -28,9 +32,9 @@ export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -106,6 +110,7 @@ export class InfoService {
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
statistics: await this.getStatistics(),
@ -143,17 +148,21 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
'https://github.com/ghostfolio/ghostfolio',
'GET',
'json',
'string',
200,
{
'User-Agent': 'request'
}
{}
);
const contributors = await get();
return contributors?.length;
const html = await get();
const $ = cheerio.load(html);
return extractNumberFromString(
$(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
).text()
);
} catch (error) {
Logger.error(error, 'InfoService');

View File

@ -16,6 +16,7 @@ import {
isBefore,
isSameMonth,
isSameYear,
isWithinInterval,
max,
min,
set
@ -167,13 +168,21 @@ export class PortfolioCalculator {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
public async getCurrentPositions(
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 {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
@ -182,39 +191,38 @@ export class PortfolioCalculator {
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
let firstIndex = transactionPointsBeforeEndDate.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstTransactionPoint = transactionPointsBeforeEndDate[i];
firstIndex = i;
}
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({
currencies,
@ -241,7 +249,7 @@ export class PortfolioCalculator {
}
}
const todayString = format(today, DATE_FORMAT);
const endDateString = format(end, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
@ -254,7 +262,7 @@ export class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
const {
grossPerformance,
@ -264,6 +272,7 @@ export class PortfolioCalculator {
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
symbol: item.symbol
@ -432,30 +441,36 @@ export class PortfolioCalculator {
}
}
let minNetPerformance = new Big(0);
let maxNetPerformance = new Big(0);
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
try {
minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
} catch {}
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
@ -694,10 +709,12 @@ export class PortfolioCalculator {
}
private getSymbolMetrics({
end,
marketSymbolMap,
start,
symbol
}: {
end: Date;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
@ -720,13 +737,12 @@ export class PortfolioCalculator {
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
@ -779,7 +795,7 @@ export class PortfolioCalculator {
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
date: format(end, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',

View File

@ -35,7 +35,8 @@ import {
Param,
Query,
UseGuards,
UseInterceptors
UseInterceptors,
Version
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -349,6 +370,7 @@ export class PortfolioController {
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
alias: access.alias,
holdings: {}
};

View File

@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
@ -35,7 +34,8 @@ import {
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition
TimelinePosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
@ -57,6 +57,7 @@ import {
} from '@prisma/client';
import Big from 'big.js';
import {
addDays,
differenceInDays,
endOfToday,
format,
@ -71,7 +72,7 @@ import {
subDays,
subYears
} from 'date-fns';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
@ -85,6 +86,7 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable()
export class PortfolioService {
private static readonly MAX_CHART_ITEMS = 250;
private baseCurrency: string;
public constructor(
@ -327,10 +329,10 @@ export class PortfolioService {
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance
lastItem?.netPerformance ?? 0
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance
lastItem?.netPerformance ?? 0
);
if (isAllTimeHigh && isAllTimeLow) {
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(
aImpersonationId: string,
aUserId: string,
@ -466,7 +540,9 @@ export class PortfolioService {
holdings[item.symbol] = {
markets,
allocationCurrent: value.div(totalValue).toNumber(),
allocationCurrent: totalValue.eq(0)
? 0
: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
@ -478,7 +554,7 @@ export class PortfolioService {
item.grossPerformancePercentage?.toNumber() ?? 0,
investment: item.investment.toNumber(),
marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState,
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
@ -969,7 +1045,7 @@ export class PortfolioService {
accounts
)
],
{ baseCurrency: currency }
<UserSettings>this.request.user.Settings.settings
),
currencyClusterRisk: await this.rulesService.evaluate(
[
@ -990,7 +1066,7 @@ export class PortfolioService {
currentPositions
)
],
{ baseCurrency: currency }
<UserSettings>this.request.user.Settings.settings
),
fees: await this.rulesService.evaluate(
[
@ -1000,7 +1076,7 @@ export class PortfolioService {
this.getFees(orders).toNumber()
)
],
{ baseCurrency: currency }
<UserSettings>this.request.user.Settings.settings
)
}
};

View File

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
@ -8,7 +9,7 @@ export class RulesService {
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: { baseCurrency: string }
aUserSettings: UserSettings
) {
return aRules
.filter((rule) => {

View File

@ -1,7 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
import { RedisCacheService } from './redis-cache.service';
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configurationService: ConfigurationService) => ({
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
})
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
return <CacheManagerOptions>{
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
};
}
}),
ConfigurationModule
],

View File

@ -1,7 +0,0 @@
import { ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: string;
userId: string;
viewMode?: ViewMode;
}

View File

@ -1,5 +0,0 @@
export interface UserSettings {
emergencyFund?: number;
locale?: string;
isRestrictedView?: boolean;
}

View File

@ -1,14 +1,44 @@
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { DateRange } from '@ghostfolio/common/types';
import { ViewMode } from '@prisma/client';
import {
IsBoolean,
IsIn,
IsNumber,
IsObject,
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserSettingDto {
@IsOptional()
@IsString()
baseCurrency?: string;
@IsObject()
@IsOptional()
benchmark?: UniqueAsset;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
@IsOptional()
dateRange?: DateRange;
@IsNumber()
@IsOptional()
emergencyFund?: number;
@IsBoolean()
@IsOptional()
isExperimentalFeatures?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean;
@IsString()
@IsOptional()
language?: string;
@IsString()
@IsOptional()
locale?: string;
@ -16,4 +46,8 @@ export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
savingsRate?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional()
viewMode?: ViewMode;
}

View File

@ -1,10 +0,0 @@
import { ViewMode } from '@prisma/client';
import { IsString } from 'class-validator';
export class UpdateUserSettingsDto {
@IsString()
baseCurrency: string;
@IsString()
viewMode: ViewMode;
}

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -24,10 +24,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';
@Controller('user')
@ -130,33 +127,4 @@ export class UserController {
userId: this.request.user.id
});
}
@Put('settings')
@UseGuards(AuthGuard('jwt'))
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if (
!hasPermission(
this.request.user.permissions,
permissions.updateUserSettings
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id
};
if (
hasPermission(this.request.user.permissions, permissions.updateViewMode)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
}

View File

@ -4,19 +4,20 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import {
User as IUser,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
const crypto = require('crypto');
@Injectable()
@ -43,7 +44,7 @@ export class UserService {
include: {
User: true
},
orderBy: { User: { alias: 'asc' } },
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
});
let tags = await this.tagService.getByUser(id);
@ -62,16 +63,14 @@ export class UserService {
tags,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
alias: accessItem.alias,
id: accessItem.id
};
}),
accounts: Account,
settings: {
...(<UserSettings>Settings.settings),
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
...(<UserSettings>(<unknown>Settings.settings)),
locale: (<UserSettings>(<unknown>Settings.settings))?.locale ?? aLocale
}
};
}
@ -89,7 +88,10 @@ export class UserService {
}
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
return (
(aUser.Settings.settings as unknown as UserSettings)?.isRestrictedView ??
false
);
}
public async user(
@ -98,7 +100,6 @@ export class UserService {
const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
@ -116,7 +117,6 @@ export class UserService {
const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
@ -128,21 +128,37 @@ export class UserService {
};
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
if (!user.Settings.settings) {
user.Settings.settings = {};
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
currency: null,
settings: {},
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
viewMode: 'DEFAULT'
};
}
// Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency =
UserService.DEFAULT_CURRENCY;
}
// Set default value for date range
(user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
@ -297,7 +313,7 @@ export class UserService {
userId: string;
userSettings: UserSettings;
}) {
const settings = userSettings as Prisma.JsonObject;
const settings = userSettings as unknown as Prisma.JsonObject;
await this.prismaService.settings.upsert({
create: {
@ -319,33 +335,6 @@ export class UserService {
return;
}
public async updateUserSettings({
currency,
userId,
viewMode
}: UserSettingsParams) {
await this.prismaService.settings.upsert({
create: {
currency,
User: {
connect: {
id: userId
}
},
viewMode
},
update: {
currency,
viewMode
},
where: {
userId: userId
}
});
return;
}
private getRandomString(length: number) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];

View File

@ -5,6 +5,7 @@ import {
Injectable,
NestInterceptor
} from '@nestjs/common';
import { isArray } from 'lodash';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -36,6 +37,13 @@ export class TransformDataSourceInResponseInterceptor<T>
});
}
if (isArray(data.benchmarks)) {
data.benchmarks.map((benchmark) => {
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
return benchmark;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,3 +0,0 @@
export interface UserSettings {
baseCurrency: string;
}

View File

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,9 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition
PortfolioPosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,9 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition
PortfolioPosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,7 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -17,7 +17,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
imports: [
BullModule.registerQueue({
limiter: {
duration: ms('5 seconds'),
duration: ms('4 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE

View File

@ -6,7 +6,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.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 { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -16,8 +20,6 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
@ -77,7 +79,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const html = await get();
const $ = cheerio.load(html);
const value = this.extractNumberFromString($(selector).text());
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
@ -175,15 +177,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return { items };
}
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(
GhostfolioScraperApiService.NUMERIC_REGEXP
);
return parseFloat(numberString.trim());
} catch {
return undefined;
}
}
}

View File

@ -1,13 +1,12 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common';
@Module({
exports: [TwitterBotService],
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
imports: [BenchmarkModule, ConfigurationModule, SymbolModule],
providers: [TwitterBotService]
})
export class TwitterBotModule {}

View File

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

View File

@ -1,3 +1,4 @@
/* eslint-disable */
export default {
displayName: 'client',

View File

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

View File

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

View File

@ -1,8 +1,15 @@
<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>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }}
{{ element.grantee }}
</td>
</ng-container>
@ -43,8 +50,8 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
Revoke
<button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container>
</button>
</mat-menu>
</td>

View File

@ -33,7 +33,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['granteeAlias', 'type', 'details'];
this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -21,18 +21,10 @@
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
<gf-value size="medium" [value]="platformName">Platform</gf-value>
</div>
</div>

View File

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

View File

@ -24,7 +24,7 @@
<table class="gf-table w-100">
<thead>
<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>Symbol</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>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onViewData(job.data)">
View Data
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
i18n
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
View Stacktrace
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
Delete Job
<button mat-menu-item (click)="onDeleteJob(job.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>

View File

@ -14,8 +14,7 @@ import {
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import {
addDays,

View File

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

View File

@ -3,17 +3,27 @@ import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
OnInit,
ViewChild
} 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 { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
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 { 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({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html'
})
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 currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
new MatTableDataSource();
public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = [];
public deviceType: string;
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;
private unsubscribeSubject = new Subject<void>();
@ -35,8 +80,28 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
params['dataSource'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
dateOfFirstActivity: params['dateOfFirstActivity'],
symbol: params['symbol']
});
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -51,7 +116,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}
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) {
@ -75,28 +164,23 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {});
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol({
dataSource: this.currentDataSource,
symbol: this.currentSymbol
});
}
}
public onOpenAssetProfileDialog({
dataSource,
dateOfFirstActivity,
symbol
}: UniqueAsset & { dateOfFirstActivity: string }) {
try {
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
} catch {}
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 });
}
this.router.navigate([], {
queryParams: {
dateOfFirstActivity,
dataSource,
symbol,
assetProfileDialog: true
}
});
}
public ngOnDestroy() {
@ -104,25 +188,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchAdminMarketData() {
this.dataService
.fetchAdminMarketData()
private openAssetProfileDialog({
dataSource,
dateOfFirstActivity,
symbol
}: {
dataSource: DataSource;
dateOfFirstActivity: string;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketData = marketData;
.subscribe((user) => {
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) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

View File

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

View File

@ -2,17 +2,23 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
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 { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
@NgModule({
declarations: [AdminMarketDataComponent],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
GfActivitiesFilterModule,
GfAssetProfileDialogModule,
MatButtonModule,
MatMenuModule
MatMenuModule,
MatSortModule,
MatTableModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
<div class="w-50">{{ userCount }}</div>
</div>
<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">
<ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number
@ -17,7 +17,7 @@
</div>
</div>
<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="overflow-hidden">
<div class="mb-2">

View File

@ -14,8 +14,8 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent],
exports: [],
imports: [
FormsModule,
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatCardModule,

View File

@ -7,17 +7,17 @@
<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" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Registration
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Accounts
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Accounts</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Activities
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement per Day
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Engagement per Day</ng-container>
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th class="mat-header-cell px-1 py-2"></th>

View File

@ -0,0 +1,52 @@
<div class="row">
<div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
<span i18n>Benchmarks</span>
<sup i18n>Beta</sup>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
</div>
<div class="col-md-6 col-xs-12 d-flex justify-content-end">
<mat-form-field appearance="outline" class="w-100" color="accent">
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
[compareWith]="compareUniqueAssets"
[value]="benchmark"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option
*ngFor="let currentBenchmark of benchmarks"
[value]="currentBenchmark"
>{{ currentBenchmark.symbol }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoading"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>
</div>

View File

@ -0,0 +1,11 @@
:host {
display: block;
.chart-container {
aspect-ratio: 16 / 9;
ngx-skeleton-loader {
height: 100%;
}
}
}

View File

@ -0,0 +1,237 @@
import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate
} from '@ghostfolio/common/helper';
import {
LineChartItem,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import {
Chart,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
@Component({
selector: 'gf-benchmark-comparator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './benchmark-comparator.component.html',
styleUrls: ['./benchmark-comparator.component.scss']
})
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmark: UniqueAsset;
@Input() benchmarks: UniqueAsset[];
@Input() daysInMarket: number;
@Input() locale: string;
@Input() performanceDataItems: LineChartItem[];
@Input() user: User;
@Output() benchmarkChanged = new EventEmitter<UniqueAsset>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public isLoading = true;
public constructor() {
Chart.register(
annotationPlugin,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
getTooltipPositionerMapTop(this.chart, position);
}
public ngOnChanges() {
if (this.performanceDataItems) {
this.initialize();
}
}
public compareUniqueAssets(
uniqueAsset1: UniqueAsset,
uniqueAsset2: UniqueAsset
) {
return (
uniqueAsset1?.dataSource === uniqueAsset2?.dataSource &&
uniqueAsset1?.symbol === uniqueAsset2?.symbol
);
}
public onChangeBenchmark(benchmark: UniqueAsset) {
this.benchmarkChanged.next(benchmark);
}
public onChangeDateRange(dateRange: DateRange) {
this.dateRangeChanged.next(dateRange);
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
const data = {
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
}),
label: $localize`Portfolio`
},
{
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
}),
label: $localize`Benchmark`
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(),
hoverRadius: 2,
radius: 0
}
},
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
}
},
responsive: true,
scales: {
x: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year'
}
},
y: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return `${value} %`;
},
display: true,
mirror: true,
z: 1
}
}
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
});
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
locale: this.locale,
unit: '%'
}),
mode: 'index',
position: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
}

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
@NgModule({
declarations: [BenchmarkComparatorComponent],
exports: [BenchmarkComparatorComponent],
imports: [
CommonModule,
FormsModule,
GfToggleModule,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
]
})
export class GfBenchmarkComparatorModule {}

View File

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

View File

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

View File

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

View File

@ -285,17 +285,16 @@
mat-flat-button
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
Sign In
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
<a
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
class="d-none d-sm-block"
color="primary"
i18n
mat-flat-button
[routerLink]="['/register']"
>Get Started
><ng-container i18n>Get started</ng-container>
</a>
</ng-container>
</mat-toolbar>

View File

@ -5,10 +5,6 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +22,6 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
templateUrl: './home-holdings.html'
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public hasImpersonationId: boolean;
@ -44,10 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
route.queryParams
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
@ -73,7 +67,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
this.update();
}
});
}
@ -88,18 +82,25 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId;
});
this.dateRange =
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.update();
}
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() {
@ -151,7 +152,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.positions = undefined;
this.dataService
.fetchPositions({ range: this.dateRange })
.fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;

View File

@ -1,7 +1,7 @@
<div class="container justify-content-center p-3">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
@ -17,7 +17,7 @@
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
[range]="user?.settings?.dateRange"
></gf-positions>
</mat-card-content>
</mat-card>

View File

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

View File

@ -9,18 +9,17 @@
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMaxLabel="Greed"
yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
[yMaxLabel]="greedLabel"
[yMinLabel]="fearLabel"
></gf-line-chart>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index>
</div>
</div>

View File

@ -2,19 +2,15 @@ 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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
LineChartItem,
PortfolioPerformance,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -25,7 +21,6 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-overview.html'
})
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public errors: UniqueAsset[];
@ -47,7 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
this.userService.stateChanged
@ -61,7 +55,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
this.update();
}
});
}
@ -78,11 +72,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dateRange =
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.showDetails =
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
@ -91,10 +80,22 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.update();
}
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() {
@ -106,7 +107,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.isLoadingPerformance = true;
this.dataService
.fetchChart({ range: this.dateRange })
.fetchChart({
range: this.user?.settings?.dateRange,
version: this.user?.settings?.isExperimentalFeatures ? 2 : 1
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
@ -122,7 +126,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
});
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.fetchPortfolioPerformance({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.errors = response.errors;

View File

@ -15,7 +15,7 @@
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[currency]="user?.settings?.isExperimentalFeatures ? undefined : user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
@ -24,6 +24,7 @@
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
[unit]="user?.settings?.isExperimentalFeatures ? '%' : undefined"
></gf-line-chart>
</div>
</div>
@ -45,7 +46,7 @@
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="dateRange"
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"

View File

@ -38,7 +38,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
this.update();
}
});
}
@ -59,7 +59,16 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
.putUserSetting({ emergencyFund })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.update();
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}

View File

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

View File

@ -57,6 +57,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public chart: Chart;
public isLoading = true;
private data: InvestmentItem[];
public constructor() {
Chart.register(
annotationPlugin,
@ -87,10 +89,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
this.isLoading = true;
if (!this.groupBy && this.investments?.length > 0) {
// Create a clone
this.data = this.investments.map((a) => Object.assign({}, a));
if (!this.groupBy && this.data?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.investments[0];
this.investments.unshift({
const firstItem = this.data[0];
this.data.unshift({
...firstItem,
date: subDays(
parseISO(firstItem.date),
@ -100,8 +105,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
});
// Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1];
this.investments.push({
const lastItem = this.data[this.data.length - 1];
this.data.push({
...lastItem,
date: addDays(
parseDate(lastItem.date),
@ -111,7 +116,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
const data = {
labels: this.investments.map((investmentItem) => {
labels: this.data.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
@ -119,10 +124,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => {
return position.investment;
data: this.data.map((position) => {
return this.isInPercent
? position.investment * 100
: position.investment;
}),
label: 'Investment',
label: $localize`Deposit`,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
@ -249,10 +256,11 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions(
this.isInPercent ? undefined : this.currency,
this.isInPercent ? undefined : this.locale
),
...getTooltipOptions({
currency: this.isInPercent ? undefined : this.currency,
locale: this.isInPercent ? undefined : this.locale,
unit: this.isInPercent ? '%' : undefined
}),
mode: 'index',
position: <unknown>'top',
xAlign: 'center',

View File

@ -49,12 +49,11 @@
<div>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
>
Sign in
<ng-container i18n>Sign in</ng-container>
</button>
</div>
</div>

View File

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

View File

@ -9,9 +9,11 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import {
EnhancedSymbolProfile,
LineChartItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';

View File

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

View File

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

View File

@ -72,7 +72,13 @@ export class AuthGuard implements CanActivate {
})
)
.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') &&
user.settings.viewMode === ViewMode.ZEN
) {

View File

@ -56,7 +56,9 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (!this.snackBarRef) {
if (this.info.isReadOnlyMode) {
this.snackBarRef = this.snackBar.open(
$localize`This feature is currently unavailable. Please try again later.`,
$localize`This feature is currently unavailable.` +
' ' +
$localize`Please try again later.`,
undefined,
{ duration: 6000 }
);
@ -81,7 +83,9 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
$localize`Oops! Something went wrong. Please try again later.`,
$localize`Oops! Something went wrong.` +
' ' +
$localize`Please try again later.`,
$localize`Okay`,
{ duration: 6000 }
);

View File

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

View File

@ -53,6 +53,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang;
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number;
public priceId: string;
@ -162,29 +163,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue };
this.dataService
.putUserSettings({
baseCurrency: settings?.baseCurrency,
viewMode: settings?.viewMode
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
if (aKey === 'language') {
if (aValue) {
window.location.href = `../${aValue}/account`;
} else {
window.location.href = `../`;
}
}
});
});
}
@ -217,6 +203,24 @@ 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() {
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
@ -240,7 +244,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(
'✅' + $localize`Coupon code has been redeemed`,
'✅ ' + $localize`Coupon code has been redeemed`,
$localize`Reload`,
{
duration: 3000
@ -307,6 +311,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {
alias: '',
type: 'PUBLIC'
}
},
@ -322,7 +327,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (access) {
this.dataService
.postAccess({})
.postAccess({ alias: access.alias })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {

View File

@ -31,11 +31,10 @@
<ng-container *ngIf="hasPermissionForSubscription">
<button
color="primary"
i18n
mat-flat-button
(click)="onCheckout(priceId)"
>
Upgrade
<ng-container i18n>Upgrade</ng-container>
</button>
<div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon"
@ -91,8 +90,8 @@
<div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
Base Currency
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
@ -100,7 +99,7 @@
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
@ -111,11 +110,31 @@
</mat-form-field>
</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="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted" i18n>
Date and number format
<div class="hint-text text-muted">
<ng-container i18n>Date and number format</ng-container>
</div>
</div>
<div class="pl-1 w-50">
@ -137,8 +156,8 @@
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>View Mode</ng-container>
</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden">
@ -147,7 +166,7 @@
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
(selectionChange)="onChangeUserSetting('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
@ -169,6 +188,22 @@
></mat-slide-toggle>
</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="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div>

View File

@ -1,6 +1,17 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Grant access</h1>
<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>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
@ -14,12 +25,11 @@
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
>
Save
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
@ -16,6 +17,7 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.com
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
]

View File

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

View File

@ -84,7 +84,7 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
href="'../en/blog/2022/01/ghostfolio-first-months-in-open-source"
href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">

View File

@ -1,36 +1,34 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Frequently Asked Questions (FAQ)</h3>
<h3 class="mb-3 text-center">Frequently Asked Questions (FAQ)</h3>
<mat-card class="mb-3">
<mat-card-title i18n>What is Ghostfolio?</mat-card-title>
<mat-card-content i18n>
<mat-card-title>What is Ghostfolio?</mat-card-title>
<mat-card-content>
Ghostfolio is a lightweight, open source wealth management application
for individuals to keep track of their net worth. The software
empowers you to make solid, data-driven investment decisions.
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n
<mat-card-title
>What assets can I track with Ghostfolio?</mat-card-title
>
<mat-card-content i18n>
<mat-card-content>
With Ghostfolio, you can keep track of various assets like stocks,
ETFs or cryptocurrencies.
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n
>What else is included in Ghostfolio?</mat-card-title
>
<mat-card-content i18n>
<mat-card-title>What else is included in Ghostfolio?</mat-card-title>
<mat-card-content>
Please find a feature overview to manage your wealth
<a [routerLink]="['/features']">here</a>.
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>How do I start?</mat-card-title>
<mat-card-content i18n>
<mat-card-title>How do I start?</mat-card-title>
<mat-card-content>
You can sign up via the “<a [routerLink]="['/register']"
>Get Started</a
>” button at the top of the page. You have multiple options to join
@ -42,8 +40,8 @@
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>Can I use Ghostfolio anonymously?</mat-card-title>
<mat-card-content i18n>
<mat-card-title>Can I use Ghostfolio anonymously?</mat-card-title>
<mat-card-content>
Yes, the authentication systems (via security token or
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>Internet Identity</a
@ -52,8 +50,8 @@
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>How can Ghostfolio be free?</mat-card-title>
<mat-card-content i18n
<mat-card-title>How can Ghostfolio be free?</mat-card-title>
<mat-card-content
>This project is driven by the efforts of contributors from around the
world. The
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
@ -64,16 +62,16 @@
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>Is it really free?</mat-card-title>
<mat-card-content i18n
<mat-card-title>Is it really free?</mat-card-title>
<mat-card-content
>Yes, it is! Our
<a [routerLink]="['/pricing']">pricing page</a> details everything you
get for free.</mat-card-content
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>What is Ghostfolio Premium?</mat-card-title>
<mat-card-content i18n
<mat-card-title>What is Ghostfolio Premium?</mat-card-title>
<mat-card-content
><a [routerLink]="['/pricing']">Ghostfolio Premium</a> is a fully
managed Ghostfolio cloud offering for ambitious investors. The revenue
is used to cover the hosting infrastructure. It is the Open Source
@ -81,8 +79,8 @@
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>Can I start with a trial version?</mat-card-title>
<mat-card-content i18n
<mat-card-title>Can I start with a trial version?</mat-card-title>
<mat-card-content
>Yes, you can try
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> by signing up
for Ghostfolio and applying for a trial (see “My Ghostfolio”). Its
@ -91,8 +89,8 @@
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>Which devices are supported?</mat-card-title>
<mat-card-content i18n
<mat-card-title>Which devices are supported?</mat-card-title>
<mat-card-content
>Ghostfolio works in every modern web browser on smartphones, tablets
and desktop computers (where you have even more analysis options and
statistics). For Android users, there is a dedicated Ghostfolio app
@ -104,10 +102,10 @@
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n
<mat-card-title
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
>
<mat-card-content i18n
<mat-card-content
>Any support for Ghostfolio is welcome. Be it with a
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> subscription to
finance the hosting, a positive rating in the
@ -124,8 +122,8 @@
>
</mat-card>
<mat-card class="mb-3">
<mat-card-title i18n>Got any other questions?</mat-card-title>
<mat-card-content i18n
<mat-card-title>Got any other questions?</mat-card-title>
<mat-card-content
>Join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"

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