Compare commits

...

80 Commits

Author SHA1 Message Date
d5d14497d6 Release 1.288.0 (#2146) 2023-07-12 19:56:16 +02:00
09c300661a Feature/improve language localization for german 20230711 (#2144)
* Improve i18n

* Update changelog
2023-07-12 19:53:55 +02:00
92382e0b4d Feature/improve loading state during filtering on allocations page (#2141)
* Improve loading state

* Update changelog
2023-07-11 21:41:12 +02:00
c25f532487 Improve product pages (#2143) 2023-07-11 21:40:45 +02:00
5d26d94586 Sort imports (#2142) 2023-07-11 20:27:54 +02:00
73b6784e9f Feature/beautify ampersand in asset profile names (#2138)
* Beautify ampersand

* Update changelog
2023-07-10 20:16:38 +02:00
6159f48a62 Feature/setup personal finance tools pages 2 (#2140) 2023-07-10 20:16:20 +02:00
7d34fba7c1 Release 1.287.0 (#2136) 2023-07-09 10:44:41 +02:00
c434b730a8 Feature/hide average buy price in position detail chart if no holding (#2133)
* Hide the average buy price if no holding

* Update changelog
2023-07-09 10:42:53 +02:00
2d23c566f1 Feature/setup personal finance tools pages (#2135) 2023-07-09 10:42:10 +02:00
ba220eaee9 Bugfix/fix sorting by currency in activities table (#2122)
* Fix sorting by currency

* Update changelog
2023-07-09 09:38:48 +02:00
09023214ce Feature/French translation update (#2130)
* French translation update

* Update changelog
2023-07-07 21:26:51 +02:00
1ceabb6e6b Feature/refactor blog articles to standalone components (#2117)
* Refactor blog articles to standalone components

* Update changelog
2023-07-04 18:42:40 +02:00
421072c7fa Release 1.286.0 (#2120) 2023-07-03 22:30:33 +02:00
0d421e7181 Bugfix/fix adding 'Item' and 'Liability' activities (#2119)
* Fix adding activities of type item and liability

* Update changelog
2023-07-03 22:29:00 +02:00
f5180ce88f Improve wording (#2118) 2023-07-03 10:10:08 +02:00
aabf27dc96 Remove empty style files (#2116) 2023-07-02 08:17:39 +02:00
421809ae95 Release 1.285.0 (#2114) 2023-07-01 19:14:26 +02:00
d3234f9e77 Feature/add blog post exploring the path to fire (#2113)
* Add blog post: Exploring the Path to FIRE

* Update changelog
2023-07-01 18:33:31 +02:00
a40be2f744 Feature/extract locales 20230701 (#2112)
* Improve i18n

* Update changelog
2023-07-01 18:30:09 +02:00
e62da06c5c Feature/extend scraper configuration support (#2110)
* Extend scraper configuration support

* Update changelog
2023-07-01 11:08:10 +02:00
b7f635bdfc Increase frequency (#2111) 2023-07-01 11:06:34 +02:00
0a465f125d Feature/add pagination to market data table in admin control panel (#2108)
* Add pagination

* Update changelog
2023-07-01 10:49:00 +02:00
c02e390bc1 Rename Slack channel to community (#2091) 2023-06-28 15:59:38 +02:00
f9bec0d793 Release 1.284.0 (#2106) 2023-06-27 18:47:44 +02:00
2f44748f79 Feature/upgrade internet identity dependencies to version 0.15.7 (#2105)
* Disable crossOriginOpenerPolicy

* Upgrade Internet Identity dependencies

* Update changelog
2023-06-27 18:46:03 +02:00
97504756be Feature/add currency to cash balance in create or update account dialog (#2104)
* Add currency as text suffix to cash balance

* Update changelog
2023-06-27 18:33:50 +02:00
6a802a62a0 Add ability to search for indices and fix gf-symbol-autocomplete validation (#2094)
* Bugfix/Fix gf-symbol-autocomplete validation

* Feature/Add ability to search for indices

* Update changelog
2023-06-26 18:38:24 +02:00
51ca26bb4d Release 1.283.5 (#2103) 2023-06-25 13:39:39 +02:00
2ecc8dbc4e Release 1.283.4 (#2101) 2023-06-24 18:41:37 +02:00
c0e0e2401e Release 1.283.3 (#2100) 2023-06-24 18:25:44 +02:00
1a30c180bc Release 1.283.2 (#2099) 2023-06-24 18:05:12 +02:00
39d4f80f36 Release 1.283.1 (#2098) 2023-06-24 17:49:12 +02:00
3693091ad6 Release 1.283.0 (#2097) 2023-06-24 17:14:04 +02:00
bf52f1137d Feature/setup helmet (#2096)
* Setup helmet

* Update changelog
2023-06-24 17:12:05 +02:00
54ea6c84b4 Feature/add caching for quotes (#2095)
* Add caching for quotes

* Update changelog
2023-06-24 13:06:28 +02:00
689e50ae1a Improve bug report template (#2092)
* Update Slack community link
* Remove checkboxes
2023-06-23 08:52:24 +02:00
677757fdf0 Feature/improve import dividends dialog (#2086)
* Improve dialog

* Add loading indicator
* Improve selected item of holding selector

* Update changelog
2023-06-22 20:29:50 +02:00
58d9816f01 Feature/extend symbol search component by asset sub classes (#2087)
* Add asset sub classes

* Update changelog
2023-06-21 16:09:18 +02:00
5f3d445f1d Release 1.282.0 (#2085) 2023-06-19 20:58:30 +02:00
fce6caebc2 Fix arm64 prisma binary target (#2082)
* Fix arm64 prisma binary target

* Update changelog
2023-06-19 20:56:28 +02:00
d0a4f5c000 Feature/added ability to add asset profile in admin control panel (#2075)
* Added ability to add asset profile in admin control panel

* Update changelog
2023-06-19 20:50:11 +02:00
b5e2a3aa91 Feature/harmonize use of permissions on about and landing page (#2084)
* Harmonize use of permissions

* About page
* Landing page

* About changelog
2023-06-19 20:29:36 +02:00
f47883fb0b Feature/add icon to external links in footer (#2083)
* Add icon for external links

* Update changelog
2023-06-19 20:29:12 +02:00
2932744a68 Feature/improve language localization for german 20230618 (#2081)
* Update translations

* Update changelog
2023-06-19 19:40:10 +02:00
73c0f02e06 Add the final translations for Portuguese (#2079) 2023-06-18 08:59:16 +02:00
382fe24f29 Release 1.281.0 (#2080) 2023-06-17 17:38:30 +02:00
908876ca6e Feature/setup language localization for portuguese (#2076)
* Set up Portuguese

* Update changelog
2023-06-17 17:26:40 +02:00
99cf9f8802 Feature/translation pt 2 (#2074)
* Add more Portuguese translations
2023-06-16 20:47:06 +02:00
7444ff97fc Feature/translation pt (#2073)
* Complete Portuguese translations for home screen and various other Portuguese translations
2023-06-15 08:34:47 +02:00
834a48466e Feature/add liabilities to feature page (#2072)
* Add section for liabilities

* Update changelog
2023-06-14 20:08:04 +02:00
a9526430c2 Improve column headers in holdings table for mobile (#2071)
* Improve column headers in holdings table for mobile

* Update changelog
2023-06-13 21:00:56 +02:00
fce3b2084e Feature/extract symbol search to component (#2003) (#2056)
* Extract symbol search to component (#2003)

* Update changelog
2023-06-13 20:36:16 +02:00
f5a50a95de Feature/upgrade prisma to version 4.15.0 (#2070)
* Upgrade prisma to version 4.15.0

* Update changelog
2023-06-12 15:16:43 +02:00
06dfb91f82 Release 1.280.1 (#2069) 2023-06-10 21:41:39 +02:00
be36050d76 Release 1.280.0 (#2068) 2023-06-10 17:18:28 +02:00
7931e6950d Feature/add support for liabilities (#1789)
* Add support for liabilities

* Update changelog
2023-06-10 16:17:11 +02:00
04eb452e04 Add missing guards (#2067) 2023-06-10 16:16:27 +02:00
6f7e370fca Release 1.279.0 (#2066) 2023-06-10 12:21:11 +02:00
b4a126280f Bugfix/fix public page (#2065)
* Check for user in request because of public page

* Update changelog
2023-06-10 12:19:34 +02:00
2d009aacc4 Bugfix/handle value nullifcation for undefined object (#2064)
* Handle undefined object

* Update changelog
2023-06-10 12:01:26 +02:00
9116443305 Feature/support note in accounts (#2063)
* Add support for a note in accounts

* Update changelog
2023-06-10 12:01:13 +02:00
0adaf12a01 Add new French translations (#2057)
* Add new French translations

* Update changelog

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-06-10 11:19:33 +02:00
b6562b6e2c Release 1.278.0 (#2062) 2023-06-09 21:14:38 +02:00
b0a4b09ef5 Feature/extract license to dedicated tab (#2061)
* Extract license to tab on about page

* Update changelog
2023-06-09 21:13:05 +02:00
ad8b9ad333 Bugfix/improve spacing in bechmark comparator (#2060)
* Improve spacing

* Update changelog
2023-06-09 20:52:51 +02:00
809956f210 Feature/display markets overview link in footer based on permission (#2059)
* Add check for permission

* Update changelog
2023-06-09 20:01:24 +02:00
6077bfa754 Feature/change direction of ellipsis icon to horizontal in tables (#2055)
* Change direction of ellipsis icon to horizontal

* Update changelog
2023-06-09 19:39:14 +02:00
09498bd804 Feature/refresh cryptocurrencies list 20230606 (#2049)
* Update cryptocurrencies.json

* Update changelog
2023-06-09 18:55:24 +02:00
fd84f4ec14 Change to routerLink (#2058) 2023-06-09 17:37:52 +02:00
c711a11d6e Feature/upgrade node.js from version 16 to 18 (#2053)
* Upgrade to Node.js 18

* Update changelog
2023-06-09 17:28:05 +02:00
8232b05f62 Feature/extend activity cloning by quantity (#2054)
* Allow to clone quantity

* Update changelog
2023-06-09 16:14:40 +02:00
0ea66aebcb Release 1.277.0 (#2052) 2023-06-07 17:35:19 +02:00
64087de3fc Bugfix/fix date format parsing in activities import (#2051)
* Fix date format parsing

* Update changelog
2023-06-07 17:33:35 +02:00
7082ff12f8 Feature/add semantic list structure to header navigation (#2044)
* Add semantic list structure (ul and li elements)

* Update changelog
2023-06-07 17:22:09 +02:00
1c7d92e15e Harmonize Slack community links (#2047) 2023-06-06 09:23:32 +02:00
a53461d257 Feature/migrate currency to unit in value component (#2043)
* Migrate currency to unit

* Update locales
2023-06-04 21:46:49 +02:00
d630fb900d Feature/add investment streaks (#2042)
* Add investment streaks

* Current streak
* Longest streak

* Add unit to value component

* Update changelog
2023-06-04 09:35:58 +02:00
51e8555fa5 Update link (#2040) 2023-06-04 09:30:35 +02:00
9db675b955 Feature/improve get symbol data endpoint (#2041)
* Add default value to query parameter

* Update changelog
2023-06-03 19:32:48 +02:00
236 changed files with 27839 additions and 4672 deletions

View File

@ -6,7 +6,7 @@ labels: ''
assignees: '' assignees: ''
--- ---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions). The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Bug Description** **Bug Description**
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information --> <!-- Please complete the following information -->
- [ ] Cloud - Cloud or Self-hosted
- [ ] Self-hosted
- Ghostfolio Version X.Y.Z - Ghostfolio Version X.Y.Z
- Browser - Browser
- OS - OS

View File

@ -10,7 +10,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 16 - 18
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3

2
.nvmrc
View File

@ -1 +1 @@
v16 v18

View File

@ -5,6 +5,147 @@ 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.288.0 - 2023-07-12
### Changed
- Improved the loading state during filtering on the allocations page
- Beautified the names with ampersand (`&amp;`) in the asset profile
- Improved the language localization for German (`de`)
## 1.287.0 - 2023-07-09
### Changed
- Hid the average buy price in the position detail chart if there is no holding
- Improved the language localization for French (`fr`)
- Refactored the blog articles to standalone components
### Fixed
- Fixed the sorting by currency in the activities table
## 1.286.0 - 2023-07-03
### Fixed
- Fixed the creation of (wealth) items and liabilities
## 1.285.0 - 2023-07-01
### Added
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
- Added pagination to the historical market data table of the admin control panel
- Added the attribute `headers` to the scraper configuration
### Changed
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
- Improved the language localization for German (`de`)
## 1.284.0 - 2023-06-27
### Added
- Added the currency to the cash balance in the create or update account dialog
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
### Changed
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
### Fixed
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
## 1.283.5 - 2023-06-25
### Added
- Added the caching for current market prices
- Added a loading indicator to the import dividends dialog
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
### Changed
- Improved the selected item of the holding selector in the import dividends dialog
- Extended the symbol search component by asset sub classes
## 1.282.0 - 2023-06-19
### Added
- Added an icon to the external links in the footer navigation
- Added the ability to add an asset profile in the admin control panel
### Changed
- Harmonized the use of permissions on the about page
- Harmonized the use of permissions on the landing page
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
## 1.281.0 - 2023-06-17
### Added
- Extended the feature overview page by liabilities
- Set up the language localization for Portuguese (`pt`)
### Changed
- Extracted the symbol search to a dedicated component
- Improved the column headers in the holdings table for mobile
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
## 1.280.1 - 2023-06-10
### Added
- Added support for liabilities
## 1.279.0 - 2023-06-10
### Added
- Supported a note for accounts
### Changed
- Improved the language localization for French (`fr`)
### Fixed
- Fixed an issue with the value nullification related to the investment streaks
- Fixed an issue in the public page related to the impersonation service
## 1.278.0 - 2023-06-09
### Changed
- Extended the clone functionality of a transaction by the quantity
- Changed the direction of the ellipsis icon in various tables
- Extracted the license to a dedicated tab on the about page
- Displayed the link to the markets overview in the footer based on a permission
- Improved the spacing in the benchmark comparator
- Refreshed the cryptocurrencies list
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
## 1.277.0 - 2023-06-07
### Added
- Added the investment streaks to the analysis page
- Added support for a unit in the value component
- Added a semantic list structure to the header navigation
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
### Fixed
- Fixed an issue with the date format parsing in the activities import
## 1.276.0 - 2023-06-03 ## 1.276.0 - 2023-06-03
### Added ### Added
@ -753,7 +894,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year - Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year - Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`) - Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed ### Changed

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:16-slim as builder FROM --platform=$BUILDPLATFORM node:18-slim as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:16-slim FROM node:18-slim
RUN apt update && apt install -y \ RUN apt update && apt install -y \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsNumber, IsNumber,
@ -6,6 +7,7 @@ import {
IsString, IsString,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsString() @IsString()
@ -14,6 +16,13 @@ export class CreateAccountDto {
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsNumber, IsNumber,
@ -6,6 +7,7 @@ import {
IsString, IsString,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsString() @IsString()
@ -14,6 +16,13 @@ export class UpdateAccountDto {
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,7 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -26,11 +28,12 @@ import {
Post, Post,
Put, Put,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} 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 { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -245,7 +248,11 @@ export class AdminController {
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
if ( if (
!hasPermission( !hasPermission(
@ -270,7 +277,13 @@ export class AdminController {
}) })
]; ];
return this.adminService.getMarketData(filters); return this.adminService.getMarketData({
filters,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')
@ -328,6 +341,28 @@ export class AdminController {
}); });
} }
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ dataSource, symbol });
}
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteProfileData( public async deleteProfileData(

View File

@ -1,21 +1,24 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import {
DEFAULT_PAGE_SIZE,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property } from '@prisma/client'; import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -25,6 +28,7 @@ export class AdminService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -35,6 +39,38 @@ export class AdminService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public async addAssetProfile({
dataSource,
symbol
}: UniqueAsset): Promise<SymbolProfile | never> {
try {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException(
`Asset profile of ${symbol} (${dataSource}) already exists`
);
}
throw error;
}
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
@ -65,7 +101,21 @@ export class AdminService {
}; };
} }
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> { public async getMarketData({
filters,
sortColumn,
sortDirection,
skip,
take = DEFAULT_PAGE_SIZE
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
@ -75,42 +125,33 @@ export class AdminService {
} }
); );
const marketData = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) { if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
} }
const symbolProfilesToGather: AdminMarketDataItem[] = ( if (sortColumn) {
await this.prismaService.symbolProfile.findMany({ orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
Order: {
_count: sortDirection
}
};
}
}
const [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where, where,
orderBy: [{ symbol: 'asc' }],
select: { select: {
_count: { _count: {
select: { Order: true } select: { Order: true }
@ -129,38 +170,48 @@ export class AdminService {
sectors: true, sectors: true,
symbol: true symbol: true
} }
}) }),
).map((symbolProfile) => { this.prismaService.symbolProfile.count({ where })
const countriesCount = symbolProfile.countries ]);
? Object.keys(symbolProfile.countries).length
: 0; return {
count,
marketData: assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount = const marketDataItemCount =
marketData.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
marketDataItem.dataSource === symbolProfile.dataSource && marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbolProfile.symbol marketDataItem.symbol === symbol
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = symbolProfile.sectors const sectorsCount = sectors ? Object.keys(sectors).length : 0;
? Object.keys(symbolProfile.sectors).length
: 0;
return { return {
assetClass,
assetSubClass,
comment,
countriesCount, countriesCount,
dataSource,
symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: symbolProfile._count.Order, activitiesCount: _count.Order,
assetClass: symbolProfile.assetClass, date: Order?.[0]?.date
assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
}; };
}); }
)
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
}; };
} }
@ -198,12 +249,14 @@ export class AdminService {
public async patchAssetProfileData({ public async patchAssetProfileData({
comment, comment,
dataSource, dataSource,
scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({ await this.symbolProfileService.updateSymbolProfile({
comment, comment,
dataSource, dataSource,
scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}); });

View File

@ -1,3 +1,4 @@
import { Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator'; import { IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
symbolMapping?: { symbolMapping?: {

View File

@ -21,6 +21,7 @@ export class ExportService {
select: { select: {
accountType: true, accountType: true,
balance: true, balance: true,
comment: true,
currency: true, currency: true,
id: true, id: true,
isExcluded: true, isExcluded: true,

View File

@ -104,6 +104,11 @@ export class FrontendMiddleware implements NestMiddleware {
) { ) {
featureGraphicPath = 'assets/images/blog/20230520.jpg'; featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`; title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
} }
if ( if (

View File

@ -202,7 +202,7 @@ export class ImportService {
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
} else { } else {
activity.dataSource = activity.dataSource =

View File

@ -96,7 +96,7 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (data.type === 'ITEM') { if (data.type === 'ITEM' || data.type === 'LIABILITY') {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency; currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -129,7 +129,10 @@ export class OrderService {
} }
}); });
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
@ -201,7 +204,7 @@ export class OrderService {
where where
}); });
if (order.type === 'ITEM') { if (order.type === 'ITEM' || order.type === 'LIABILITY') {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -320,7 +323,11 @@ export class OrderService {
}) })
) )
.filter((order) => { .filter((order) => {
return withExcludedAccounts || order.Account?.isExcluded === false; return (
withExcludedAccounts ||
!order.Account ||
order.Account?.isExcluded === false
);
}) })
.map((order) => { .map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
@ -368,7 +375,7 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM' || data.type === 'LIABILITY') {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

View File

@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
[], [],
null, null,
null, null,
propertyService propertyService,
null
); );
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') }, { date: '2015-01-01', investment: new Big('640.86') },
{ date: '2015-02-01', investment: new Big('0') },
{ date: '2015-03-01', investment: new Big('0') },
{ date: '2015-04-01', investment: new Big('0') },
{ date: '2015-05-01', investment: new Big('0') },
{ date: '2015-06-01', investment: new Big('0') },
{ date: '2015-07-01', investment: new Big('0') },
{ date: '2015-08-01', investment: new Big('0') },
{ date: '2015-09-01', investment: new Big('0') },
{ date: '2015-10-01', investment: new Big('0') },
{ date: '2015-11-01', investment: new Big('0') },
{ date: '2015-12-01', investment: new Big('0') },
{ date: '2016-01-01', investment: new Big('0') },
{ date: '2016-02-01', investment: new Big('0') },
{ date: '2016-03-01', investment: new Big('0') },
{ date: '2016-04-01', investment: new Big('0') },
{ date: '2016-05-01', investment: new Big('0') },
{ date: '2016-06-01', investment: new Big('0') },
{ date: '2016-07-01', investment: new Big('0') },
{ date: '2016-08-01', investment: new Big('0') },
{ date: '2016-09-01', investment: new Big('0') },
{ date: '2016-10-01', investment: new Big('0') },
{ date: '2016-11-01', investment: new Big('0') },
{ date: '2016-12-01', investment: new Big('0') },
{ date: '2017-01-01', investment: new Big('0') },
{ date: '2017-02-01', investment: new Big('0') },
{ date: '2017-03-01', investment: new Big('0') },
{ date: '2017-04-01', investment: new Big('0') },
{ date: '2017-05-01', investment: new Big('0') },
{ date: '2017-06-01', investment: new Big('0') },
{ date: '2017-07-01', investment: new Big('0') },
{ date: '2017-08-01', investment: new Big('0') },
{ date: '2017-09-01', investment: new Big('0') },
{ date: '2017-10-01', investment: new Big('0') },
{ date: '2017-11-01', investment: new Big('0') },
{ date: '2017-12-01', investment: new Big('-14156.4') } { date: '2017-12-01', investment: new Big('-14156.4') }
]); ]);
}); });

View File

@ -544,7 +544,7 @@ export class PortfolioCalculator {
return []; return [];
} }
const investments = []; const investments: { date: string; investment: Big }[] = [];
let currentDate: Date; let currentDate: Date;
let investmentByGroup = new Big(0); let investmentByGroup = new Big(0);
@ -554,13 +554,11 @@ export class PortfolioCalculator {
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate)) (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) { ) {
// Same group: Add up investments // Same group: Add up investments
investmentByGroup = investmentByGroup.plus( investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
); );
} else { } else {
// New group: Store previous group and reset // New group: Store previous group and reset
if (currentDate) { if (currentDate) {
investments.push({ investments.push({
date: format( date: format(
@ -595,7 +593,39 @@ export class PortfolioCalculator {
} }
} }
return investments; // Fill in the missing dates with investment = 0
const startDate = parseDate(first(this.orders).date);
const endDate = parseDate(last(this.orders).date);
const allDates: string[] = [];
currentDate = startDate;
while (currentDate <= endDate) {
allDates.push(
format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
)
);
currentDate.setMonth(currentDate.getMonth() + 1);
}
for (const date of allDates) {
const existingInvestment = investments.find((investment) => {
return investment.date === date;
});
if (!existingInvestment) {
investments.push({ date, investment: new Big(0) });
}
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async calculateTimeline( public async calculateTimeline(

View File

@ -162,6 +162,7 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'items', 'items',
'liabilities',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
'totalSell' 'totalSell'
@ -258,11 +259,12 @@ export class PortfolioController {
filterByTags filterByTags
}); });
let investments = await this.portfolioService.getInvestments({ let { investments, streaks } = await this.portfolioService.getInvestments({
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId impersonationId,
savingsRate: this.request.user?.Settings?.settings.savingsRate
}); });
if ( if (
@ -278,6 +280,11 @@ export class PortfolioController {
date: item.date, date: item.date,
investment: item.investment / maxInvestment investment: item.investment / maxInvestment
})); }));
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
} }
if ( if (
@ -287,9 +294,14 @@ export class PortfolioController {
investments = investments.map((item) => { investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']); return nullifyValuesInObject(item, ['investment']);
}); });
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
} }
return { investments }; return { investments, streaks };
} }
@Get('performance') @Get('performance')

View File

@ -28,6 +28,7 @@ import {
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReport,
@ -252,13 +253,15 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId impersonationId,
savingsRate
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { savingsRate: number;
}): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
@ -276,7 +279,10 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
} }
let investments: InvestmentItem[]; let investments: InvestmentItem[];
@ -346,9 +352,23 @@ export class PortfolioService {
parseDate(investments[0]?.date) parseDate(investments[0]?.date)
); );
return investments.filter(({ date }) => { investments = investments.filter(({ date }) => {
return !isBefore(parseDate(date), startDate); return !isBefore(parseDate(date), startDate);
}); });
let streaks: PortfolioInvestments['streaks'];
if (savingsRate) {
streaks = this.getStreaks({
investments,
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
});
}
return {
investments,
streaks
};
} }
public async getChart({ public async getChart({
@ -1282,12 +1302,11 @@ export class PortfolioService {
}: { }: {
activities: OrderWithAccount[]; activities: OrderWithAccount[];
date?: Date; date?: Date;
userCurrency: string; userCurrency: string;
}) { }) {
return activities return activities
.filter((activity) => { .filter((activity) => {
// Filter out all activities before given date and type dividend // Filter out all activities before given date (drafts) and type dividend
return ( return (
isBefore(date, new Date(activity.date)) && isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND activity.type === TypeOfOrder.DIVIDEND
@ -1411,7 +1430,7 @@ export class PortfolioService {
}) { }) {
return activities return activities
.filter((activity) => { .filter((activity) => {
// Filter out all activities before given date // Filter out all activities before given date (drafts)
return isBefore(date, new Date(activity.date)); return isBefore(date, new Date(activity.date));
}) })
.map(({ fee, SymbolProfile }) => { .map(({ fee, SymbolProfile }) => {
@ -1458,19 +1477,37 @@ export class PortfolioService {
}; };
} }
private getItems(orders: OrderWithAccount[], date = new Date(0)) { private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return orders return activities
.filter((order) => { .filter((activity) => {
// Filter out all orders before given date and type item // Filter out all activities before given date (drafts) and type item
return ( return (
isBefore(date, new Date(order.date)) && isBefore(date, new Date(activity.date)) &&
order.type === TypeOfOrder.ITEM activity.type === TypeOfOrder.ITEM
); );
}) })
.map((order) => { .map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
order.SymbolProfile.currency, SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities(activities: OrderWithAccount[]) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency this.request.user.Settings.settings.baseCurrency
); );
}) })
@ -1510,6 +1547,28 @@ export class PortfolioService {
return portfolioStart; return portfolioStart;
} }
private getStreaks({
investments,
savingsRate
}: {
investments: InvestmentItem[];
savingsRate: number;
}) {
let currentStreak = 0;
let longestStreak = 0;
for (const { investment } of investments) {
if (investment >= savingsRate) {
currentStreak++;
longestStreak = Math.max(longestStreak, currentStreak);
} else {
currentStreak = 0;
}
}
return { currentStreak, longestStreak };
}
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
@ -1559,6 +1618,7 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1591,6 +1651,7 @@ export class PortfolioService {
.plus(performanceInformation.performance.currentValue) .plus(performanceInformation.performance.currentValue)
.plus(items) .plus(items)
.plus(excludedAccountsAndActivities) .plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -1617,6 +1678,7 @@ export class PortfolioService {
fees, fees,
firstOrderDate, firstOrderDate,
items, items,
liabilities,
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
@ -1841,13 +1903,6 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getTotalByType( private getTotalByType(
orders: OrderWithAccount[], orders: OrderWithAccount[],
currency: string, currency: string,
@ -1874,4 +1929,11 @@ export class PortfolioService {
this.baseCurrency this.baseCurrency
); );
} }
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
} }

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
@ -13,6 +14,10 @@ export class RedisCacheService {
return await this.cache.get(key); return await this.cache.get(key);
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`;
}
public async remove(key: string) { public async remove(key: string) {
await this.cache.del(key); await this.cache.del(key);
} }

View File

@ -36,10 +36,12 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query() { query = '' } @Query('includeIndices') includeIndices: boolean = false,
@Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices,
query: query.toLowerCase(), query: query.toLowerCase(),
user: this.request.user user: this.request.user
}); });
@ -60,7 +62,7 @@ export class SymbolController {
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('includeHistoricalData') includeHistoricalData?: number @Query('includeHistoricalData') includeHistoricalData = 0
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(

View File

@ -81,9 +81,11 @@ export class SymbolService {
} }
public async lookup({ public async lookup({
includeIndices = false,
query, query,
user user
}: { }: {
includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<{ items: LookupItem[] }> {
@ -95,6 +97,7 @@ export class SymbolService {
try { try {
const { items } = await this.dataProviderService.search({ const { items } = await this.dataProviderService.search({
includeIndices,
query, query,
user user
}); });

View File

@ -166,7 +166,7 @@ export class UserService {
this.subscriptionService.getSubscription(Subscription); this.subscriptionService.getSubscription(Subscription);
if ( if (
Analytics?.activityCount % 20 === 0 && Analytics?.activityCount % 10 === 0 &&
user.subscription?.type === 'Basic' user.subscription?.type === 'Basic'
) { ) {
currentPermissions.push(permissions.enableSubscriptionInterstitial); currentPermissions.push(permissions.enableSubscriptionInterstitial);

File diff suppressed because it is too large Load Diff

View File

@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T { export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
const object = cloneDeep(aObject); const object = cloneDeep(aObject);
if (object) {
keys.forEach((key) => { keys.forEach((key) => {
object[key] = null; object[key] = null;
}); });
}
return object; return object;
} }

View File

@ -1,7 +1,9 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
@ -10,11 +12,12 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService); const configService = configApp.get<ConfigService>(ConfigService);
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: environment.production logger: environment.production
? ['error', 'log', 'warn'] ? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn'] : ['debug', 'error', 'log', 'verbose', 'warn']
}); });
app.enableCors(); app.enableCors();
app.enableVersioning({ app.enableVersioning({
defaultVersion: '1', defaultVersion: '1',
@ -32,6 +35,22 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities // Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' })); app.use(bodyParser.json({ limit: '10mb' }));
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})
);
}
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY'); const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;

View File

@ -16,6 +16,7 @@ export class ConfigurationService {
default: 'USD' default: 'USD'
}), }),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),

View File

@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
const result = await this.alphaVantage.data.search(aQuery); includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(query);
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {

View File

@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin'; return 'bitcoin';
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const { coins } = await get(); const { coins } = await get();
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {

View File

@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
let name = longName; let name = longName;
if (name) { if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', ''); name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', ''); name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', ''); name = name.replace('iShares III Public Limited Company - ', '');

View File

@ -1,3 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [ providers: [

View File

@ -1,3 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -27,7 +28,8 @@ export class DataProviderService {
private readonly dataProviderInterfaces: DataProviderInterface[], private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) { ) {
this.initialize(); this.initialize();
} }
@ -235,9 +237,43 @@ export class DataProviderService {
} = {}; } = {};
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); // Get items from cache
const itemsToFetch: IDataGatheringItem[] = [];
const promises = []; for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
} catch {}
}
if (!quoteString) {
itemsToFetch.push({ dataSource, symbol });
}
}
const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) {
Logger.debug(
`Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3
)} seconds`
);
}
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
return dataSource;
});
const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
@ -271,6 +307,15 @@ export class DataProviderService {
result result
)) { )) {
response[symbol] = dataProviderResponse; response[symbol] = dataProviderResponse;
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource],
symbol
}),
JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
} }
Logger.debug( Logger.debug(
@ -283,7 +328,7 @@ export class DataProviderService {
); );
try { try {
await this.marketDataService.updateMany({ this.marketDataService.updateMany({
data: Object.keys(response) data: Object.keys(response)
.filter((symbol) => { .filter((symbol) => {
return ( return (
@ -322,9 +367,11 @@ export class DataProviderService {
} }
public async search({ public async search({
includeIndices = false,
query, query,
user user
}: { }: {
includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<{ items: LookupItem[] }> {
@ -347,7 +394,12 @@ export class DataProviderService {
} }
for (const dataSource of dataSources) { for (const dataSource of dataSources) {
promises.push(this.getDataProvider(DataSource[dataSource]).search(query)); promises.push(
this.getDataProvider(DataSource[dataSource]).search({
includeIndices,
query
})
);
} }
const searchResults = await Promise.all(promises); const searchResults = await Promise.all(promises);

View File

@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return !symbol.endsWith('.FOREX'); return !symbol.endsWith('.FOREX');
}) })
.map((symbol) => { .map((symbol) => {
return this.search(symbol); return this.search({ query: symbol });
}) })
); );
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US'; return 'AAPL.US';
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
const searchResult = await this.getSearchResult(aQuery); includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(query);
return { return {
items: searchResult items: searchResult

View File

@ -143,12 +143,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL'; return 'AAPL';
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`, `${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
'GET', 'GET',
'json', 'json',
200 200

View File

@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX'; return 'INDEXSP:.INX';
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
dataSource: this.getName(), dataSource: this.getName(),
name: { name: {
mode: 'insensitive', mode: 'insensitive',
startsWith: aQuery startsWith: query
} }
}, },
{ {
dataSource: this.getName(), dataSource: this.getName(),
symbol: { symbol: {
mode: 'insensitive', mode: 'insensitive',
startsWith: aQuery startsWith: query
} }
} }
] ]

View File

@ -42,5 +42,11 @@ export interface DataProviderInterface {
getTestSymbol(): string; getTestSymbol(): string;
search(aQuery: string): Promise<{ items: LookupItem[] }>; search({
includeIndices,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }>;
} }

View File

@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[{ symbol, dataSource: this.getName() }] [{ symbol, dataSource: this.getName() }]
); );
const { defaultMarketPrice, selector, url } = const {
symbolProfile.scraperConfiguration ?? {}; defaultMarketPrice,
headers = {},
selector,
url
} = symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) { if (defaultMarketPrice) {
const historical: { const historical: {
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const get = bent(url, 'GET', 'string', 200, {}); const get = bent(url, 'GET', 'string', 200, headers);
const html = await get(); const html = await get();
const $ = cheerio.load(html); const $ = cheerio.load(html);
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({ let items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
dataSource: this.getName(), dataSource: this.getName(),
name: { name: {
mode: 'insensitive', mode: 'insensitive',
startsWith: aQuery startsWith: query
} }
}, },
{ {
dataSource: this.getName(), dataSource: this.getName(),
symbol: { symbol: {
mode: 'insensitive', mode: 'insensitive',
startsWith: aQuery startsWith: query
} }
} }
] ]

View File

@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -275,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
return 'AAPL'; return 'AAPL';
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const searchResult = await yahooFinance.search(aQuery); const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
if (includeIndices) {
quoteTypes.push('INDEX');
}
const searchResult = await yahooFinance.search(query);
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
@ -295,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.baseCurrency this.baseCurrency
) )
)) || )) ||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType) quoteTypes.includes(quoteType)
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {

View File

@ -12,6 +12,7 @@ export class ImpersonationService {
) {} ) {}
public async validateImpersonationId(aId = '') { public async validateImpersonationId(aId = '') {
if (this.request.user) {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
GranteeUser: { id: this.request.user.id }, GranteeUser: { id: this.request.user.id },
@ -20,7 +21,7 @@ export class ImpersonationService {
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject?.userId; return accessObject.userId;
} else if ( } else if (
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
@ -29,6 +30,19 @@ export class ImpersonationService {
) { ) {
return aId; return aId;
} }
} else {
// Public access
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: null,
User: { id: aId }
}
});
if (accessObject?.userId) {
return accessObject.userId;
}
}
return null; return null;
} }

View File

@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string; BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string; BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string; DATA_SOURCE_IMPORT: string;

View File

@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
export class SymbolProfileService { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: UniqueAsset) { public async delete({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
@ -90,11 +96,12 @@ export class SymbolProfileService {
public updateSymbolProfile({ public updateSymbolProfile({
comment, comment,
dataSource, dataSource,
scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { comment, symbolMapping }, data: { comment, scraperConfiguration, symbolMapping },
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
} }
@ -189,6 +196,8 @@ export class SymbolProfileService {
if (scraperConfiguration) { if (scraperConfiguration) {
return { return {
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number, defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
selector: scraperConfiguration.selector as string, selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string url: scraperConfiguration.url as string
}; };

View File

@ -18,36 +18,6 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})), })),
...[
'about/changelog',
/////
'a-propos/changelog',
'informazioni-su/changelog',
'over/changelog',
'sobre/changelog',
'ueber-uns/changelog'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
})),
...[
'about/privacy-policy',
/////
'a-propos/politique-de-confidentialite',
'informazioni-su/informativa-sulla-privacy',
'over/privacybeleid',
'sobre/politica-de-privacidad',
'ueber-uns/datenschutzbestimmungen'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
})),
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -77,97 +47,6 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})), })),
{
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'blog/2022/10/hacktoberfest-2022',
loadChildren: () =>
import(
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
).then((m) => m.Hacktoberfest2022PageModule)
},
{
path: 'blog/2022/11/black-friday-2022',
loadChildren: () =>
import(
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule)
},
{
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
loadChildren: () =>
import(
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
},
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
},
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
@ -179,6 +58,7 @@ const routes: Routes = [
'domande-piu-frequenti', 'domande-piu-frequenti',
'foire-aux-questions', 'foire-aux-questions',
'haeufig-gestellte-fragen', 'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes', 'preguntas-mas-frecuentes',
'vaak-gestelde-vragen' 'vaak-gestelde-vragen'
].map((path) => ({ ].map((path) => ({
@ -243,6 +123,7 @@ const routes: Routes = [
'pricing', 'pricing',
///// /////
'precios', 'precios',
'precos',
'preise', 'preise',
'prezzi', 'prezzi',
'prijzen', 'prijzen',
@ -259,6 +140,7 @@ const routes: Routes = [
///// /////
'enregistrement', 'enregistrement',
'iscrizione', 'iscrizione',
'registo',
'registratie', 'registratie',
'registrierung', 'registrierung',
'registro' 'registro'

View File

@ -66,7 +66,9 @@
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="['/markets']">Markets</a></li> <li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a>
</li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li> <li><a i18n [routerLink]="['/resources']">Resources</a></li>
</ul> </ul>
</div> </div>
@ -78,15 +80,16 @@
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'changelog']" <a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
>Changelog & License</a
>
</li> </li>
<li><a i18n [routerLink]="['/features']">Features</a></li> <li><a i18n [routerLink]="['/features']">Features</a></li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a> <a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li>
<a i18n [routerLink]="['/about', 'license']">License</a>
</li>
<li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
@ -98,9 +101,13 @@
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a href="https://status.ghostfol.io" title="Ghostfolio Status" <a
>Status</a class="align-items-baseline d-flex"
> href="https://status.ghostfol.io"
target="_blank"
title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li> </li>
</ul> </ul>
</div> </div>
@ -109,24 +116,30 @@
<ul class="list-unstyled"> <ul class="list-unstyled">
<li> <li>
<a <a
class="align-items-baseline d-flex"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
> ></a>
</li> </li>
<li> <li>
<a <a
class="align-items-baseline d-flex"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack channel" target="_blank"
>Slack</a title="Join the Ghostfolio Slack community"
> >Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li> </li>
<li> <li>
<a <a
class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
target="_blank"
title="Follow Ghostfolio on Twitter" title="Follow Ghostfolio on Twitter"
>Twitter</a >Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
> ></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
<li> <li>
@ -147,6 +160,9 @@
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -33,7 +33,9 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public user: User; public user: User;
@ -69,6 +71,16 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableSubscription permissions.enableSubscription
); );
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
permissions.enableStatistics
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {

View File

@ -47,7 +47,7 @@
[matMenuTriggerFor]="transactionMenu" [matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
@ -57,6 +57,6 @@
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>

View File

@ -12,8 +12,9 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="user?.settings?.baseCurrency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency" [value]="valueInBaseCurrency"
></gf-value> ></gf-value>
</div> </div>
@ -24,8 +25,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="currency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="currency"
[value]="balance" [value]="balance"
>Cash Balance</gf-value >Cash Balance</gf-value
> >
@ -34,8 +36,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="currency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="currency"
[value]="equity" [value]="equity"
>Equity</gf-value >Equity</gf-value
> >

View File

@ -207,6 +207,30 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="comment">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<button
*ngIf="element.comment"
class="mx-1 no-min-width px-2"
mat-button
title="Note"
(click)="onOpenComment(element.comment); $event.stopPropagation()"
>
<ion-icon name="document-text-outline"></ion-icon>
</button>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -216,7 +240,7 @@
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">

View File

@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'balance', 'balance',
'value', 'value',
'currency', 'currency',
'valueInBaseCurrency' 'valueInBaseCurrency',
'comment'
]; ];
if (this.showActions) { if (this.showActions) {
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public onOpenComment(aComment: string) {
alert(aComment);
}
public onUpdateAccount(aAccount: AccountModel) { public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }

View File

@ -108,7 +108,7 @@
[matMenuTriggerFor]="jobActionsMenu" [matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before"> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)"> <button mat-menu-item (click)="onViewData(job.data)">

View File

@ -1,4 +1,5 @@
import { import {
AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@ -7,23 +8,26 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } 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 { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource } from '@prisma/client'; import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component'; import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
styleUrls: ['./admin-market-data.scss'], styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent
implements AfterViewInit, OnDestroy, OnInit
{
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = []; public activeFilters: Filter[] = [];
@ -73,6 +80,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
public isLoading = false; public isLoading = false;
public placeholder = ''; public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -80,7 +89,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -99,6 +107,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
dataSource: params['dataSource'], dataSource: params['dataSource'],
symbol: params['symbol'] symbol: params['symbol']
}); });
} else if (params['createAssetProfileDialog']) {
this.openCreateAssetProfileDialog();
} }
}); });
@ -113,33 +123,39 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
); );
} }
}); });
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.subscribe((filters) => {
this.activeFilters = filters;
this.loadData();
});
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe(
({ active: sortColumn, direction }: Sort) => {
this.paginator.pageIndex = 0;
this.loadData({
sortColumn,
sortDirection: <Prisma.SortOrder>direction,
pageIndex: this.paginator.pageIndex
});
}
);
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
this.filters$ public onChangePage(page: PageEvent) {
.pipe( this.loadData({
distinctUntilChanged(), pageIndex: page.pageIndex,
switchMap((filters) => { sortColumn: this.sort.active,
this.isLoading = true; sortDirection: <Prisma.SortOrder>this.sort.direction
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}); });
} }
@ -208,6 +224,47 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private loadData(
{
pageIndex,
sortColumn,
sortDirection
}: {
pageIndex: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
} = { pageIndex: 0 }
) {
this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
this.adminService
.fetchAdminMarketData({
sortColumn,
sortDirection,
filters: this.activeFilters,
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ count, marketData }) => {
this.totalItems = count;
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
symbol symbol
@ -241,4 +298,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}); });
}); });
} }
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
autoFocus: false,
data: <CreateAssetProfileDialogParams>{
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(
switchMap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
return this.adminService.fetchAdminMarketData({
filters: this.activeFilters,
take: this.pageSize
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
} }

View File

@ -56,7 +56,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container> <ng-container i18n>First Activity</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@ -74,7 +74,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="marketDataItemCount"> <ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Historical Data</ng-container> <ng-container i18n>Historical Data</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -83,7 +83,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="sectorsCount"> <ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Sectors Count</ng-container> <ng-container i18n>Sectors Count</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -92,7 +92,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="countriesCount"> <ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Countries Count</ng-container> <ng-container i18n>Countries Count</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -140,7 +140,7 @@
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <button
@ -162,6 +162,40 @@
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })" (click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
></tr> ></tr>
</table> </table>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none':
(isLoading && totalItems === 0) ||
totalItems <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<ngx-skeleton-loader
*ngIf="isLoading && totalItems === 0"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div> </div>
</div> </div>
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div> </div>

View File

@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component'; import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module'; import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@NgModule({ @NgModule({
declarations: [AdminMarketDataComponent], declarations: [AdminMarketDataComponent],
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
CommonModule, CommonModule,
GfActivitiesFilterModule, GfActivitiesFilterModule,
GfAssetProfileDialogModule, GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -2,4 +2,11 @@
:host { :host {
display: block; display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
} }

View File

@ -13,6 +13,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ public assetProfileForm = this.formBuilder.group({
comment: '', comment: '',
scraperConfiguration: '',
symbolMapping: '' symbolMapping: ''
}); });
public assetSubClass: string; public assetSubClass: string;
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileForm.setValue({ this.assetProfileForm.setValue({
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {}
),
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}) symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
}); });
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public onSubmit() { public onSubmit() {
let scraperConfiguration = {};
let symbolMapping = {}; let symbolMapping = {};
try {
scraperConfiguration = JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].value
);
} catch {}
try { try {
symbolMapping = JSON.parse( symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value this.assetProfileForm.controls['symbolMapping'].value
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {} } catch {}
const assetProfileData: UpdateAssetProfileDto = { const assetProfileData: UpdateAssetProfileDto = {
scraperConfiguration,
symbolMapping, symbolMapping,
comment: this.assetProfileForm.controls['comment'].value ?? null comment: this.assetProfileForm.controls['comment'].value ?? null
}; };

View File

@ -162,6 +162,17 @@
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Scraper Configuration</mat-label>
<textarea
cdkTextareaAutosize
formControlName="scraperConfiguration"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label> <mat-label i18n>Note</mat-label>

View File

@ -0,0 +1,53 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog',
templateUrl: 'create-asset-profile-dialog.html'
})
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup;
public constructor(
public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({
searchSymbol: new FormControl(null, [Validators.required])
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
this.dialogRef.close({
dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
});
}
public ngOnDestroy() {}
}

View File

@ -0,0 +1,28 @@
<form
class="d-flex flex-column h-100"
[formGroup]="createAssetProfileForm"
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
formControlName="searchSymbol"
[includeIndices]="true"
/>
</mat-form-field>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!createAssetProfileForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@NgModule({
declarations: [CreateAssetProfileDialog],
imports: [
CommonModule,
FormsModule,
GfSymbolAutocompleteModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCreateAssetProfileDialogModule {}

View File

@ -0,0 +1,4 @@
export interface CreateAssetProfileDialogParams {
deviceType: string;
locale: string;
}

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
private fetchAdminData() { private fetchAdminData() {
this.dataService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => { .subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {

View File

@ -82,7 +82,7 @@
[matMenuTriggerFor]="platformMenu" [matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #platformMenu="matMenu" xPosition="before"> <mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)"> <button mat-menu-item (click)="onUpdatePlatform(element)">

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -30,6 +31,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
} }
private fetchAdminData() { private fetchAdminData() {
this.dataService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => { .subscribe(({ users }) => {

View File

@ -109,7 +109,7 @@
[matMenuTriggerFor]="userMenu" [matMenuTriggerFor]="userMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #userMenu="matMenu" xPosition="before"> <mat-menu #userMenu="matMenu" xPosition="before">
<button <button

View File

@ -1,6 +1,8 @@
<div class="mb-2 row"> <div class="mb-2 row">
<div class="col-md-6 col-xs-12 d-flex"> <div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"> <div
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
>
<span i18n>Performance</span> <span i18n>Performance</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'" *ngIf="user?.subscription?.type === 'Basic'"

View File

@ -8,18 +8,23 @@
<gf-logo [label]="pageTitle"></gf-logo> <gf-logo [label]="pageTitle"></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0">
<li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen', 'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen',
'text-decoration-underline': 'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen' currentRoute === 'home' || currentRoute === 'zen'
}" }"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -31,6 +36,8 @@
[routerLink]="['/portfolio']" [routerLink]="['/portfolio']"
>Portfolio</a >Portfolio</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -42,8 +49,9 @@
[routerLink]="['/accounts']" [routerLink]="['/accounts']"
>Accounts</a >Accounts</a
> >
</li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a <a
*ngIf="hasPermissionToAccessAdminControl"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
@ -54,6 +62,8 @@
[routerLink]="['/admin']" [routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -65,10 +75,14 @@
[routerLink]="['/resources']" [routerLink]="['/resources']"
>Resources</a >Resources</a
> >
<a </li>
<li
*ngIf=" *ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription && user?.subscription?.type === 'Basic'
" "
class="list-inline-item"
>
<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
@ -79,6 +93,8 @@
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -90,6 +106,8 @@
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >
</li>
<li class="list-inline-item">
<button <button
class="no-min-width px-1" class="no-min-width px-1"
mat-flat-button mat-flat-button
@ -146,7 +164,8 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen' 'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen'
}" }"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
@ -198,7 +217,8 @@
> >
<a <a
*ngIf=" *ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
" "
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
@ -218,6 +238,8 @@
<hr class="d-flex d-sm-none m-0" /> <hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button> <button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu> </mat-menu>
</li>
</ul>
</ng-container> </ng-container>
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<a <a
@ -231,6 +253,8 @@
></gf-logo> ></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0">
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -242,6 +266,8 @@
[routerLink]="['/features']" [routerLink]="['/features']"
>Features</a >Features</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
@ -253,8 +279,9 @@
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >
</li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a <a
*ngIf="hasPermissionForSubscription"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -264,8 +291,12 @@
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
<a </li>
<li
*ngIf="hasPermissionToAccessFearAndGreedIndex" *ngIf="hasPermissionToAccessFearAndGreedIndex"
class="list-inline-item"
>
<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
@ -276,22 +307,32 @@
[routerLink]="['/markets']" [routerLink]="['/markets']"
>Markets</a >Markets</a
> >
</li>
<li class="list-inline-item">
<a <a
class="d-none d-sm-block no-min-width" class="d-none d-sm-block no-min-width p-1"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button mat-flat-button
><ion-icon name="logo-github"></ion-icon ><ion-icon name="logo-github"></ion-icon
></a> ></a>
</li>
<li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()"> <button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
<a </li>
<li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item"
>
<a
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/register']" [routerLink]="['/register']"
><ng-container i18n>Get started</ng-container> ><ng-container i18n>Get started</ng-container>
</a> </a>
</li>
</ul>
</ng-container> </ng-container>
</mat-toolbar> </mat-toolbar>

View File

@ -13,8 +13,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalBuy" [value]="isLoading ? undefined : summary?.totalBuy"
></gf-value> ></gf-value>
</div> </div>
@ -24,8 +25,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalSell" [value]="isLoading ? undefined : summary?.totalSell"
></gf-value> ></gf-value>
</div> </div>
@ -38,8 +40,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.committedFunds" [value]="isLoading ? undefined : summary?.committedFunds"
></gf-value> ></gf-value>
</div> </div>
@ -49,8 +52,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance" [value]="isLoading ? undefined : summary?.currentGrossPerformance"
></gf-value> ></gf-value>
</div> </div>
@ -79,8 +83,9 @@
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span> <span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.fees" [value]="isLoading ? undefined : summary?.fees"
></gf-value> ></gf-value>
</div> </div>
@ -93,8 +98,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance" [value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value> ></gf-value>
</div> </div>
@ -121,8 +127,9 @@
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue" [value]="isLoading ? undefined : summary?.currentValue"
></gf-value> ></gf-value>
</div> </div>
@ -132,8 +139,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.items" [value]="isLoading ? undefined : summary?.items"
></gf-value> ></gf-value>
</div> </div>
@ -152,8 +160,9 @@
></ion-icon> ></ion-icon>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund" [value]="isLoading ? undefined : summary?.emergencyFund"
></gf-value> ></gf-value>
</div> </div>
@ -163,8 +172,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash" [value]="isLoading ? undefined : summary?.cash"
></gf-value> ></gf-value>
</div> </div>
@ -174,8 +184,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities" [value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
></gf-value> ></gf-value>
</div> </div>
@ -183,13 +194,34 @@
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end">
<span
*ngIf="summary?.liabilities || summary?.liabilities === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div> <div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth" [value]="isLoading ? undefined : summary?.netWorth"
></gf-value> ></gf-value>
</div> </div>
@ -217,8 +249,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend" [value]="isLoading ? undefined : summary?.dividend"
></gf-value> ></gf-value>
</div> </div>

View File

@ -215,6 +215,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
this.benchmarkDataItems = this.benchmarkDataItems.map(
({ date, value }) => {
return {
date,
value: value === 0 ? null : value
};
}
);
if (Number.isInteger(this.quantity)) { if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { } else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {

View File

@ -12,8 +12,9 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="value" [value]="value"
></gf-value> ></gf-value>
</div> </div>
@ -40,8 +41,9 @@
i18n i18n
size="medium" size="medium"
[colorizeSign]="true" [colorizeSign]="true"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformance"
>Change</gf-value >Change</gf-value
> >
@ -61,8 +63,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="averagePrice" [value]="averagePrice"
>Average Unit Price</gf-value >Average Unit Price</gf-value
> >
@ -71,8 +74,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="marketPrice" [value]="marketPrice"
>Market Price</gf-value >Market Price</gf-value
> >
@ -81,9 +85,10 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="minPrice" [value]="minPrice"
>Minimum Price</gf-value >Minimum Price</gf-value
> >
@ -92,9 +97,10 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="maxPrice" [value]="maxPrice"
>Maximum Price</gf-value >Maximum Price</gf-value
> >
@ -113,8 +119,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="investment" [value]="investment"
>Investment</gf-value >Investment</gf-value
> >
@ -123,8 +130,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency" [value]="dividendInBaseCurrency"
>Dividend</gf-value >Dividend</gf-value
> >
@ -133,8 +141,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="feeInBaseCurrency" [value]="feeInBaseCurrency"
>Fees</gf-value >Fees</gf-value
> >

View File

@ -45,8 +45,9 @@
<gf-value <gf-value
class="mr-3" class="mr-3"
[colorizeSign]="true" [colorizeSign]="true"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformance" [value]="position?.netPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value

View File

@ -22,13 +22,38 @@ const routes: Routes = [
(m) => m.ChangelogPageModule (m) => m.ChangelogPageModule
) )
}, },
{ ...[
path: 'privacy-policy', 'license',
/////
'licenca',
'licence',
'licencia',
'licentie',
'lizenz',
'licenza'
].map((path) => ({
path,
loadChildren: () =>
import('./license/license-page.module').then(
(m) => m.LicensePageModule
)
})),
...[
'privacy-policy',
/////
'datenschutzbestimmungen',
'informativa-sulla-privacy',
'politique-de-confidentialite',
'politica-de-privacidad',
'politica-de-privacidade',
'privacybeleid'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then( import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule (m) => m.PrivacyPolicyPageModule
) )
} }))
], ],
component: AboutPageComponent, component: AboutPageComponent,
path: '', path: '',

View File

@ -53,9 +53,14 @@ export class AboutPageComponent implements OnDestroy, OnInit {
}, },
{ {
iconName: 'sparkles-outline', iconName: 'sparkles-outline',
label: $localize`Changelog & License`, label: $localize`Changelog`,
path: ['/about', 'changelog'] path: ['/about', 'changelog']
}, },
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/about', 'license']
},
{ {
iconName: 'shield-checkmark-outline', iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`, label: $localize`Privacy Policy`,

View File

@ -9,7 +9,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: ChangelogPageComponent, component: ChangelogPageComponent,
path: '', path: '',
title: $localize`Changelog & License` title: $localize`Changelog`
} }
]; ];

View File

@ -1,23 +1,10 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1>
<mat-card appearance="outlined" class="changelog"> <div class="changelog">
<mat-card-content>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown> <markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content> </div>
</mat-card>
</div>
</div>
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card appearance="outlined">
<mat-card-content>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>
</div> </div>

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 { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { ChangelogPageRoutingModule } from './changelog-page-routing.module'; import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
@ -11,8 +10,7 @@ import { ChangelogPageComponent } from './changelog-page.component';
imports: [ imports: [
ChangelogPageRoutingModule, ChangelogPageRoutingModule,
CommonModule, CommonModule,
MarkdownModule.forChild(), MarkdownModule.forChild()
MatCardModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -2,8 +2,7 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-mdc-card { .changelog {
&.changelog {
::ng-deep { ::ng-deep {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
@ -35,7 +34,6 @@
} }
} }
} }
}
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));

View File

@ -2,14 +2,14 @@ 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 { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component'; import { LicensePageComponent } from './license-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: BlackFriday2022PageComponent, component: LicensePageComponent,
path: '', path: '',
title: 'Black Friday 2022' title: $localize`License`
} }
]; ];
@ -17,4 +17,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class BlackFriday2022RoutingModule {} export class LicensePageRoutingModule {}

View File

@ -0,0 +1,19 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-license-page',
styleUrls: ['./license-page.scss'],
templateUrl: './license-page.html'
})
export class LicensePageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,10 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>License</h1>
<div>
<markdown [src]="'../assets/LICENSE'"></markdown>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { LicensePageRoutingModule } from './license-page-routing.module';
import { LicensePageComponent } from './license-page.component';
@NgModule({
declarations: [LicensePageComponent],
imports: [LicensePageRoutingModule, CommonModule, MarkdownModule.forChild()],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LicensePageModule {}

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { environment } from '@ghostfolio/client/../environments/environment'; import { environment } from '@ghostfolio/client/../environments/environment';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -15,8 +14,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './about-overview-page.html' templateUrl: './about-overview-page.html'
}) })
export class AboutOverviewPageComponent implements OnDestroy, OnInit { export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public user: User; public user: User;
@ -36,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
permissions.enableBlog permissions.enableBlog
); );
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
globalPermissions, globalPermissions,
permissions.enableSubscription permissions.enableSubscription

View File

@ -19,13 +19,11 @@
title="GNU Affero General Public License" title="GNU Affero General Public License"
>AGPL-3.0 license</a >AGPL-3.0 license</a
> >
<ng-container *ngIf="hasPermissionForStatistics">
and we share aggregated and we share aggregated
<a <a title="Open Startup" [routerLink]="['/open']">key metrics</a>
href="https://ghostfol.io/{{ defaultLanguageCode }}/open" of the platforms performance</ng-container
title="Open Startup" >. The project has been initiated by
>key metrics</a
>
of the platforms performance. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul" <a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a >Thomas Kaul</a
> >
@ -53,8 +51,9 @@
<a <a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack community</a >Slack</a
>, tweet to >
community, tweet to
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Tweet to Ghostfolio on Twitter"
@ -94,7 +93,7 @@
class="mx-2" class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button mat-icon-button
title="Join the Ghostfolio Slack channel" title="Join the Ghostfolio Slack community"
> >
<ion-icon name="logo-slack"></ion-icon> <ion-icon name="logo-slack"></ion-icon>
</a> </a>

View File

@ -1,13 +1,19 @@
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 { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module'; import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module';
import { AboutOverviewPageComponent } from './about-overview-page.component'; import { AboutOverviewPageComponent } from './about-overview-page.component';
@NgModule({ @NgModule({
declarations: [AboutOverviewPageComponent], declarations: [AboutOverviewPageComponent],
imports: [AboutOverviewPageRoutingModule, CommonModule, MatButtonModule], imports: [
AboutOverviewPageRoutingModule,
CommonModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class AboutOverviewPageModule {} export class AboutOverviewPageModule {}

View File

@ -156,10 +156,10 @@
>Nederlands (<ng-container i18n>Community</ng-container >Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
<!--<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
>--> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -153,6 +153,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public openUpdateAccountDialog({ public openUpdateAccountDialog({
accountType, accountType,
balance, balance,
comment,
currency, currency,
id, id,
isExcluded, isExcluded,
@ -164,6 +165,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
account: { account: {
accountType, accountType,
balance, balance,
comment,
currency, currency,
id, id,
isExcluded, isExcluded,
@ -232,6 +234,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
account: { account: {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0, balance: 0,
comment: null,
currency: this.user?.settings?.baseCurrency, currency: this.user?.settings?.baseCurrency,
isExcluded: false, isExcluded: false,
name: null, name: null,

View File

@ -37,6 +37,7 @@
type="number" type="number"
[(ngModel)]="data.account.balance" [(ngModel)]="data.account.balance"
/> />
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }"> <div [ngClass]="{ 'd-none': platforms?.length < 1 }">
@ -50,6 +51,19 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
matInput
name="comment"
[(ngModel)]="data.account.comment"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="mb-3 px-2"> <div class="mb-3 px-2">
<mat-checkbox <mat-checkbox
color="primary" color="primary"

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HalloGhostfolioPageComponent,
path: '',
title: 'Hallo Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HalloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hallo-ghostfolio-page', selector: 'gf-hallo-ghostfolio-page',
styleUrls: ['./hallo-ghostfolio-page.scss'], standalone: true,
templateUrl: './hallo-ghostfolio-page.html' templateUrl: './hallo-ghostfolio-page.html'
}) })
export class HalloGhostfolioPageComponent {} export class HalloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HalloGhostfolioPageRoutingModule } from './hallo-ghostfolio-page-routing.module';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
@NgModule({
declarations: [HalloGhostfolioPageComponent],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HalloGhostfolioPageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HelloGhostfolioPageComponent,
path: '',
title: 'Hello Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HelloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hello-ghostfolio-page', selector: 'gf-hello-ghostfolio-page',
styleUrls: ['./hello-ghostfolio-page.scss'], standalone: true,
templateUrl: './hello-ghostfolio-page.html' templateUrl: './hello-ghostfolio-page.html'
}) })
export class HelloGhostfolioPageComponent {} export class HelloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HelloGhostfolioPageRoutingModule } from './hello-ghostfolio-page-routing.module';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
@NgModule({
declarations: [HelloGhostfolioPageComponent],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HelloGhostfolioPageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FirstMonthsInOpenSourcePageComponent,
path: '',
title: 'First months in Open Source'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirstMonthsInOpenSourceRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-first-months-in-open-source-page', selector: 'gf-first-months-in-open-source-page',
styleUrls: ['./first-months-in-open-source-page.scss'], standalone: true,
templateUrl: './first-months-in-open-source-page.html' templateUrl: './first-months-in-open-source-page.html'
}) })
export class FirstMonthsInOpenSourcePageComponent {} export class FirstMonthsInOpenSourcePageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
@NgModule({
declarations: [FirstMonthsInOpenSourcePageComponent],
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirstMonthsInOpenSourcePageModule {}

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