Compare commits

..

43 Commits

Author SHA1 Message Date
6623bc0113 Release 1.9.0 (#136) 2021-06-01 21:44:46 +02:00
146b5201b5 Feature/make x ray rules order consistent (#134)
* Make order of X-ray rules consistent

* Update changelog
2021-06-01 21:40:32 +02:00
b021fbde59 Feature/refactor to format distance to now strict (#133)
* Change from formatDistanceToNow to formatDistanceToNowStrict

* Update changelog
2021-06-01 21:38:55 +02:00
ec046b81a7 Fix style (#132) 2021-06-01 21:35:47 +02:00
aea497154a Feature/prettify symbols in transaction filtering component (#131)
* Prettify generic scraper symbols

* Update changelog
2021-06-01 21:34:53 +02:00
dc736d53b4 Fix sorting (#129)
* Fix sorting
2021-06-01 21:33:56 +02:00
5957b33779 Feature/enable labels on the x axis of the investment chart (#128)
* Enable x-axis labels

* Update changelog
2021-05-30 20:39:37 +02:00
bafdce56ad add yarn build:all to .travis.yml (#127) 2021-05-27 22:32:10 +02:00
42a2d404e4 Fix type errors (#126) 2021-05-27 21:12:55 +02:00
11b2379d98 Feature/respect data source in data gathering (#107)
* Respect data source in data gathering

* Update changelog

* optimize fetching from multiple data sources (#123)

* optimize fetching from multiple data sources

* improve performance by executing data gathering promises in parallel

* removed unused imports

* rename hasHistoricalData to canHandle

* Sort imports

* Clean up

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-27 20:50:10 +02:00
c0657a2e9e Extend README.md (#124)
* Add contributions welcome badge
* Add features
* State technology stack precisely
2021-05-24 21:11:06 +02:00
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
f5bd6b0d58 Release 1.5.0 (#111) 2021-05-22 10:11:01 +02:00
78a4946e8b Feature/zen mode (#110)
* Start with implementation
* Refactor AuthGuard, persist displayMode in user settings
* Refactor DisplayMode to ViewMode
* Update changelog
2021-05-22 10:04:56 +02:00
702ee956a2 Release 1.4.0 (#108) 2021-05-20 20:43:48 +02:00
200a7d2d65 Feature/refactor search functionality (#105)
* Refactor search functionality

* Update changelog

* Improvements after code review
2021-05-20 20:36:08 +02:00
79edc09710 Store utm_source in local storage (#106) 2021-05-19 20:36:44 +02:00
77255df4be Feature/disable base currency selector for demo user (#104)
* Disable base currency selector based on permission

* Update changelog
2021-05-18 19:36:24 +02:00
277133fa1a Clean up services (#103)
* LanguageService
* TokenStorageService
2021-05-17 19:55:10 +02:00
abd0e08566 Introduce @ghostfolio/common lib (#102) 2021-05-16 22:11:14 +02:00
561d8dbc70 Feature/rename account link (#101)
* Rename account link

* Update changelog
2021-05-16 21:22:03 +02:00
c973ffd3ba Feature/reorganize helper lib (#100)
Reorganize helper lib (Move interfaces and types)
* InfoItem
* PortfolioItem
* PortfolioOverview
* PortfolioPerformance
* Position
* PortfolioPosition
* PortfolioReport
* PortfolioReportRule
* User
* UserSettings
* DateRange
* AdminData
* AccessWithGranteeUser
* OrderWithAccount
* Granularity
* UserWithSettings
* RequestWithUser
2021-05-16 21:20:59 +02:00
368de7dedc Extend unit tests (#99) 2021-05-16 21:19:14 +02:00
e56514629f Refactor postgres variables (#98)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:49 +02:00
7a8a25c4c0 Feature/filter by year in transaction table (#97)
* Filter by year

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:28 +02:00
5d36d3a6bb remove database dependencies for test execution (#96)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-15 18:57:27 +02:00
0ef35fd31f Feature/hide unknown exchange (#95)
* Hide unknown exchange

* Update changelog
2021-05-15 17:50:28 +02:00
c1c22c195d Release 1.3.0 (#94) 2021-05-15 10:15:29 +02:00
111d8d8e3c Feature/rename share to allocation in columns of positions table (#93)
* Rename columns

* Initial Share -> Initial Allocation
* Current Share -> Current Allocation

* Update changelog
2021-05-15 10:12:12 +02:00
b0a24e4fc0 Desaturate background color (#92) 2021-05-15 10:09:07 +02:00
694b9b8991 Feature/refactor active menu item state (#91)
* Refactor active menu item state

* Update changelog
2021-05-15 10:05:03 +02:00
fada347aa5 Fix pricing page link (#90) 2021-05-15 08:36:13 +02:00
160 changed files with 1694 additions and 1068 deletions

4
.env
View File

@ -5,12 +5,12 @@ REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
# POSTGRES # POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456 JWT_SECRET_KEY=123456
PORT=3333 PORT=3333

View File

@ -7,3 +7,5 @@ before_script:
- yarn - yarn
script: script:
- yarn format:check - yarn format:check
- yarn test
- yarn build:all

View File

@ -5,6 +5,93 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.9.0 - 01.06.2021
### Added
- Added the year labels to the investment chart on the x-axis
### Changed
- Respected the data source attribute of the transactions model in the data management for historical data
- Prettified the generic scraper symbols in the transaction filtering component
- Changed to the strict mode of distance formatting between two given dates
### Fixed
- Fixed the sorting in various tables
- Made the order of the rules in the _X-ray_ section consistent
## 1.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the user table of the admin control panel
### Changed
- Improved the alignment in the user table of the admin control panel
## 1.5.0 - 22.05.2021
### Added
- Added _Zen Mode_: the distraction-free view
## 1.4.0 - 20.05.2021
### Added
- Added filtering by year in the transaction filtering component
### Changed
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
- Hid unknown exchange in the position overview
- Disable the base currency selector for the demo user
- Refactored the portfolio unit tests to work without database
- Refactored the search functionality of the data management (aligned with data source)
- Renamed shared helper to `@ghostfolio/common/helper`
- Moved shared interfaces to `@ghostfolio/common/interfaces`
- Moved shared types to `@ghostfolio/common/types`
## 1.3.0 - 15.05.2021
### Changed
- Refactored the active menu item state by parsing the current url
- Used a desaturated background color for unknown types in pie charts
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
### Fixed
- Fixed the link to the pricing page
## 1.2.1 - 14.05.2021 ## 1.2.1 - 14.05.2021
### Changed ### Changed

View File

@ -7,16 +7,15 @@
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p> </p>
<p> <p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow"> <a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/> <img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
</a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</a>
</p> </p>
</div> </div>
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules. **Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
## Why Ghostfolio? ## Why Ghostfolio?
@ -43,10 +42,13 @@ Ghostfolio is for you if you are...
## Features ## Features
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`) - ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode - ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
## Technology Stack ## Technology Stack
@ -54,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
### Backend ### Backend
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching. The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
### Frontend ### Frontend
The frontend is built with [Angular](https://angular.io). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started ## Getting Started

View File

@ -208,22 +208,22 @@
} }
} }
}, },
"helper": { "common": {
"root": "libs/helper", "root": "libs/common",
"sourceRoot": "libs/helper/src", "sourceRoot": "libs/common/src",
"projectType": "library", "projectType": "library",
"architect": { "architect": {
"lint": { "lint": {
"builder": "@nrwl/linter:eslint", "builder": "@nrwl/linter:eslint",
"options": { "options": {
"lintFilePatterns": ["libs/helper/**/*.ts"] "lintFilePatterns": ["libs/common/**/*.ts"]
} }
}, },
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/helper"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/helper/jest.config.js", "jestConfig": "libs/common/jest.config.js",
"passWithNoTests": true "passWithNoTests": true
} }
} }

View File

@ -1,10 +1,10 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { Access } from './interfaces/access.interface';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {

View File

@ -1,9 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
public constructor(private prisma: PrismaService) {} public constructor(private prisma: PrismaService) {}

View File

@ -1,7 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -17,7 +21,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel, Order } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';

View File

@ -1,6 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import { AdminData } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -14,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { AdminData } from './interfaces/admin-data.interface';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {

View File

@ -1,10 +1,9 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { AdminData } from './interfaces/admin-data.interface';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
@ -109,7 +108,7 @@ export class AdminService {
createdAt: true, createdAt: true,
id: true id: true
}, },
take: 20, take: 30,
where: { where: {
NOT: { NOT: {
Analytics: null Analytics: null

View File

@ -1,5 +1,5 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,9 +1,6 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
baseCurrency, import { RequestWithUser } from '@ghostfolio/common/types';
benchmarks,
isApiTokenAuthorized
} from '@ghostfolio/helper';
import { import {
Body, Body,
Controller, Controller,
@ -40,7 +37,9 @@ export class ExperimentalController {
); );
} }
return benchmarks; return benchmarks.map(({ symbol }) => {
return symbol;
});
} }
@Get('benchmarks/:symbol') @Get('benchmarks/:symbol')

View File

@ -3,11 +3,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider.serv
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client'; import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { OrderWithAccount } from '../order/interfaces/order-with-account.type';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface'; import { Data } from './interfaces/data.interface';

View File

@ -1,7 +1,7 @@
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { InfoService } from './info.service'; import { InfoService } from './info.service';
import { InfoItem } from './interfaces/info-item.interface';
@Controller('info') @Controller('info')
export class InfoController { export class InfoController {

View File

@ -1,12 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { permissions } from '@ghostfolio/helper'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { InfoItem } from './interfaces/info-item.interface';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';

View File

@ -1,7 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,11 +1,11 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithAccount } from './interfaces/order-with-account.type';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -53,6 +53,7 @@ export class OrderService {
// Gather symbol data of order in the background // Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date,
symbol: data.symbol symbol: data.symbol
} }
@ -90,6 +91,7 @@ export class OrderService {
// Gather symbol data of order in the background // Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: <DataSource>data.dataSource,
date: <Date>data.date, date: <Date>data.date,
symbol: <string>data.symbol symbol: <string>data.symbol
} }

View File

@ -4,7 +4,19 @@ import {
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -21,16 +33,10 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express'; import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { RequestWithUser } from '../interfaces/request-with-user.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
import { import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
import { PortfolioReport } from './interfaces/portfolio-report.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -309,18 +315,6 @@ export class PortfolioController {
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
let report = await portfolio.getReport(); return await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
} }
} }

View File

@ -1,12 +1,17 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Portfolio } from '@ghostfolio/api/models/portfolio'; import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import {
PortfolioItem,
PortfolioOverview
} from '@ghostfolio/common/interfaces';
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import { import {
add, add,
format, format,
@ -26,9 +31,6 @@ import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service'; import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { DateRange } from './interfaces/date-range.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
@ -157,8 +159,8 @@ export class PortfolioService {
return { return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'), date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent, grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null, marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value || null value: portfolioItem.value - portfolioItem.investment ?? null
}; };
}); });
} }
@ -288,7 +290,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw( historicalData = await this.dataProviderService.getHistoricalRaw(
[aSymbol], [{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolio.getMinDate(), portfolio.getMinDate(),
new Date() new Date()
); );

View File

@ -1,4 +1,7 @@
import { DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;
} }

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -28,9 +28,12 @@ export class SymbolController {
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> { public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try { try {
return this.symbolService.lookup(query); const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,10 +1,8 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import * as bent from 'bent';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -27,62 +25,30 @@ export class SymbolService {
}; };
} }
public async lookup(aQuery = ''): Promise<LookupItem[]> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const query = aQuery.toLowerCase(); const results: { items: LookupItem[] } = { items: [] };
const results: LookupItem[] = [];
if (!query) { if (!aQuery) {
return results; return results;
} }
const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${query}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(query)) {
results.push({
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
try { try {
const { quotes } = await get(); const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
const searchResult = quotes // Add custom symbols
.filter(({ isYahooFinance }) => { const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
return isYahooFinance; scraperConfigurations.forEach((scraperConfiguration) => {
}) if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
.filter(({ quoteType }) => { results.items.push({
return ( dataSource: DataSource.GHOSTFOLIO,
quoteType === 'CRYPTOCURRENCY' || name: scraperConfiguration.name,
quoteType === 'EQUITY' || symbol: scraperConfiguration.symbol
quoteType === 'ETF' });
); }
}) });
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true; return results;
})
.map(({ longname, shortname, symbol }) => {
return {
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
return results.concat(searchResult);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

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

View File

@ -1,5 +1,10 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { User } from '@ghostfolio/common/interfaces';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -20,7 +25,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { User } from './interfaces/user.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -89,8 +93,9 @@ export class UserController {
} }
return await this.userService.updateUserSettings({ return await this.userService.updateUserSettings({
currency: data.currency, currency: data.baseCurrency,
userId: this.request.user.id userId: this.request.user.id,
viewMode: data.viewMode
}); });
} }
} }

View File

@ -1,18 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import { locale } from '@ghostfolio/common/config';
getPermissions, import { resetHours } from '@ghostfolio/common/helper';
locale, import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
permissions, import { getPermissions, permissions } from '@ghostfolio/common/permissions';
resetHours
} from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns'; import { add } from 'date-fns';
import { UserWithSettings } from '../interfaces/user-with-settings';
import { User as IUser } from './interfaces/user.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
@ -57,8 +52,9 @@ export class UserService {
accounts: Account, accounts: Account,
permissions: currentPermissions, permissions: currentPermissions,
settings: { settings: {
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY, locale,
locale baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT
}, },
subscription: { subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })), expiresAt: resetHours(add(new Date(), { days: 7 })),
@ -85,7 +81,8 @@ export class UserService {
user.Settings = { user.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id userId: user?.id,
viewMode: ViewMode.DEFAULT
}; };
} }
@ -192,10 +189,12 @@ export class UserService {
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId userId,
viewMode
}: { }: {
currency: Currency; currency?: Currency;
userId: string; userId: string;
viewMode?: ViewMode;
}) { }) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
@ -204,10 +203,12 @@ export class UserService {
connect: { connect: {
id: userId id: userId
} }
} },
viewMode
}, },
update: { update: {
currency currency,
viewMode
}, },
where: { where: {
userId: userId userId: userId

View File

@ -1,7 +1,4 @@
import { import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
PortfolioItem,
Position
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { Order } from '../order'; import { Order } from '../order';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,65 +1,102 @@
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper'; import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { Test } from '@nestjs/testing'; import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client'; import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from '../services/configuration.service';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { MarketState } from '../services/interfaces/interfaces'; import { MarketState } from '../services/interfaces/interfaces';
import { PrismaService } from '../services/prisma.service';
import { RulesService } from '../services/rules.service'; import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio'; import { Portfolio } from './portfolio';
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269'; const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237'; const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => { describe('Portfolio', () => {
let alphaVantageService: AlphaVantageService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let ghostfolioScraperApiService: GhostfolioScraperApiService;
let portfolio: Portfolio; let portfolio: Portfolio;
let prismaService: PrismaService;
let rakutenRapidApiService: RakutenRapidApiService;
let rulesService: RulesService; let rulesService: RulesService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => { beforeAll(async () => {
const app = await Test.createTestingModule({ dataProviderService = new DataProviderService(
imports: [], null,
providers: [ null,
AlphaVantageService, null,
ConfigurationService, null,
DataProviderService, null,
ExchangeRateDataService, null
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}).compile();
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
configurationService = app.get<ConfigurationService>(ConfigurationService);
dataProviderService = app.get<DataProviderService>(DataProviderService);
exchangeRateDataService = app.get<ExchangeRateDataService>(
ExchangeRateDataService
); );
ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>( exchangeRateDataService = new ExchangeRateDataService(null);
GhostfolioScraperApiService rulesService = new RulesService();
);
prismaService = app.get<PrismaService>(PrismaService);
rakutenRapidApiService = app.get<RakutenRapidApiService>(
RakutenRapidApiService
);
rulesService = app.get<RulesService>(RulesService);
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -90,7 +127,8 @@ describe('Portfolio', () => {
Settings: { Settings: {
currency: Currency.CHF, currency: Currency.CHF,
updatedAt: new Date(), updatedAt: new Date(),
userId: USER_ID userId: USER_ID,
viewMode: ViewMode.DEFAULT
}, },
thirdPartyId: null, thirdPartyId: null,
updatedAt: new Date() updatedAt: new Date()
@ -170,7 +208,7 @@ describe('Portfolio', () => {
expect(details).toMatchObject({ expect(details).toMatchObject({
BTCUSD: { BTCUSD: {
accounts: { accounts: {
Other: { [UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency( /*current: exchangeRateDataService.toCurrency(
1 * 49631.24, 1 * 49631.24,
Currency.USD, Currency.USD,
@ -183,8 +221,10 @@ describe('Portfolio', () => {
) )
} }
}, },
allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD, currency: Currency.USD,
exchange: 'Other', exchange: UNKNOWN_KEY,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency( investment: exchangeRateDataService.toCurrency(
@ -192,12 +232,10 @@ describe('Portfolio', () => {
Currency.USD, Currency.USD,
baseCurrency baseCurrency
), ),
// marketPrice: 57973.008, marketPrice: 57973.008,
marketState: MarketState.open, marketState: MarketState.open,
name: 'Bitcoin USD', name: 'Bitcoin USD',
quantity: 1, quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD', symbol: 'BTCUSD',
transactionCount: 1, transactionCount: 1,
type: 'Cryptocurrency' type: 'Cryptocurrency'
@ -271,7 +309,7 @@ describe('Portfolio', () => {
expect(details).toMatchObject({ expect(details).toMatchObject({
ETHUSD: { ETHUSD: {
accounts: { accounts: {
Other: { [UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency( /*current: exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -284,8 +322,10 @@ describe('Portfolio', () => {
) )
} }
}, },
// allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD, currency: Currency.USD,
exchange: 'Other', exchange: UNKNOWN_KEY,
// grossPerformance: 0, // grossPerformance: 0,
// grossPerformancePercent: 0, // grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency( investment: exchangeRateDataService.toCurrency(
@ -293,11 +333,9 @@ describe('Portfolio', () => {
Currency.USD, Currency.USD,
baseCurrency baseCurrency
), ),
// marketPrice: 57973.008, marketPrice: 3915.337,
name: 'Ethereum USD', name: 'Ethereum USD',
quantity: 0.2, quantity: 0.2,
// shareCurrent: 1,
shareInvestment: 1,
transactionCount: 1, transactionCount: 1,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: 'Cryptocurrency' type: 'Cryptocurrency'
@ -326,7 +364,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49, investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0, // marketPrice: 3915.337,
quantity: 0.2 quantity: 0.2
} }
}); });
@ -402,7 +440,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050, investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 0, // marketPrice: 3641.984,
quantity: 0.5 quantity: 0.5
} }
}); });
@ -551,8 +589,7 @@ describe('Portfolio', () => {
} }
]); ]);
// TODO: Fix expect(portfolio.getCommittedFunds()).toEqual(
/*expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency( exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -568,7 +605,7 @@ describe('Portfolio', () => {
Currency.USD, Currency.USD,
baseCurrency baseCurrency
) )
);*/ );
expect(portfolio.getFees()).toEqual( expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency) exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
@ -580,12 +617,11 @@ describe('Portfolio', () => {
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2), (0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z', firstBuyDate: '2018-01-05T00:00:00.000Z',
// TODO: Fix investment: exchangeRateDataService.toCurrency(
/*investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050, 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD, Currency.USD,
baseCurrency baseCurrency
),*/ ),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050, investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0, // marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2 quantity: 0.2 - 0.1 + 0.2
@ -595,8 +631,4 @@ describe('Portfolio', () => {
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']); expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
}); });
}); });
afterAll(async () => {
prismaService.$disconnect();
});
}); });

View File

@ -1,8 +1,14 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import { import {
PortfolioItem, PortfolioItem,
Position PortfolioPerformance,
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; PortfolioPosition,
import { getToday, getYesterday, resetHours } from '@ghostfolio/helper'; PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { import {
add, add,
format, format,
@ -22,18 +28,13 @@ import {
import { cloneDeep, isEmpty } from 'lodash'; import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to'; import * as roundTo from 'round-to';
import { UserWithSettings } from '../app/interfaces/user-with-settings';
import { OrderWithAccount } from '../app/order/interfaces/order-with-account.type';
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service'; import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface'; import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order'; import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
@ -227,15 +228,17 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if (accounts[orderOfSymbol.getAccount()?.name || 'Other']?.current) { if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[ accounts[
orderOfSymbol.getAccount()?.name || 'Other' orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol; ].current += currentValueOfSymbol;
accounts[ accounts[
orderOfSymbol.getAccount()?.name || 'Other' orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol; ].original += originalValueOfSymbol;
} else { } else {
accounts[orderOfSymbol.getAccount()?.name || 'Other'] = { accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol, current: currentValueOfSymbol,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
@ -278,6 +281,14 @@ export class Portfolio implements PortfolioInterface {
...data[symbol], ...data[symbol],
accounts, accounts,
symbol, symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
grossPerformance: roundTo( grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before), portfolioItemsNow.positions[symbol].quantity * (now - before),
2 2
@ -285,14 +296,6 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4), grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment, investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity, quantity: portfolioItem.positions[symbol].quantity,
shareCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
shareInvestment:
portfolioItem.positions[symbol].investment / investment,
transactionCount: portfolioItem.positions[symbol].transactionCount transactionCount: portfolioItem.positions[symbol].transactionCount
}; };
}); });
@ -399,10 +402,10 @@ export class Portfolio implements PortfolioInterface {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
this, this,
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService this.exchangeRateDataService
), ),
new AccountClusterRiskInitialInvestment( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService this.exchangeRateDataService
), ),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService) new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
@ -530,12 +533,12 @@ export class Portfolio implements PortfolioInterface {
this.orders.push( this.orders.push(
new Order({ new Order({
account: order.Account, account: order.Account,
currency: <any>order.currency, currency: order.currency,
date: order.date.toISOString(), date: order.date.toISOString(),
fee: order.fee, fee: order.fee,
quantity: order.quantity, quantity: order.quantity,
symbol: order.symbol, symbol: order.symbol,
type: <any>order.type, type: <OrderType>order.type,
unitPrice: order.unitPrice unitPrice: order.unitPrice
}) })
); );

View File

@ -1,7 +1,7 @@
import { groupBy } from '@ghostfolio/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { bool, cleanEnv, num, port, str } from 'envalid'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@ -12,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),

View File

@ -1,11 +1,11 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import { import {
benchmarks,
currencyPairs,
getUtc, getUtc,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
resetHours resetHours
} from '@ghostfolio/helper'; } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -19,6 +19,7 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
@ -116,15 +117,13 @@ export class DataGatheringService {
} }
} }
public async gatherSymbols( public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
aSymbolsWithStartDate: { date: Date; symbol: string }[]
) {
let hasError = false; let hasError = false;
for (const { date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[symbol], [{ dataSource, symbol }],
date, date,
new Date() new Date()
); );
@ -185,20 +184,24 @@ export class DataGatheringService {
} }
} }
public async getCustomSymbolsToGather(startDate?: Date) { public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations(); const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => { return scraperConfigurations.map((scraperConfiguration) => {
return { return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate, date: startDate,
symbol: scraperConfiguration.symbol symbol: scraperConfiguration.symbol
}; };
}); });
} }
private getBenchmarksToGather(startDate: Date) { private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map((symbol) => { const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return { return {
dataSource,
symbol, symbol,
date: startDate date: startDate
}; };
@ -206,6 +209,7 @@ export class DataGatheringService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({ benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate, date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX' symbol: 'GF.FEAR_AND_GREED_INDEX'
}); });
@ -214,16 +218,16 @@ export class DataGatheringService {
return benchmarksToGather; return benchmarksToGather;
} }
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { symbol: true } select: { dataSource: true, symbol: true }
}); });
const distinctOrdersWithDate = distinctOrders const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
.filter((distinctOrder) => { .filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol); return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
}) })
@ -234,12 +238,15 @@ export class DataGatheringService {
}; };
}); });
const currencyPairsToGather = currencyPairs.map((symbol) => { const currencyPairsToGather = currencyPairs.map(
return { ({ dataSource, symbol }) => {
symbol, return {
date: startDate dataSource,
}; symbol,
}); date: startDate
};
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate startDate
@ -253,24 +260,27 @@ export class DataGatheringService {
]; ];
} }
private async getSymbolsMax() { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01')); const startDate = new Date(getUtc('2015-01-01'));
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate startDate
); );
const currencyPairsToGather = currencyPairs.map((symbol) => { const currencyPairsToGather = currencyPairs.map(
return { ({ dataSource, symbol }) => {
symbol, return {
date: startDate dataSource,
}; symbol,
}); date: startDate
};
}
);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true, symbol: true } select: { dataSource: true, date: true, symbol: true }
}); });
return [ return [

View File

@ -1,10 +1,10 @@
import { import {
isCrypto,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
@ -12,16 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface';
import { Granularity } from './interfaces/granularity.type';
import { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from './interfaces/interfaces'; } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataProviderService implements DataProviderInterface { export class DataProviderService {
public constructor( public constructor(
private readonly alphaVantageService: AlphaVantageService, private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -120,68 +119,57 @@ export class DataProviderService implements DataProviderInterface {
} }
public async getHistoricalRaw( public async getHistoricalRaw(
aSymbols: string[], aDataGatheringItems: IDataGatheringItem[],
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
const filteredSymbols = aSymbols.filter((symbol) => { const result: {
return !isGhostfolioScraperApiSymbol(symbol); [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}); } = {};
const dataOfYahoo = await this.yahooFinanceService.getHistorical( const promises: Promise<{
filteredSymbols, data: { [date: string]: IDataProviderHistoricalResponse };
undefined, symbol: string;
from, }>[] = [];
to for (const { dataSource, symbol } of aDataGatheringItems) {
); const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) {
if (aSymbols.length === 1) { promises.push(
const symbol = aSymbols[0]; dataProvider
.getHistorical([symbol], undefined, from, to)
if ( .then((data) => ({ data: data?.[symbol], symbol }))
isCrypto(symbol) &&
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
) {
// Merge data from Yahoo with data from Alpha Vantage
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
[symbol],
undefined,
from,
to
); );
return {
[symbol]: {
...dataOfYahoo[symbol],
...dataOfAlphaVantage[symbol]
}
};
} else if (isGhostfolioScraperApiSymbol(symbol)) {
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfGhostfolioScraperApi;
} else if (
isRakutenRapidApiSymbol(symbol) &&
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
) {
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfRakutenRapidApi;
} }
} }
return dataOfYahoo; const allData = await Promise.all(promises);
for (const { data, symbol } of allData) {
result[symbol] = data;
}
return result;
}
public async search(aSymbol: string) {
return this.getDataProvider(
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
).search(aSymbol);
}
private getDataProvider(providerName: DataSource) {
switch (providerName) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.GHOSTFOLIO:
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
}
} }
} }

View File

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
import { ConfigurationService } from '../../configuration.service'; import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
@ -22,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
}); });
} }
public canHandle(symbol: string) {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -77,7 +83,17 @@ export class AlphaVantageService implements DataProviderInterface {
} }
} }
public search(aSymbol: string) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aSymbol);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
})
};
} }
} }

View File

@ -1,4 +1,8 @@
import { getYesterday } from '@ghostfolio/helper'; import {
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
@ -6,7 +10,6 @@ import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(private prisma: PrismaService) {} public constructor(private prisma: PrismaService) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -117,6 +124,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return []; return [];
} }
public async search(aSymbol: string) {
return { items: [] };
}
private extractNumberFromString(aString: string): number { private extractNumberFromString(aString: string): number {
try { try {
const [numberString] = aString.match( const [numberString] = aString.match(

View File

@ -1,4 +1,9 @@
import { getToday, getYesterday } from '@ghostfolio/helper'; import {
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
@ -6,7 +11,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { ConfigurationService } from '../../configuration.service'; import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {}
public canHandle(symbol: string) {
return (
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -117,6 +128,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {}; return {};
} }
public async search(aSymbol: string) {
return { items: [] };
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
private async getFearAndGreedIndex(): Promise<{ private async getFearAndGreedIndex(): Promise<{
now: { value: number; valueText: string }; now: { value: number; valueText: string };
previousClose: { value: number; valueText: string }; previousClose: { value: number; valueText: string };
@ -147,8 +166,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
} }
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
} }

View File

@ -1,24 +0,0 @@
/*
import { Test } from '@nestjs/testing';
import { YahooFinanceService } from './yahoo-finance.service';
describe('AppService', () => {
let service: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [YahooFinanceService]
}).compile();
service = app.get<YahooFinanceService>(YahooFinanceService);
});
describe('get', () => {
it('should return data for USDCHF', () => {
expect(service.get(['USDCHF'])).toEqual('{}');
});
});
});
*/

View File

@ -1,11 +1,14 @@
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
@ -21,8 +24,14 @@ import {
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {} public constructor() {}
public canHandle(symbol: string) {
return true;
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -135,6 +144,49 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const result = await get();
items = result.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch {}
return { items };
}
/** /**
* Converts a symbol to a Yahoo symbol * Converts a symbol to a Yahoo symbol
* *
@ -170,7 +222,7 @@ export class YahooFinanceService implements DataProviderInterface {
private parseExchange(aString: string): string { private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') { if (aString?.toLowerCase() === 'ccc') {
return 'Other'; return UNKNOWN_KEY;
} }
return aString; return aString;
@ -200,7 +252,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Industry.Software; return Industry.Software;
} }
return Industry.Other; return Industry.Unknown;
} }
private parseSector(aString: string): Sector { private parseSector(aString: string): Sector {
@ -222,7 +274,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Sector.Technology; return Sector.Technology;
} }
return Sector.Other; return Sector.Unknown;
} }
private parseType(aString: string): Type { private parseType(aString: string): Type {
@ -234,7 +286,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Type.Stock; return Type.Stock;
} }
return Type.Other; return Type.Unknown;
} }
} }

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/helper'; import { getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';

View File

@ -1,10 +1,14 @@
import { Granularity } from './granularity.type'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from './interfaces'; } from './interfaces';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>; get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
getHistorical( getHistorical(
@ -15,4 +19,6 @@ export interface DataProviderInterface {
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

View File

@ -1,4 +1,5 @@
import { Account, Currency, DataSource, Platform } from '@prisma/client'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
@ -7,9 +8,9 @@ export const Industry = {
Biotechnology: 'Biotechnology', Biotechnology: 'Biotechnology',
Food: 'Food', Food: 'Food',
Internet: 'Internet', Internet: 'Internet',
Other: 'Other',
Pharmaceutical: 'Pharmaceutical', Pharmaceutical: 'Pharmaceutical',
Software: 'Software' Software: 'Software',
Unknown: UNKNOWN_KEY
}; };
export const MarketState = { export const MarketState = {
@ -21,15 +22,15 @@ export const MarketState = {
export const Sector = { export const Sector = {
Consumer: 'Consumer', Consumer: 'Consumer',
Healthcare: 'Healthcare', Healthcare: 'Healthcare',
Other: 'Other', Technology: 'Technology',
Technology: 'Technology' Unknown: UNKNOWN_KEY
}; };
export const Type = { export const Type = {
Cryptocurrency: 'Cryptocurrency', Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF', ETF: 'ETF',
Other: 'Other', Stock: 'Stock',
Stock: 'Stock' Unknown: UNKNOWN_KEY
}; };
export interface IOrder { export interface IOrder {
@ -64,6 +65,12 @@ export interface IDataProviderResponse {
url?: string; url?: string;
} }
export interface IDataGatheringItem {
dataSource: DataSource;
date?: Date;
symbol: string;
}
export type Industry = typeof Industry[keyof typeof Industry]; export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -1,7 +1,7 @@
import { import {
DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_MONTH_YEAR DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/helper'; } from '@ghostfolio/common/config';
export const DateFormats = { export const DateFormats = {
display: { display: {

View File

@ -71,6 +71,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule) import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
}, },
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{ {
path: 'transactions', path: 'transactions',
loadChildren: () => loadChildren: () =>
@ -78,11 +83,16 @@ const routes: Routes = [
(m) => m.TransactionsPageModule (m) => m.TransactionsPageModule
) )
}, },
{
path: 'zen',
loadChildren: () =>
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
},
{ {
// wildcard, if requested url doesn't match any paths for routes defined // wildcard, if requested url doesn't match any paths for routes defined
// earlier // earlier
path: '**', path: '**',
redirectTo: '/home', redirectTo: 'home',
pathMatch: 'full' pathMatch: 'full'
} }
]; ];

View File

@ -4,6 +4,7 @@
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[info]="info" [info]="info"
[user]="user" [user]="user"
(signOut)="onSignOut()"
></gf-header> ></gf-header>
</header> </header>
@ -25,7 +26,10 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer class="footer d-flex justify-content-center position-absolute w-100"> <footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center"> <div class="container text-center">
<div> <div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -5,22 +5,19 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface'; import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
permissions,
primaryColorHex,
secondaryColorHex
} from '@ghostfolio/helper';
import { MaterialCssVarsService } from 'angular-material-css-vars'; import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({ @Component({
selector: 'gf-root', selector: 'gf-root',
@ -32,25 +29,29 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem; public info: InfoItem;
public isLoggedIn = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService, private materialCssVarsService: MaterialCssVarsService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService,
private userService: UserService
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => { this.dataService.fetchInfo().subscribe((info) => {
this.info = info; this.info = info;
}); });
@ -58,32 +59,48 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {
this.currentRoute = this.router.url.toString().substring(1); const urlTree = this.router.parseUrl(this.router.url);
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.isLoggedIn = !!this.tokenStorageService.getToken(); if (state?.user) {
this.user = state.user;
if (this.isLoggedIn) { this.canCreateAccount = hasPermission(
this.dataService.fetchUser().subscribe((user) => { this.user.permissions,
this.user = user; permissions.createUserAccount
);
this.canCreateAccount = hasPermission( } else if (!this.tokenStorageService.getToken()) {
this.user.permissions, // User has not been logged in
permissions.createUserAccount
);
this.cd.markForCheck();
});
} else {
this.user = null; this.user = null;
} }
this.changeDetectorRef.markForCheck();
}); });
} }
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeTheme() { private initializeTheme() {
this.materialCssVarsService.setDarkTheme( this.materialCssVarsService.setDarkTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia('(prefers-color-scheme: dark)').matches
@ -96,14 +113,4 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex); this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex); this.materialCssVarsService.setAccentColor(secondaryColorHex);
} }
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

View File

@ -22,7 +22,7 @@ import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module'; import { GfHeaderModule } from './components/header/header.module';
import { authInterceptorProviders } from './core/auth.interceptor'; import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageManager } from './core/language-manager.service'; import { LanguageService } from './core/language.service';
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@ -46,11 +46,11 @@ import { LanguageManager } from './core/language-manager.service';
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,
httpResponseInterceptorProviders, httpResponseInterceptorProviders,
LanguageManager, LanguageService,
{ {
provide: DateAdapter, provide: DateAdapter,
useClass: CustomDateAdapter, useClass: CustomDateAdapter,
deps: [LanguageManager, MAT_DATE_LOCALE, Platform] deps: [LanguageService, MAT_DATE_LOCALE, Platform]
}, },
{ provide: MAT_DATE_FORMATS, useValue: DateFormats } { provide: MAT_DATE_FORMATS, useValue: DateFormats }
], ],

View File

@ -6,7 +6,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface'; import { Access } from '@ghostfolio/common/interfaces';
@Component({ @Component({
selector: 'gf-access-table', selector: 'gf-access-table',

View File

@ -1,24 +1,13 @@
<table <table class="gf-table w-100" mat-table [dataSource]="dataSource">
class="gf-table w-100"
matSort
matSortActive="account"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
Name
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }} {{ element.name }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
Platform
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon
@ -60,7 +49,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th> <th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
<td *matCellDef="let element" mat-cell> <td *matCellDef="let element" mat-cell>
{{ element.Order?.length }} {{ element.Order?.length }}
</td> </td>

View File

@ -6,13 +6,9 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource(); public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public displayedColumns = []; public displayedColumns = [];
public isLoading = true; public isLoading = true;
@ -41,11 +35,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor() {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {}
public ngOnInit() {} public ngOnInit() {}
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
if (this.accounts) { if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts); this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.isLoading = false; this.isLoading = false;
} }

View File

@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
MatButtonModule, MatButtonModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule

View File

@ -5,7 +5,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { resolveFearAndGreedIndex } from '@ghostfolio/helper'; import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
@Component({ @Component({
selector: 'gf-fear-and-greed-index', selector: 'gf-fear-and-greed-index',

View File

@ -8,31 +8,32 @@
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('home') ? 'primary' : null" [color]="
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
> >
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null" [color]="
[routerLink]="['/analysis']" currentRoute === 'analysis' ||
>Analysis</a currentRoute === 'report' ||
currentRoute === 'tools'
? 'primary'
: null
"
[routerLink]="['/tools']"
>Tools</a
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('report') ? 'primary' : null" [color]="currentRoute === 'transactions' ? 'primary' : null"
[routerLink]="['/report']"
>X-ray</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
[routerLink]="['/transactions']" [routerLink]="['/transactions']"
>Transactions</a >Transactions</a
> >
@ -40,7 +41,7 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null" [color]="currentRoute === 'accounts' ? 'primary' : null"
[routerLink]="['/accounts']" [routerLink]="['/accounts']"
>Accounts</a >Accounts</a
> >
@ -49,7 +50,7 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('admin') ? 'primary' : null" [color]="currentRoute === 'admin' ? 'primary' : null"
[routerLink]="['/admin']" [routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
@ -57,7 +58,7 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('resources') ? 'primary' : null" [color]="currentRoute === 'resources' ? 'primary' : null"
[routerLink]="['/resources']" [routerLink]="['/resources']"
>Resources</a >Resources</a
> >
@ -66,7 +67,7 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('pricing') ? 'primary' : null" [color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
@ -74,7 +75,7 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('about') ? 'primary' : null" [color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >
@ -138,24 +139,21 @@
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }" [ngClass]="{
[routerLink]="['/analysis']" 'font-weight-bold':
>Analysis</a currentRoute === 'analysis' ||
> currentRoute === 'report' ||
<a currentRoute === 'tools'
class="d-block d-sm-none" }"
i18n [routerLink]="['/tools']"
mat-menu-item >Tools</a
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
[routerLink]="['/report']"
>X-ray</a
> >
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute?.startsWith('transactions') 'font-weight-bold': currentRoute === 'transactions'
}" }"
[routerLink]="['/transactions']" [routerLink]="['/transactions']"
>Transactions</a >Transactions</a
@ -164,7 +162,7 @@
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }" [ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']" [routerLink]="['/accounts']"
>Accounts</a >Accounts</a
> >
@ -172,16 +170,16 @@
class="align-items-center d-flex" class="align-items-center d-flex"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }" [ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[routerLink]="['/account']" [routerLink]="['/account']"
>Ghostfolio Account</a >My Ghostfolio</a
> >
<a <a
*ngIf="hasPermissionToAccessAdminControl" *ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }" [ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']" [routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
@ -191,7 +189,7 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute?.startsWith('resources') 'font-weight-bold': currentRoute === 'resources'
}" }"
[routerLink]="['/resources']" [routerLink]="['/resources']"
>Resources</a >Resources</a
@ -201,7 +199,7 @@
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('pricing') }" [ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
@ -209,7 +207,7 @@
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }" [ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']" [routerLink]="['/about']"
>About Ghostfolio</a >About Ghostfolio</a
> >
@ -227,11 +225,19 @@
<gf-logo></gf-logo> <gf-logo></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute?.startsWith('about') ? 'primary' : null" [color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >

View File

@ -1,18 +1,19 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
Input, Input,
OnChanges OnChanges,
Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -27,6 +28,8 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() user: User; @Input() user: User;
@Output() signOut = new EventEmitter<void>();
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -76,8 +79,7 @@ export class HeaderComponent implements OnChanges {
} }
public onSignOut() { public onSignOut() {
this.tokenStorageService.signOut(); this.signOut.next();
window.location.reload();
} }
public openLoginDialog(): void { public openLoginDialog(): void {

View File

@ -9,8 +9,8 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb } from '@ghostfolio/helper'; import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { import {
LineController, LineController,
LineElement, LineElement,
@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
public constructor() { public constructor() {
Chart.register( Chart.register(
LinearScale,
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
LinearScale,
TimeScale TimeScale
); );
} }
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
display: false, display: true,
grid: { grid: {
display: false display: false
}, },

View File

@ -9,7 +9,8 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor } from '@ghostfolio/common/helper';
import { import {
Chart, Chart,
Filler, Filler,
@ -62,6 +63,10 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices = []; const benchmarkPrices = [];
@ -76,7 +81,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
const canvas = document.getElementById('chartCanvas'); const canvas = document.getElementById('chartCanvas');
var gradient = this.chartCanvas?.nativeElement const gradient = this.chartCanvas?.nativeElement
?.getContext('2d') ?.getContext('2d')
.createLinearGradient( .createLinearGradient(
0, 0,
@ -88,14 +93,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0, 0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)` `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
); );
gradient.addColorStop( gradient.addColorStop(1, getBackgroundColor());
1,
getComputedStyle(document.documentElement).getPropertyValue(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
)
);
const data = { const data = {
labels, labels,
@ -181,8 +179,4 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = false; this.isLoading = false;
} }
public ngOnDestroy() {
this.chart?.destroy();
}
} }

View File

@ -28,7 +28,7 @@ export class PerformanceChartDialog {
public title: string; public title: string;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>, public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
this.historicalDataItems = this.data.historicalDataItems; this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => { this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => { const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date; return item.date === historicalDataItem.date;
}); });
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
} }
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.title = `Performance vs. ${this.benchmarkLabel}`; this.title = `Performance vs. ${this.benchmarkLabel}`;

View File

@ -5,7 +5,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface'; import { PortfolioOverview } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@Component({ @Component({

View File

@ -7,7 +7,7 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface'; import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';

View File

@ -4,7 +4,7 @@ import {
Input, Input,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface'; import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@Component({ @Component({

View File

@ -7,7 +7,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { endOfDay, parseISO, startOfDay } from 'date-fns'; import { endOfDay, parseISO, startOfDay } from 'date-fns';
@Component({ @Component({

View File

@ -7,7 +7,9 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js'; import { Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
@ -38,7 +40,11 @@ export class PortfolioProportionChartComponent
private colorMap: { private colorMap: {
[symbol: string]: string; [symbol: string]: string;
} = {}; } = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
};
public constructor() { public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
@ -74,10 +80,10 @@ export class PortfolioProportionChartComponent
}; };
} }
} else { } else {
if (chartData['Other']) { if (chartData[UNKNOWN_KEY]) {
chartData['Other'].value += this.positions[symbol].value; chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
} else { } else {
chartData['Other'] = { chartData[UNKNOWN_KEY] = {
value: this.positions[symbol].value value: this.positions[symbol].value
}; };
} }
@ -134,7 +140,8 @@ export class PortfolioProportionChartComponent
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const label = context.label; const label =
context.label === UNKNOWN_KEY ? 'Other' : context.label;
if (this.isInPercent) { if (this.isInPercent) {
const value = 100 * <number>context.raw; const value = 100 * <number>context.raw;

View File

@ -34,7 +34,7 @@ export class PositionDetailDialog {
public transactionCount: number; public transactionCount: number;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -127,7 +127,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }

View File

@ -35,7 +35,9 @@
<div class="h6 m-0 text-truncate">{{ position?.name }}</div> <div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex"> <div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span> <span>{{ position?.symbol | gfSymbol }}</span>
<span *ngIf="position?.exchange" class="ml-2 text-muted" <span
*ngIf="position?.exchange && position?.exchange !== unknownKey"
class="ml-2 text-muted"
>({{ position.exchange }})</span >({{ position.exchange }})</span
> >
</div> </div>

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Subject, Subscription } from 'rxjs'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@ -27,7 +28,7 @@ export class PositionComponent implements OnDestroy, OnInit {
@Input() position: PortfolioPosition; @Input() position: PortfolioPosition;
@Input() range: string; @Input() range: string;
public routeQueryParams: Subscription; public unknownKey = UNKNOWN_KEY;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -36,7 +37,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router private router: Router
) { ) {
this.routeQueryParams = route.queryParams route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (

View File

@ -1,7 +1,7 @@
<table <table
class="gf-table w-100" class="gf-table w-100"
matSort matSort
matSortActive="shareCurrent" matSortActive="allocationCurrent"
matSortDirection="desc" matSortDirection="desc"
mat-table mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
@ -36,7 +36,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="shareInvestment"> <ng-container matColumnDef="allocationInvestment">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
@ -44,20 +44,20 @@
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Initial Share Initial Allocation
</th> </th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end px-1"> <div class="d-flex justify-content-end px-1">
<gf-value <gf-value
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.shareInvestment" [value]="isLoading ? undefined : element.allocationInvestment"
></gf-value> ></gf-value>
</div> </div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="shareCurrent"> <ng-container matColumnDef="allocationCurrent">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
@ -65,14 +65,14 @@
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Current Share Current Allocation
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.shareCurrent" [value]="isLoading ? undefined : element.allocationCurrent"
></gf-value> ></gf-value>
</div> </div>
</td> </td>

View File

@ -13,7 +13,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client'; import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -73,8 +73,8 @@ export class PositionsTableComponent implements OnChanges, OnInit {
this.displayedColumns = [ this.displayedColumns = [
'symbol', 'symbol',
'performance', 'performance',
'shareInvestment', 'allocationInvestment',
'shareCurrent' 'allocationCurrent'
]; ];
this.isLoading = true; this.isLoading = true;

View File

@ -5,8 +5,8 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({ @Component({
selector: 'gf-positions', selector: 'gf-positions',

View File

@ -4,7 +4,7 @@ import {
Input, Input,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
@Component({ @Component({
selector: 'gf-rule', selector: 'gf-rule',

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
@Component({ @Component({
selector: 'gf-rules', selector: 'gf-rules',

View File

@ -8,7 +8,7 @@
[removable]="true" [removable]="true"
(removed)="removeKeyword(searchKeyword)" (removed)="removeKeyword(searchKeyword)"
> >
{{ searchKeyword }} {{ searchKeyword | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon> <ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip> </mat-chip>
<input <input
@ -26,11 +26,8 @@
#autocomplete="matAutocomplete" #autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)" (optionSelected)="keywordSelected($event)"
> >
<mat-option <mat-option *ngFor="let filter of filters | async" [value]="filter">
*ngFor="let transaction of filteredTransactions | async" {{ filter | gfSymbol }}
[value]="transaction"
>
{{ transaction }}
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
@ -178,9 +175,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
Account
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon

View File

@ -21,8 +21,9 @@ import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { OrderWithAccount } from '@ghostfolio/api/app/order/interfaces/order-with-account.type'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { format } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -56,10 +57,8 @@ export class TransactionsTableComponent
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource(); public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = []; public displayedColumns = [];
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
public filteredTransactions: Observable< public filters: Observable<string[]> = this.filters$.asObservable();
string[]
> = this.filteredTransactions$.asObservable();
public isLoading = true; public isLoading = true;
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
@ -67,7 +66,7 @@ export class TransactionsTableComponent
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
private allFilteredTransactions: string[]; private allFilters: string[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -89,13 +88,13 @@ export class TransactionsTableComponent
this.searchControl.valueChanges.subscribe((keyword) => { this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) { if (keyword) {
const filterValue = keyword.toLowerCase(); const filterValue = keyword.toLowerCase();
this.filteredTransactions$.next( this.filters$.next(
this.allFilteredTransactions.filter( this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0 (filter) => filter.toLowerCase().indexOf(filterValue) === 0
) )
); );
} else { } else {
this.filteredTransactions$.next(this.allFilteredTransactions); this.filters$.next(this.allFilters);
} }
}); });
} }
@ -238,13 +237,13 @@ export class TransactionsTableComponent
this.placeholder = this.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : ''; lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
this.allFilteredTransactions = this.getSearchableFieldValues( this.allFilters = this.getSearchableFieldValues(this.transactions).filter(
this.transactions (item) => {
).filter((item) => { return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase()); }
}); );
this.filteredTransactions$.next(this.allFilteredTransactions); this.filters$.next(this.allFilters);
} }
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] { private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {
@ -258,7 +257,21 @@ export class TransactionsTableComponent
.filter((item) => { .filter((item) => {
return item !== undefined; return item !== undefined;
}) })
.sort(); .sort((a, b) => {
const aFirstChar = a.charAt(0);
const bFirstChar = b.charAt(0);
const isANumber = aFirstChar >= '0' && aFirstChar <= '9';
const isBNumber = bFirstChar >= '0' && bFirstChar <= '9';
// Sort priority: text, followed by numbers
if (isANumber && !isBNumber) {
return 1;
} else if (!isANumber && isBNumber) {
return -1;
} else {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
}
});
} }
private getFilterableValues( private getFilterableValues(
@ -270,6 +283,7 @@ export class TransactionsTableComponent
fieldValues.add(transaction.type); fieldValues.add(transaction.type);
fieldValues.add(transaction.Account?.name); fieldValues.add(transaction.Account?.name);
fieldValues.add(transaction.Account?.Platform?.name); fieldValues.add(transaction.Account?.Platform?.name);
fieldValues.add(format(transaction.date, 'yyyy'));
return [...fieldValues].filter((item) => { return [...fieldValues].filter((item) => {
return item !== undefined; return item !== undefined;

View File

@ -5,7 +5,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { format, isDate } from 'date-fns'; import { format, isDate } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';

View File

@ -5,34 +5,74 @@ import {
Router, Router,
RouterStateSnapshot RouterStateSnapshot
} from '@angular/router'; } from '@angular/router';
import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { TokenStorageService } from '../services/token-storage.service'; import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
constructor( constructor(
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private settingsStorageService: SettingsStorageService,
private userService: UserService
) {} ) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const isLoggedIn = !!this.tokenStorageService.getToken(); if (route.queryParams?.utm_source) {
this.settingsStorageService.setSetting(
if (isLoggedIn) { 'utm_source',
if (state.url === '/start') { route.queryParams?.utm_source
this.router.navigate(['/home']); );
return false;
}
return true;
} }
// Not logged in return new Promise<boolean>((resolve) => {
if (state.url !== '/start') { this.userService
this.router.navigate(['/start']); .get()
return false; .pipe(
} catchError(() => {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {
this.router.navigate(['/start']);
resolve(false);
return EMPTY;
}
return true; resolve(true);
return EMPTY;
})
)
.subscribe((user) => {
if (
state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN
) {
this.router.navigate(['/zen']);
resolve(false);
} else if (state.url.startsWith('/start')) {
if (user.settings.viewMode === ViewMode.ZEN) {
this.router.navigate(['/zen']);
} else {
this.router.navigate(['/home']);
}
resolve(false);
} else if (
state.url.startsWith('/zen') &&
user.settings.viewMode === ViewMode.DEFAULT
) {
this.router.navigate(['/home']);
resolve(false);
}
resolve(true);
});
});
} }
} }

View File

@ -1,8 +1,4 @@
import { import { HTTP_INTERCEPTORS, HttpEvent } from '@angular/common/http';
HTTP_INTERCEPTORS,
HttpErrorResponse,
HttpEvent
} from '@angular/common/http';
import { import {
HttpHandler, HttpHandler,
HttpInterceptor, HttpInterceptor,
@ -11,7 +7,6 @@ import {
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { ImpersonationStorageService } from '../services/impersonation-storage.service'; import { ImpersonationStorageService } from '../services/impersonation-storage.service';
import { TokenStorageService } from '../services/token-storage.service'; import { TokenStorageService } from '../services/token-storage.service';

View File

@ -79,7 +79,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.router.navigate(['start']);
} }
return throwError(''); return throwError('');

View File

@ -1,126 +0,0 @@
import { Injectable } from '@angular/core';
import * as deDateFnsLocale from 'date-fns/locale/de/index';
import * as frDateFnsLocale from 'date-fns/locale/fr/index';
import { BehaviorSubject } from 'rxjs';
// TODO: Rename to language service
/**
* Service that distributes onLanguageChanged events
*/
@Injectable()
export class LanguageManager {
private static readonly AVALABLE_LANGUAGES = ['de', 'fr'];
private static readonly LANGUAGE_LABELS = {
de: 'Deutsch',
fr: 'Français'
};
private currentLanguage: string;
private changeLoadLanguageStateSubject = new BehaviorSubject(false);
/**
* @constructor
*/
public constructor() {} // private translate: TranslateService // private dataLoaderManager: DataLoaderManager,
/**
* Emits an event that the language has changed
*/
public changeLanguage(aLanguage: string) {
if (aLanguage && aLanguage !== this.currentLanguage) {
this.currentLanguage = aLanguage;
this.changeLoadLanguageStateSubject.next(true);
// this.translate.use(this.currentLanguage);
/*this.dataLoaderManager.changeLanguage(this.currentLanguage).then(() => {
// Emit an event that loading has finished
this.changeLoadLanguageStateSubject.next(false);
});*/
}
}
/**
* Returns a list of available languages for admin
*/
public getAvailableLanguages() {
return LanguageManager.AVALABLE_LANGUAGES;
}
/**
* Get the current language
*/
public getCurrentLanguage(aReturnFullLocale = false) {
// Check if the full locale is needed (e.g. for angular pipes like
// '| percentage')
if (aReturnFullLocale) {
if (this.currentLanguage) {
if (this.currentLanguage.match(/^de/)) {
return 'de-CH';
}
if (this.currentLanguage.match(/^fr/)) {
return 'fr-CH';
}
}
// Default
return 'de-CH';
}
if (this.currentLanguage) {
return this.currentLanguage;
}
// Default
return 'de';
}
/**
* Gets the locale module of date-fns in the current language
*/
public getDateFnsLocale() {
let currentDateFnsLocale = null;
switch (this.getCurrentLanguage()) {
case 'de':
currentDateFnsLocale = deDateFnsLocale;
break;
case 'fr':
currentDateFnsLocale = frDateFnsLocale;
break;
default:
currentDateFnsLocale = deDateFnsLocale;
}
return currentDateFnsLocale;
}
/**
* Returns the default language
*/
public getDefaultLanguage() {
// return globals.defaultLanguage;
return 'de';
}
/**
* Returns a pretty label of the given language
*/
public getLanguageLabel(aLanguage: string) {
if (LanguageManager.LANGUAGE_LABELS[aLanguage]) {
return LanguageManager.LANGUAGE_LABELS[aLanguage];
}
return aLanguage;
}
/**
* Returns an observable that emits true when loading is in progress and false
* when loading is finished
*/
public onChangeLoadLanguageState() {
return this.changeLoadLanguageStateSubject.asObservable();
}
}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable()
export class LanguageService {
public constructor() {}
}

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
const routes: Routes = [{ path: '', component: AboutPageComponent }]; const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -1,8 +1,7 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { baseCurrency } from '@ghostfolio/common/config';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { User } from '@ghostfolio/common/interfaces';
import { baseCurrency } from '@ghostfolio/helper';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -26,28 +25,23 @@ export class AboutPageComponent implements OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private userService: UserService
private tokenStorageService: TokenStorageService
) {} ) {}
/** /**
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken(); this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn) this.changeDetectorRef.markForCheck();
this.tokenStorageService }
.onChangeHasToken() });
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -5,15 +5,14 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<p> <p>
<strong>Ghostfolio</strong> ({{ version }}) is open source software <strong>Ghostfolio</strong> is open source software which empowers
which empowers busy folks to have a sharp look of their financial busy folks to have a sharp look of their financial assets and to
assets and to make solid, data-driven investment decisions by make solid, data-driven investment decisions by evaluating automated
evaluating automated static portfolio analysis rules. The project static portfolio analysis rules. The project has been initiated by
has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>. <a href="https://dotsilver.ch">Thomas Kaul</a>.
<ng-container *ngIf="lastPublish"> <ng-container *ngIf="lastPublish">
This instance has been last published on {{ lastPublish This instance is running Ghostfolio {{ version }} and has been
}}.</ng-container last published on {{ lastPublish }}.</ng-container
> >
</p> </p>
<p> <p>

View File

@ -1,13 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
DEFAULT_DATE_FORMAT, import { Access, User } from '@ghostfolio/common/interfaces';
hasPermission, import { hasPermission, permissions } from '@ghostfolio/common/permissions';
permissions
} from '@ghostfolio/helper';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -23,6 +19,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public currencies: Currency[] = []; public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -31,9 +28,9 @@ export class AccountPageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private tokenStorageService: TokenStorageService private userService: UserService
) { ) {
this.dataService this.dataService
.fetchInfo() .fetchInfo()
@ -47,15 +44,19 @@ export class AccountPageComponent implements OnDestroy, OnInit {
); );
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.cd.markForCheck(); this.hasPermissionToUpdateUserSettings = hasPermission(
}); this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
}); });
} }
@ -66,16 +67,26 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeBaseCurrency({ value: currency }: { value: Currency }) { public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue };
this.dataService this.dataService
.putUserSettings({ currency }) .putUserSettings({
baseCurrency: settings?.baseCurrency,
viewMode: settings?.viewMode
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.dataService.fetchUser().subscribe((user) => { this.userService.remove();
this.user = user;
this.cd.markForCheck(); this.userService
}); .get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}); });
} }
@ -91,7 +102,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => { .subscribe((response) => {
this.accesses = response; this.accesses = response;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
} }

View File

@ -30,13 +30,14 @@
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Settings</div> <div class="pt-4 w-50" i18n>Settings</div>
<div class="w-50"> <div class="w-50">
<form #addTransactionForm="ngForm"> <form #changeUserSettingsForm="ngForm">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Base Currency</mat-label> <mat-label i18n>Base Currency</mat-label>
<mat-select <mat-select
name="baseCurrency" name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency" [value]="user.settings.baseCurrency"
(selectionChange)="onChangeBaseCurrency($event)" (selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
> >
<mat-option <mat-option
*ngFor="let currency of currencies" *ngFor="let currency of currencies"
@ -45,6 +46,18 @@
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>View Mode</mat-label>
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</form> </form>
</div> </div>
</div> </div>

View File

@ -3,11 +3,11 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { hasPermission, permissions } from '@ghostfolio/helper'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client'; import { Account as AccountModel, AccountType } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -35,14 +35,14 @@ export class AccountsPageComponent implements OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class AccountsPageComponent implements OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission( this.hasPermissionToCreateAccount = hasPermission(
user.permissions, this.user.permissions,
permissions.createAccount permissions.createAccount
); );
this.hasPermissionToDeleteAccount = hasPermission( this.hasPermissionToDeleteAccount = hasPermission(
user.permissions, this.user.permissions,
permissions.deleteAccount permissions.deleteAccount
); );
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
this.fetchAccounts(); this.fetchAccounts();
@ -105,7 +105,7 @@ export class AccountsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

View File

@ -1,12 +1,11 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns'; import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNowStrict, isValid, parseISO, sub } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -33,9 +32,9 @@ export class AdminPageComponent implements OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private tokenStorageService: TokenStorageService private userService: UserService
) {} ) {}
/** /**
@ -44,13 +43,12 @@ export class AdminPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.fetchAdminData(); this.fetchAdminData();
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
}); }
}); });
} }
@ -78,7 +76,7 @@ export class AdminPageComponent implements OnInit {
public formatDistanceToNow(aDateString: string) { public formatDistanceToNow(aDateString: string) {
if (aDateString) { if (aDateString) {
const distanceString = formatDistanceToNow( const distanceString = formatDistanceToNowStrict(
sub(parseISO(aDateString), { seconds: 10 }), sub(parseISO(aDateString), { seconds: 10 }),
{ {
addSuffix: true addSuffix: true
@ -126,7 +124,7 @@ export class AdminPageComponent implements OnInit {
this.users = users; this.users = users;
if (isValid(parseISO(lastDataGathering?.toString()))) { if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNow( this.lastDataGathering = formatDistanceToNowStrict(
new Date(lastDataGathering), new Date(lastDataGathering),
{ {
addSuffix: true addSuffix: true
@ -141,7 +139,7 @@ export class AdminPageComponent implements OnInit {
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.userCount = userCount; this.userCount = userCount;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }

View File

@ -73,26 +73,40 @@
<table class="gf-table"> <table class="gf-table">
<thead> <thead>
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-center" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th> <th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2" i18n>Registration Date</th> <th class="mat-header-cell px-1 py-2 text-center" i18n>
<th class="mat-header-cell px-1 py-2" i18n>Accounts</th> Registration Date
<th class="mat-header-cell px-1 py-2" i18n>Transactions</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Engagement</th> <th class="mat-header-cell px-1 py-2 text-center" i18n>
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Transactions
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Engagement
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th> <th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let userItem of users" class="mat-row"> <tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }} {{ userItem.alias || userItem.id }}
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2 text-right">
{{ userItem.createdAt | date: defaultDateFormat }} {{ userItem.createdAt | date: defaultDateFormat }}
</td> </td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Account }}</td> <td class="mat-cell px-1 py-2 text-right">
<td class="mat-cell px-1 py-2">{{ userItem._count?.Order }}</td> {{ userItem._count?.Account }}
<td class="mat-cell px-1 py-2"> </td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Order }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.Analytics?.activityCount }} {{ userItem.Analytics?.activityCount }}
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">

View File

@ -1,11 +1,13 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type'; import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioItem,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -38,11 +40,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService private userService: UserService
) {} ) {}
/** /**
@ -64,7 +66,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe((response) => { .subscribe((response) => {
this.portfolioItems = response; this.portfolioItems = response;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.dataService
@ -74,18 +76,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.portfolioPositions = response; this.portfolioPositions = response;
this.initializeAnalysisData(this.portfolioPositions, this.period); this.initializeAnalysisData(this.portfolioPositions, this.period);
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
} }
@ -108,8 +109,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
type: position.type, type: position.type,
value: value:
aPeriod === 'original' aPeriod === 'original'
? position.shareInvestment ? position.allocationInvestment
: position.shareCurrent : position.allocationCurrent
}; };
this.positionsArray.push(position); this.positionsArray.push(position);

View File

@ -2,12 +2,15 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<gf-positions-table <div class="mb-4">
[baseCurrency]="user?.settings?.baseCurrency" <h4 class="m-0" i18n>Positions</h4>
[deviceType]="deviceType" <gf-positions-table
[locale]="user?.settings?.locale" [baseCurrency]="user?.settings?.baseCurrency"
[positions]="positionsArray" [deviceType]="deviceType"
></gf-positions-table> [locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
</div>
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">
@ -157,14 +160,14 @@
</div> </div>
<div i18n> <div i18n>
You can find more charts on your desktop: You can find more charts on your desktop:
<a href="https://ghostfol.io" target="_blank">www.ghostfol.io</a> <a href="https://ghostfol.io" target="_blank">Ghostfol.io</a>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
</div> </div>
<div *ngIf="!hasImpersonationId" class="d-none d-sm-block row"> <div class="d-none d-sm-block row">
<div class="col-lg"> <div class="col-lg">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header> <mat-card-header>

View File

@ -1,11 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DateRange } from '@ghostfolio/api/app/portfolio/interfaces/date-range.type';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component'; import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type'; import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
@ -15,8 +10,15 @@ import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { hasPermission, permissions } from '@ghostfolio/helper'; import {
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -56,7 +58,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
@ -64,9 +66,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['performanceChartDialog']) { if (params['performanceChartDialog']) {
@ -74,14 +76,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
} }
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission( this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
user.permissions, this.user.permissions,
permissions.accessFearAndGreedIndex permissions.accessFearAndGreedIndex
); );
@ -92,17 +94,17 @@ export class HomePageComponent implements OnDestroy, OnInit {
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
this.hasPermissionToReadForeignPortfolio = hasPermission( this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions, this.user.permissions,
permissions.readForeignPortfolio permissions.readForeignPortfolio
); );
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
} }
@ -130,6 +132,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openDialog(): void { private openDialog(): void {
const dialogRef = this.dialog.open(PerformanceChartDialog, { const dialogRef = this.dialog.open(PerformanceChartDialog, {
autoFocus: false, autoFocus: false,
@ -162,7 +169,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
}; };
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.dataService
@ -171,14 +178,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.performance = response; this.performance = response;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService.fetchPortfolioOverview().subscribe((response) => { this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response; this.overview = response;
this.isLoadingOverview = false; this.isLoadingOverview = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.dataService
@ -188,14 +195,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.hasPositions = this.hasPositions =
this.positions && Object.keys(this.positions).length > 0; this.positions && Object.keys(this.positions).length > 0;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
} }
} }

View File

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
@ -27,7 +26,6 @@ import { HomePageComponent } from './home-page.component';
GfPositionsModule, GfPositionsModule,
GfToggleModule, GfToggleModule,
HomePageRoutingModule, HomePageRoutingModule,
MatButtonModule,
MatCardModule, MatCardModule,
RouterModule RouterModule
], ],

View File

@ -26,7 +26,7 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private dialog: MatDialog, private dialog: MatDialog,
private router: Router, private router: Router,
@ -37,15 +37,12 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
// Remove all tokens (e.g. impersonationId)
window.localStorage.clear();
this.dataService.fetchInfo().subscribe(({ demoAuthToken }) => { this.dataService.fetchInfo().subscribe(({ demoAuthToken }) => {
this.demoAuthToken = demoAuthToken; this.demoAuthToken = demoAuthToken;
this.initializeLineChart(); this.initializeLineChart();
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

View File

@ -16,7 +16,7 @@ export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true; public isAgreeButtonDisabled = true;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: any @Inject(MAT_DIALOG_DATA) public data: any
) {} ) {}
@ -26,7 +26,7 @@ export class ShowAccessTokenDialog {
setTimeout(() => { setTimeout(() => {
this.isAgreeButtonDisabled = false; this.isAgreeButtonDisabled = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}, 1500); }, 1500);
} }
} }

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