Browse Source

Merge pull request #132 from dandevaud/mr/Mr-Upstream-changes-2024-10-30

Mr/mr upstream changes 2024 10 30
pull/5027/head
dandevaud 8 months ago
committed by GitHub
parent
commit
1a891ffaee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .eslintrc.json
  2. 2
      .husky/pre-commit
  3. 100
      CHANGELOG.md
  4. 1
      Dockerfile
  5. 6
      README.md
  6. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  7. 4
      apps/api/src/app/account/account.service.ts
  8. 2
      apps/api/src/app/account/create-account.dto.ts
  9. 2
      apps/api/src/app/account/update-account.dto.ts
  10. 4
      apps/api/src/app/admin/admin.service.ts
  11. 4
      apps/api/src/app/admin/queue/queue.controller.ts
  12. 4
      apps/api/src/app/admin/queue/queue.service.ts
  13. 13
      apps/api/src/app/auth/auth.controller.ts
  14. 8
      apps/api/src/app/auth/google.strategy.ts
  15. 4
      apps/api/src/app/auth/interfaces/simplewebauthn.ts
  16. 11
      apps/api/src/app/auth/web-auth.service.ts
  17. 4
      apps/api/src/app/benchmark/benchmark.service.ts
  18. 7
      apps/api/src/app/health/health.controller.ts
  19. 6
      apps/api/src/app/order/order.service.ts
  20. 8
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  21. 48
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  22. 4
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  23. 129
      apps/api/src/app/portfolio/portfolio.service.ts
  24. 9
      apps/api/src/app/portfolio/rules.service.ts
  25. 4
      apps/api/src/app/redis-cache/redis-cache.module.ts
  26. 4
      apps/api/src/app/subscription/subscription.controller.ts
  27. 4
      apps/api/src/app/subscription/subscription.service.ts
  28. 14
      apps/api/src/app/user/update-user-setting.dto.ts
  29. 2
      apps/api/src/app/user/user.controller.ts
  30. 60
      apps/api/src/app/user/user.service.ts
  31. 24
      apps/api/src/assets/sitemap.xml
  32. 2
      apps/api/src/helper/object.helper.ts
  33. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  34. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  35. 10
      apps/api/src/models/rule.ts
  36. 16
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  37. 6
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  38. 6
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  39. 16
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  40. 84
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  41. 84
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  42. 6
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  43. 16
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  44. 18
      apps/api/src/services/api/api.service.ts
  45. 18
      apps/api/src/services/configuration/configuration.service.ts
  46. 2
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  47. 8
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  48. 4
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  49. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  50. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  51. 2
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  52. 4
      apps/api/src/services/interfaces/environment.interface.ts
  53. 23
      apps/api/src/services/market-data/market-data.service.ts
  54. 16
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  55. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  56. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  57. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts
  58. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  59. 3
      apps/api/tsconfig.app.json
  60. 11
      apps/client/src/app/app.component.html
  61. 7
      apps/client/src/app/app.component.scss
  62. 9
      apps/client/src/app/app.component.ts
  63. 5
      apps/client/src/app/components/access-table/access-table.component.ts
  64. 8
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  65. 4
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  66. 11
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  67. 29
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  68. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  69. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  70. 4
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  71. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  72. 35
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  73. 53
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  74. 6
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  75. 39
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  76. 42
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  77. 2
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.scss
  78. 4
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts
  79. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  80. 3
      apps/client/src/app/components/admin-users/admin-users.component.ts
  81. 2
      apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts
  82. 21
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  83. 7
      apps/client/src/app/components/dialog-footer/dialog-footer.component.ts
  84. 7
      apps/client/src/app/components/dialog-header/dialog-header.component.ts
  85. 9
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts
  86. 3
      apps/client/src/app/components/header/header.component.html
  87. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  88. 7
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  89. 3
      apps/client/src/app/components/home-market/home-market.component.ts
  90. 10
      apps/client/src/app/components/home-market/home-market.html
  91. 4
      apps/client/src/app/components/home-market/home-market.module.ts
  92. 35
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  93. 2
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  94. 7
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  95. 6
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  96. 14
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  97. 90
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  98. 2
      apps/client/src/app/components/rule/rule.component.html
  99. 19
      apps/client/src/app/components/rule/rule.component.ts
  100. 1
      apps/client/src/app/components/rules/rules.component.html

10
.eslintrc.json

@ -39,6 +39,7 @@
"plugin:@typescript-eslint/stylistic-type-checked" "plugin:@typescript-eslint/stylistic-type-checked"
], ],
"rules": { "rules": {
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/dot-notation": "off", "@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [ "@typescript-eslint/explicit-member-accessibility": [
"off", "off",
@ -142,14 +143,7 @@
// The following rules are part of @typescript-eslint/stylistic-type-checked // The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved // and can be remove once solved
"@typescript-eslint/consistent-type-definitions": "warn", "@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true
"@typescript-eslint/consistent-type-assertions": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
"@typescript-eslint/consistent-indexed-object-style": "warn",
"@typescript-eslint/consistent-generic-constructors": "warn"
} }
} }
], ],

2
.husky/pre-commit

@ -1,6 +1,6 @@
# Run linting and stop the commit process if any errors are found # Run linting and stop the commit process if any errors are found
# --quiet suppresses warnings (temporary until all warnings are fixed) # --quiet suppresses warnings (temporary until all warnings are fixed)
npm run lint --quiet || exit 1 npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
# Check formatting on modified and uncommitted files, stop the commit if issues are found # Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:check --uncommitted || exit 1 npm run format:check --uncommitted || exit 1

100
CHANGELOG.md

@ -7,19 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26
### Changed
- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `prisma` from version `5.20.0` to `5.21.1`
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23
### Added
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service
### Changed
- Improved the font colors of the chart of the holdings tab on the home page (experimental)
- Optimized the dialog sizes for mobile (full screen)
- Optimized the git-hook via `husky` to lint only affected projects before a commit
- Upgraded `angular` from version `18.1.1` to `18.2.8`
- Upgraded `Nx` from version `19.5.6` to `20.0.3`
### Fixed
- Fixed the warning `export was not found` in connection with `GetValuesParams`
- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
## 2.117.0 - 2024-10-19
### Added
- Added the logotype to the footer
- Added the data providers management to the admin control panel
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the carousel component for the testimonial section on the landing page
## 2.116.0 - 2024-10-17
### Added
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page
- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
### Changed
- Improved the empty state in the benchmarks of the markets overview
- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental)
- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.115.0 - 2024-10-14
### Added ### Added
- Added the name to the tooltip of the chart of the holdings tab on the home page (experimental) - Added the name to the tooltip of the chart of the holdings tab on the home page (experimental)
### Changed ### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the labels of the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing sliders (experimental)
- Refactored the rule thresholds in the _X-ray_ section (experimental)
- Exposed the timeout of the portfolio snapshot computation as an environment variable (`PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT`) - Exposed the timeout of the portfolio snapshot computation as an environment variable (`PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT`)
- Harmonized the processor concurrency environment variables
- Improved the portfolio unit tests to work with exported activity files - Improved the portfolio unit tests to work with exported activity files
- Enabled the `noUnusedLocals` compiler option in the `tsconfig`
- Enabled the `noUnusedParameters` compiler option in the `tsconfig`
### Fixed ### Fixed
- Considered the language of the user settings on login with _Security Token_ - Considered the language of the user settings on login with _Security Token_
### Todo
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE` to `PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA` to `PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT` to `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.114.0 - 2024-10-10 ## 2.114.0 - 2024-10-10
### Added ### Added

1
Dockerfile

@ -61,6 +61,7 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chmod 0700 /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node USER node

6
README.md

@ -177,6 +177,12 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
`200 OK` `200 OK`
```
{
"status": "OK"
}
```
### Import Activities ### Import Activities
#### Prerequisites #### Prerequisites

2
apps/api/src/app/account-balance/account-balance.service.ts

@ -88,7 +88,7 @@ export class AccountBalanceService {
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: <string>where.userId userId: where.userId as string
}) })
); );

4
apps/api/src/app/account/account.service.ts

@ -211,8 +211,8 @@ export class AccountService {
const { data, where } = params; const { data, where } = params;
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: <string>data.id, accountId: data.id as string,
balance: <number>data.balance, balance: data.balance as number,
date: format(new Date(), DATE_FORMAT), date: format(new Date(), DATE_FORMAT),
userId: aUserId userId: aUserId
}); });

2
apps/api/src/app/account/create-account.dto.ts

@ -36,6 +36,6 @@ export class CreateAccountDto {
name: string; name: string;
@IsString() @IsString()
@ValidateIf((object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
} }

2
apps/api/src/app/account/update-account.dto.ts

@ -35,6 +35,6 @@ export class UpdateAccountDto {
name: string; name: string;
@IsString() @IsString()
@ValidateIf((object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
} }

4
apps/api/src/app/admin/admin.service.ts

@ -8,7 +8,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { SymbolProfileOverwriteService } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -58,8 +57,7 @@ export class AdminService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileOverwriteService: SymbolProfileOverwriteService
) {} ) {}
public async addAssetProfile({ public async addAssetProfile({

4
apps/api/src/app/admin/queue/queue.controller.ts

@ -26,7 +26,7 @@ export class QueueController {
public async deleteJobs( public async deleteJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<void> { ): Promise<void> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.deleteJobs({ status }); return this.queueService.deleteJobs({ status });
} }
@ -36,7 +36,7 @@ export class QueueController {
public async getJobs( public async getJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<AdminJobs> { ): Promise<AdminJobs> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.getJobs({ status }); return this.queueService.getJobs({ status });
} }

4
apps/api/src/app/admin/queue/queue.service.ts

@ -1,6 +1,6 @@
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_QUEUE, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -14,7 +14,7 @@ export class QueueService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE) @InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue, private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE) @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue private readonly portfolioSnapshotQueue: Queue
) {} ) {}

13
apps/api/src/app/auth/auth.controller.ts

@ -14,12 +14,12 @@ import {
Req, Req,
Res, Res,
UseGuards, UseGuards,
VERSION_NEUTRAL, Version,
Version VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
@ -85,7 +85,7 @@ export class AuthController {
@Res() response: Response @Res() response: Response
) { ) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = (<any>request.user).jwt; const jwt: string = (request.user as any).jwt;
if (jwt) { if (jwt) {
response.redirect( response.redirect(
@ -130,10 +130,7 @@ export class AuthController {
public async verifyAttestation( public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON } @Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) { ) {
return this.webAuthService.verifyAttestation( return this.webAuthService.verifyAttestation(body.credential);
body.deviceName,
body.credential
);
} }
@Post('webauthn/generate-assertion-options') @Post('webauthn/generate-assertion-options')

8
apps/api/src/app/auth/google.strategy.ts

@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService configurationService: ConfigurationService
) { ) {
super({ super({
callbackURL: `${configurationService.get( callbackURL: `${configurationService.get(
@ -25,9 +25,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
} }
public async validate( public async validate(
request: any, _request: any,
token: string, _token: string,
refreshToken: string, _refreshToken: string,
profile: Profile, profile: Profile,
done: Function done: Function
) { ) {

4
apps/api/src/app/auth/interfaces/simplewebauthn.ts

@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
/** /**
* A WebAuthn-compatible device and the information needed to verify assertions by it * A WebAuthn-compatible device and the information needed to verify assertions by it
*/ */
export declare type AuthenticatorDevice = { export declare interface AuthenticatorDevice {
credentialPublicKey: Buffer; credentialPublicKey: Buffer;
credentialID: Buffer; credentialID: Buffer;
counter: number; counter: number;
transports?: AuthenticatorTransport[]; transports?: AuthenticatorTransport[];
}; }
/** /**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string * An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/ */

11
apps/api/src/app/auth/web-auth.service.ts

@ -13,16 +13,16 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import {
generateAuthenticationOptions,
GenerateAuthenticationOptionsOpts, GenerateAuthenticationOptionsOpts,
generateRegistrationOptions,
GenerateRegistrationOptionsOpts, GenerateRegistrationOptionsOpts,
VerifiedAuthenticationResponse, VerifiedAuthenticationResponse,
VerifiedRegistrationResponse, VerifiedRegistrationResponse,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
verifyRegistrationResponse VerifyAuthenticationResponseOpts,
verifyRegistrationResponse,
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { import {
@ -80,7 +80,6 @@ export class WebAuthService {
} }
public async verifyAttestation( public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> { ): Promise<AuthDeviceDto> {
const user = this.request.user; const user = this.request.user;

4
apps/api/src/app/benchmark/benchmark.service.ts

@ -442,10 +442,10 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{ JSON.stringify({
benchmarks, benchmarks,
expiration: expiration.getTime() expiration: expiration.getTime()
}), } as BenchmarkValue),
CACHE_TTL_INFINITE CACHE_TTL_INFINITE
); );
} }

7
apps/api/src/app/health/health.controller.ts

@ -3,7 +3,9 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { import {
Controller, Controller,
Get, Get,
HttpCode,
HttpException, HttpException,
HttpStatus,
Param, Param,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -17,7 +19,10 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {} public constructor(private readonly healthService: HealthService) {}
@Get() @Get()
public async getHealth() {} @HttpCode(HttpStatus.OK)
public getHealth() {
return { status: getReasonPhrase(StatusCodes.OK) };
}
@Get('data-enhancer/:name') @Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) { public async getHealthOfDataEnhancer(@Param('name') name: string) {

6
apps/api/src/app/order/order.service.ts

@ -418,7 +418,7 @@ export class OrderService {
where.SymbolProfile, where.SymbolProfile,
{ {
AND: [ AND: [
{ dataSource: <DataSource>filterByDataSource }, { dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol }
] ]
} }
@ -427,7 +427,7 @@ export class OrderService {
} else { } else {
where.SymbolProfile = { where.SymbolProfile = {
AND: [ AND: [
{ dataSource: <DataSource>filterByDataSource }, { dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol }
] ]
}; };
@ -671,7 +671,7 @@ export class OrderService {
{ {
dataSource: dataSource:
data.SymbolProfile.connect.dataSource_symbol.dataSource, data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date, date: data.date as Date,
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
} }
], ],

8
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -15,8 +15,8 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import { import {
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
@ -1091,7 +1091,7 @@ export abstract class PortfolioCalculator {
opts: { opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId, jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
} }
}); });
} }
@ -1107,7 +1107,7 @@ export abstract class PortfolioCalculator {
opts: { opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId, jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH
} }
}); });

48
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,6 +1,8 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -20,6 +22,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash'; import { last } from 'lodash';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -52,6 +55,8 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
@ -59,6 +64,15 @@ describe('PortfolioCalculator', () => {
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
)
);
});
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
@ -89,38 +103,18 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [ const activities: Activity[] = activityDtos.map((activity) => ({
{
...activityDummyData, ...activityDummyData,
date: new Date('2022-03-07'), ...activity,
fee: 1.3, date: parseDate(activity.date),
quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'CHF', currency: activity.currency,
dataSource: 'YAHOO', dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: 'NOVN.SW' symbol: activity.symbol
},
type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 2.95,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: 85.73
} }
]; }));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,

4
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -810,7 +810,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
[key: DateRange]: Big; [key: DateRange]: Big;
} = {}; } = {};
for (const dateRange of <DateRange[]>[ for (const dateRange of [
'1d', '1d',
'1y', '1y',
'5y', '5y',
@ -826,7 +826,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
// .map((date) => { // .map((date) => {
// return format(date, 'yyyy'); // return format(date, 'yyyy');
// }) // })
]) { ] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange); const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate; const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate; let startDate = dateInterval.startDate;

129
apps/api/src/app/portfolio/portfolio.service.ts

@ -10,6 +10,8 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -139,7 +141,7 @@ export class PortfolioService {
some: { some: {
SymbolProfile: { SymbolProfile: {
AND: [ AND: [
{ dataSource: <DataSource>filterByDataSource }, { dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol }
] ]
} }
@ -623,79 +625,6 @@ export class PortfolioService {
}; };
} }
@LogPerformance
private calculateMarketsAllocation(
symbolProfile: EnhancedSymbolProfile,
markets: {
developedMarkets: number;
emergingMarkets: number;
otherMarkets: number;
},
marketsAdvanced: {
asiaPacific: number;
emergingMarkets: number;
europe: number;
japan: number;
northAmerica: number;
otherMarkets: number;
},
value: Big
) {
if (symbolProfile.countries.length > 0) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
}
}
@LogPerformance @LogPerformance
public async getPosition( public async getPosition(
aDataSource: DataSource, aDataSource: DataSource,
@ -1256,15 +1185,21 @@ export class PortfolioService {
@LogPerformance @LogPerformance
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = <UserSettings>this.request.user.Settings.settings; const userSettings = this.request.user.Settings.settings as UserSettings;
const { accounts, holdings, summary } = await this.getDetails({ const { accounts, holdings, markets, summary } = await this.getDetails({
impersonationId, impersonationId,
userId, userId,
withMarkets: true, withMarkets: true,
withSummary: true withSummary: true
}); });
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
return { return {
rules: { rules: {
accountClusterRisk: accountClusterRisk:
@ -1283,6 +1218,24 @@ export class PortfolioService {
userSettings userSettings
) )
: undefined, : undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
)
: undefined,
currencyClusterRisk: currencyClusterRisk:
summary.ordersCount > 0 summary.ordersCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
@ -1340,9 +1293,7 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId }); await this.orderService.assignTags({ dataSource, symbol, tags, userId });
} }
private getAggregatedMarkets(holdings: { private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
[symbol: string]: PortfolioPosition;
}): {
markets: PortfolioDetails['markets']; markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced']; marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} { } {
@ -1438,20 +1389,20 @@ export class PortfolioService {
} }
} }
const marketsTotal = const marketsTotalInBaseCurrency = getSum(
markets.developedMarkets.valueInBaseCurrency + Object.values(markets).map(({ valueInBaseCurrency }) => {
markets.emergingMarkets.valueInBaseCurrency + return new Big(valueInBaseCurrency);
markets.otherMarkets.valueInBaseCurrency + })
markets[UNKNOWN_KEY].valueInBaseCurrency; ).toNumber();
markets.developedMarkets.valueInPercentage = markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotal; markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage = markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotal; markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage = markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotal; markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage = markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotal; markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal = const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency + marketsAdvanced.asiaPacific.valueInBaseCurrency +
@ -2012,7 +1963,7 @@ export class PortfolioService {
}: { }: {
activities: Activity[]; activities: Activity[];
filters?: Filter[]; filters?: Filter[];
portfolioItemsNow: { [p: string]: TimelinePosition }; portfolioItemsNow: Record<string, TimelinePosition>;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;

9
apps/api/src/app/portfolio/rules.service.ts

@ -9,8 +9,6 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class RulesService { export class RulesService {
public constructor() {}
public async evaluate<T extends RuleSettings>( public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[], aRules: Rule<T>[],
aUserSettings: UserSettings aUserSettings: UserSettings
@ -24,13 +22,10 @@ export class RulesService {
return { return {
evaluation, evaluation,
value, value,
configuration: rule.getConfiguration(),
isActive: true, isActive: true,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName(), name: rule.getName()
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
}; };
} else { } else {
return { return {

4
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -19,11 +19,11 @@ import { RedisCacheService } from './redis-cache.service';
configurationService.get('REDIS_PASSWORD') configurationService.get('REDIS_PASSWORD')
); );
return <RedisClientOptions>{ return {
store: redisStore, store: redisStore,
ttl: configurationService.get('CACHE_TTL'), ttl: configurationService.get('CACHE_TTL'),
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
}; } as RedisClientOptions;
} }
}), }),
ConfigurationModule ConfigurationModule

4
apps/api/src/app/subscription/subscription.controller.ts

@ -95,7 +95,7 @@ export class SubscriptionController {
@Res() response: Response @Res() response: Response
) { ) {
const userId = await this.subscriptionService.createSubscriptionViaStripe( const userId = await this.subscriptionService.createSubscriptionViaStripe(
<string>request.query.checkoutSessionId request.query.checkoutSessionId as string
); );
Logger.log( Logger.log(
@ -113,7 +113,7 @@ export class SubscriptionController {
@Post('stripe/checkout-session') @Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession( public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId?: string; priceId: string }
) { ) {
try { try {
return this.subscriptionService.createCheckoutSession({ return this.subscriptionService.createCheckoutSession({

4
apps/api/src/app/subscription/subscription.service.ts

@ -124,7 +124,9 @@ export class SubscriptionService {
let offer: SubscriptionOffer = price ? 'renewal' : 'default'; let offer: SubscriptionOffer = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) { if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird'; offer = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offer = 'renewal-early-bird-2024';
} }
return { return {

14
apps/api/src/app/user/update-user-setting.dto.ts

@ -1,10 +1,10 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import type { import type {
ColorScheme, ColorScheme,
DateRange, DateRange,
HoldingsViewMode, HoldingsViewMode,
ViewMode, ViewMode
XRayRulesSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
@ -31,11 +31,11 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
benchmark?: string; benchmark?: string;
@IsIn(<ColorScheme[]>['DARK', 'LIGHT']) @IsIn(['DARK', 'LIGHT'] as ColorScheme[])
@IsOptional() @IsOptional()
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
@IsIn(<DateRange[]>[ @IsIn([
'1d', '1d',
'1w', '1w',
'1m', '1m',
@ -51,7 +51,7 @@ export class UpdateUserSettingDto {
return format(date, 'yyyy'); return format(date, 'yyyy');
} }
) )
]) ] as DateRange[])
@IsOptional() @IsOptional()
dateRange?: DateRange; dateRange?: DateRange;
@ -71,7 +71,7 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE']) @IsIn(['CHART', 'TABLE'] as HoldingsViewMode[])
@IsOptional() @IsOptional()
holdingsViewMode?: HoldingsViewMode; holdingsViewMode?: HoldingsViewMode;
@ -103,7 +103,7 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
savingsRate?: number; savingsRate?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN']) @IsIn(['DEFAULT', 'ZEN'] as ViewMode[])
@IsOptional() @IsOptional()
viewMode?: ViewMode; viewMode?: ViewMode;

2
apps/api/src/app/user/user.controller.ts

@ -148,7 +148,7 @@ export class UserController {
const userSettings: UserSettings = merge( const userSettings: UserSettings = merge(
{}, {},
<UserSettings>this.request.user.Settings.settings, this.request.user.Settings.settings as UserSettings,
data data
); );

60
apps/api/src/app/user/user.service.ts

@ -2,6 +2,14 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -108,8 +116,8 @@ export class UserService {
accounts: Account, accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(<UserSettings>Settings.settings), ...(Settings.settings as UserSettings),
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale locale: (Settings.settings as UserSettings)?.locale ?? aLocale
} }
}; };
} }
@ -200,17 +208,47 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
} }
// Set default values for X-ray rules
if (!(user.Settings.settings as UserSettings).xRayRules) {
(user.Settings.settings as UserSettings).xRayRules = { (user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment: { isActive: true }, AccountClusterRiskCurrentInvestment:
AccountClusterRiskSingleAccount: { isActive: true }, new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true }, user.Settings.settings
CurrencyClusterRiskCurrentInvestment: { isActive: true }, ),
EmergencyFundSetup: { isActive: true }, AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
FeeRatioInitialInvestment: { isActive: true } undefined,
{}
).getSettings(user.Settings.settings),
EconomicMarketClusterRiskDevelopedMarkets:
new EconomicMarketClusterRiskDevelopedMarkets(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
EconomicMarketClusterRiskEmergingMarkets:
new EconomicMarketClusterRiskEmergingMarkets(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskCurrentInvestment:
new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined
).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings)
}; };
}
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);

24
apps/api/src/assets/sitemap.xml

@ -56,10 +56,22 @@
<loc>https://ghostfol.io/de/ressourcen</loc> <loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -214,6 +226,18 @@
<loc>https://ghostfol.io/en/resources</loc> <loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/glossary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/guides</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/markets</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

2
apps/api/src/helper/object.helper.ts

@ -40,7 +40,7 @@ export function redactAttributes({
object: any; object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[]; options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any { }): any {
if (!object || !options || !options.length) { if (!object || !options?.length) {
return object; return object;
} }

2
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -19,8 +19,6 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
{ {
public constructor() {}
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>

2
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -21,7 +21,7 @@ export class TransformDataSourceInResponseInterceptor<T>
) {} ) {}
public intercept( public intercept(
context: ExecutionContext, _context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>
): Observable<any> { ): Observable<any> {
return next.handle().pipe( return next.handle().pipe(

10
apps/api/src/models/rule.ts

@ -1,7 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
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 { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -65,5 +69,9 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult; public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration']
>;
public abstract getSettings(aUserSettings: UserSettings): T; public abstract getSettings(aUserSettings: UserSettings): T;
} }

16
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -76,11 +76,23 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

6
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -34,9 +34,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ xRayRules }: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

6
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -61,10 +61,14 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

16
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -61,11 +61,23 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

84
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

@ -0,0 +1,84 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private developedMarketsValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
developedMarketsValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: EconomicMarketClusterRiskDevelopedMarkets.name,
name: 'Developed Markets'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.developedMarketsValueInBaseCurrency =
developedMarketsValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const developedMarketsValueRatio = this.currentValueInBaseCurrency
? this.developedMarketsValueInBaseCurrency /
this.currentValueInBaseCurrency
: 0;
if (developedMarketsValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The developed markets contribution of your current investment (${(developedMarketsValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (developedMarketsValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The developed markets contribution of your current investment (${(developedMarketsValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The developed markets contribution of your current investment (${(developedMarketsValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.72,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.68
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
thresholdMin: number;
thresholdMax: number;
}

84
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

@ -0,0 +1,84 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private emergingMarketsValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
emergingMarketsValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: EconomicMarketClusterRiskEmergingMarkets.name,
name: 'Emerging Markets'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.emergingMarketsValueInBaseCurrency =
emergingMarketsValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const emergingMarketsValueRatio = this.currentValueInBaseCurrency
? this.emergingMarketsValueInBaseCurrency /
this.currentValueInBaseCurrency
: 0;
if (emergingMarketsValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The emerging markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (emergingMarketsValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The emerging markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The emerging markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.32,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.28
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
thresholdMin: number;
thresholdMax: number;
}

6
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -32,10 +32,14 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

16
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -43,11 +43,23 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 0.1,
min: 0,
step: 0.0025,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.01 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01
}; };
} }
} }

18
apps/api/src/services/api/api.service.ts

@ -4,8 +4,6 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ApiService { export class ApiService {
public constructor() {}
public buildFiltersFromQueryParams({ public buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -36,28 +34,28 @@ export class ApiService {
const filters = [ const filters = [
...accountIds.map((accountId) => { ...accountIds.map((accountId) => {
return <Filter>{ return {
id: accountId, id: accountId,
type: 'ACCOUNT' type: 'ACCOUNT'
}; } as Filter;
}), }),
...assetClasses.map((assetClass) => { ...assetClasses.map((assetClass) => {
return <Filter>{ return {
id: assetClass, id: assetClass,
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; } as Filter;
}), }),
...assetSubClasses.map((assetClass) => { ...assetSubClasses.map((assetClass) => {
return <Filter>{ return {
id: assetClass, id: assetClass,
type: 'ASSET_SUB_CLASS' type: 'ASSET_SUB_CLASS'
}; } as Filter;
}), }),
...tagIds.map((tagId) => { ...tagIds.map((tagId) => {
return <Filter>{ return {
id: tagId, id: tagId,
type: 'TAG' type: 'TAG'
}; } as Filter;
}) })
]; ];

18
apps/api/src/services/configuration/configuration.service.ts

@ -1,9 +1,9 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { import {
CACHE_TTL_NO_CACHE, CACHE_TTL_NO_CACHE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
DEFAULT_ROOT_URL DEFAULT_ROOT_URL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -51,14 +51,14 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }), MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({ PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
}), }),
PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({ PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY
}), }),
PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({ PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}), }),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({ PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT

2
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -7,8 +7,6 @@ const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.jso
export class CryptocurrencyService { export class CryptocurrencyService {
private combinedCryptocurrencies: string[]; private combinedCryptocurrencies: string[];
public constructor() {}
public isCryptocurrency(aSymbol = '') { public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol); return this.getCryptocurrencies().includes(cryptocurrencySymbol);

8
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -15,7 +15,6 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
}; };
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -36,7 +35,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
if (!(response.assetSubClass === 'ETF')) { if (
!(
response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
)
) {
return response; return response;
} }

4
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService cryptocurrencyService
); );
}); });

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { import {
@ -24,7 +23,6 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}

10
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -163,10 +163,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>(); ).json<any>();
return response.reduce( return response.reduce(
(result, { close, date }) => { (result, { adjusted_close, date }) => {
if (isNumber(close)) { if (isNumber(adjusted_close)) {
result[this.convertFromEodSymbol(symbol)][date] = { result[this.convertFromEodSymbol(symbol)][date] = {
marketPrice: close marketPrice: adjusted_close
}; };
} else { } else {
Logger.error( Logger.error(
@ -500,6 +500,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF; assetSubClass = AssetSubClass.ETF;
break; break;
case 'fund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
} }
return { assetClass, assetSubClass }; return { assetClass, assetSubClass };

2
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -76,7 +76,7 @@ export class GoogleSheetsService implements DataProviderInterface {
} = {}; } = {};
rows rows
.filter((row, index) => { .filter((_row, index) => {
return index >= 1; return index >= 1;
}) })
.forEach((row) => { .forEach((row) => {

4
apps/api/src/services/interfaces/environment.interface.ts

@ -30,6 +30,10 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number; MAX_CHART_ITEMS: number;
PORT: number; PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number; REDIS_DB: number;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;

23
apps/api/src/services/market-data/market-data.service.ts

@ -1,6 +1,5 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
@ -21,8 +20,6 @@ export class MarketDataService {
lock = new AwaitLock(); lock = new AwaitLock();
private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
@ -149,21 +146,21 @@ export class MarketDataService {
async ({ dataSource, date, marketPrice, symbol, state }) => { async ({ dataSource, date, marketPrice, symbol, state }) => {
return this.prismaService.marketData.upsert({ return this.prismaService.marketData.upsert({
create: { create: {
dataSource: <DataSource>dataSource, dataSource: dataSource as DataSource,
date: <Date>date, date: date as Date,
marketPrice: <number>marketPrice, marketPrice: marketPrice as number,
state: <MarketDataState>state, state: state as MarketDataState,
symbol: <string>symbol symbol: symbol as string
}, },
update: { update: {
marketPrice: <number>marketPrice, marketPrice: marketPrice as number,
state: <MarketDataState>state state: state as MarketDataState
}, },
where: { where: {
dataSource_date_symbol: { dataSource_date_symbol: {
dataSource: <DataSource>dataSource, dataSource: dataSource as DataSource,
date: <Date>date, date: date as Date,
symbol: <string>symbol symbol: symbol as string
} }
} }
}); });

16
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -6,8 +6,8 @@ import {
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
@ -45,8 +45,8 @@ export class DataGatheringProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ?? process.env.PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(), DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY.toString(),
10 10
), ),
name: GATHER_ASSET_PROFILE_PROCESS name: GATHER_ASSET_PROFILE_PROCESS
@ -76,8 +76,8 @@ export class DataGatheringProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ?? process.env.PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(), DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10 10
), ),
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
@ -85,7 +85,7 @@ export class DataGatheringProcessor {
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) { public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try { try {
const { dataSource, date, symbol } = job.data; const { dataSource, date, symbol } = job.data;
let currentDate = parseISO(<string>(<unknown>date)); let currentDate = parseISO(date as unknown as string);
Logger.log( Logger.log(
`Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
@ -160,7 +160,7 @@ export class DataGatheringProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ?? process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(), DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10 10
), ),
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -10,7 +10,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { import {
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
PORTFOLIO_SNAPSHOT_QUEUE PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
@ -23,7 +23,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: { settings: {
lockDuration: parseInt( lockDuration: parseInt(
process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT ?? process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT ??

14
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -9,9 +9,9 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
CACHE_TTL_INFINITE, CACHE_TTL_INFINITE,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_QUEUE PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
@ -22,7 +22,7 @@ import { addMilliseconds } from 'date-fns';
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@Injectable() @Injectable()
@Processor(PORTFOLIO_SNAPSHOT_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
@ -34,8 +34,8 @@ export class PortfolioSnapshotProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ?? process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY ??
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(), DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY.toString(),
10 10
), ),
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME
@ -94,10 +94,10 @@ export class PortfolioSnapshotProcessor {
filters: job.data.filters, filters: job.data.filters,
userId: job.data.userId userId: job.data.userId
}), }),
JSON.stringify(<PortfolioSnapshotValue>(<unknown>{ JSON.stringify({
expiration: expiration.getTime(), expiration: expiration.getTime(),
portfolioSnapshot: snapshot portfolioSnapshot: snapshot
})), } as unknown as PortfolioSnapshotValue),
CACHE_TTL_INFINITE CACHE_TTL_INFINITE
); );

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts

@ -1,4 +1,4 @@
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -9,7 +9,7 @@ import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queu
@Injectable() @Injectable()
export class PortfolioSnapshotService { export class PortfolioSnapshotService {
public constructor( public constructor(
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE) @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue private readonly portfolioSnapshotQueue: Queue
) {} ) {}

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -188,7 +188,7 @@ export class SymbolProfileService {
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: <Date>undefined, dateOfFirstActivity: undefined as Date,
holdings: this.getHoldings(symbolProfile), holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),

3
apps/api/tsconfig.app.json

@ -4,7 +4,8 @@
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["node"], "types": ["node"],
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2021" "target": "es2021",
"module": "commonjs"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]

11
apps/client/src/app/app.component.html

@ -46,7 +46,7 @@
</main> </main>
@if (showFooter) { @if (showFooter) {
<footer class="d-flex justify-content-center py-4 w-100"> <footer class="justify-content-center overflow-hidden py-4 w-100">
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-sm"> <div class="col-sm">
@ -187,7 +187,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="row text-center"> <div class="mb-2 row text-center">
<div class="col"> <div class="col">
© 2021 - {{ currentYear }} © 2021 - {{ currentYear }}
<a href="https://ghostfol.io">Ghostfolio</a> <a href="https://ghostfol.io">Ghostfolio</a>
@ -195,12 +195,17 @@
</div> </div>
<div class="row text-center text-muted"> <div class="row text-center text-muted">
<div class="col"> <div class="col">
<small i18n <small class="d-block" i18n
>The risk of loss in trading can be substantial. It is not advisable >The risk of loss in trading can be substantial. It is not advisable
to invest money you may need in the short term.</small to invest money you may need in the short term.</small
> >
</div> </div>
</div> </div>
</div> </div>
<div class="container d-none d-md-block mt-5">
<div class="row justify-content-center">
<div class="font-weight-bold line-height-1 logotype">Ghostfolio</div>
</div>
</div>
</footer> </footer>
} }

7
apps/client/src/app/app.component.scss

@ -35,6 +35,13 @@
footer { footer {
background-color: rgba(var(--palette-foreground-text), 0.05); background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%; font-size: 90%;
.logotype {
font-size: 13vw;
letter-spacing: -0.03em;
margin-bottom: -5svw;
opacity: 0.05;
}
} }
header { header {

9
apps/client/src/app/app.component.ts

@ -183,6 +183,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs = this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) || (this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === this.routerLinkFaq[0].slice(1) || this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'account' || this.currentRoute === 'account' ||
this.currentRoute === 'admin' || this.currentRoute === 'admin' ||
this.currentRoute === 'home' || this.currentRoute === 'home' ||
@ -198,7 +199,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute === 'p' || this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) || this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) || this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'start') && this.currentRoute === 'start') &&
this.deviceType !== 'mobile'; this.deviceType !== 'mobile';
@ -292,7 +292,7 @@ export class AppComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, { const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: <HoldingDetailDialogParams>{ data: {
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
@ -312,9 +312,8 @@ export class AppComponent implements OnDestroy, OnInit {
hasPermission(this.user?.permissions, permissions.updateOrder) && hasPermission(this.user?.permissions, permissions.updateOrder) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, } as HoldingDetailDialogParams,
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '95vw' : '50rem',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

5
apps/client/src/app/components/access-table/access-table.component.ts

@ -10,7 +10,6 @@ import {
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
@ -21,7 +20,7 @@ import { MatTableDataSource } from '@angular/material/table';
templateUrl: './access-table.component.html', templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss'] styleUrls: ['./access-table.component.scss']
}) })
export class AccessTableComponent implements OnChanges, OnInit { export class AccessTableComponent implements OnChanges {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() user: User; @Input() user: User;
@ -37,8 +36,6 @@ export class AccessTableComponent implements OnChanges, OnInit {
private notificationService: NotificationService private notificationService: NotificationService
) {} ) {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['alias', 'grantee', 'type', 'details']; this.displayedColumns = ['alias', 'grantee', 'type', 'details'];

8
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -9,7 +9,6 @@ import {
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit,
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
@ -26,7 +25,7 @@ import { Subject, Subscription } from 'rxjs';
templateUrl: './accounts-table.component.html', templateUrl: './accounts-table.component.html',
styleUrls: ['./accounts-table.component.scss'] styleUrls: ['./accounts-table.component.scss']
}) })
export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { export class AccountsTableComponent implements OnChanges, OnDestroy {
@Input() accounts: AccountModel[]; @Input() accounts: AccountModel[];
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@ -48,8 +47,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = public dataSource = new MatTableDataSource<AccountModel>();
new MatTableDataSource();
public displayedColumns = []; public displayedColumns = [];
public isLoading = true; public isLoading = true;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
@ -61,8 +59,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private router: Router private router: Router
) {} ) {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['status', 'account', 'platform']; this.displayedColumns = ['status', 'account', 'platform'];

4
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -35,10 +35,10 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
DATA_GATHERING_QUEUE_PRIORITY_HIGH; DATA_GATHERING_QUEUE_PRIORITY_HIGH;
public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM; DATA_GATHERING_QUEUE_PRIORITY_MEDIUM;
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string; public defaultDateTimeFormat: string;
public filterForm: FormGroup; public filterForm: FormGroup;
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [ public displayedColumns = [
'index', 'index',
'type', 'type',

11
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts

@ -12,7 +12,6 @@ import {
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -42,7 +41,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
styleUrls: ['./admin-market-data-detail.component.scss'], styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html' templateUrl: './admin-market-data-detail.component.html'
}) })
export class AdminMarketDataDetailComponent implements OnChanges, OnInit { export class AdminMarketDataDetailComponent implements OnChanges {
@Input() currency: string; @Input() currency: string;
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string; @Input() dateOfFirstActivity: string;
@ -81,8 +80,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
}); });
} }
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
@ -181,15 +178,15 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{ data: {
marketPrice, marketPrice,
currency: this.currency, currency: this.currency,
dataSource: this.dataSource, dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`, dateString: `${yearMonth}-${day}`,
symbol: this.symbol, symbol: this.symbol,
user: this.user user: this.user
}, } as MarketDataDetailDialogParams,
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

29
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -71,36 +71,35 @@ export class AdminMarketDataComponent
return { return {
id: assetSubClass.toString(), id: assetSubClass.toString(),
label: translate(assetSubClass), label: translate(assetSubClass),
type: <Filter['type']>'ASSET_SUB_CLASS' type: 'ASSET_SUB_CLASS' as Filter['type']
}; };
}) })
.concat([ .concat([
{ {
id: 'BENCHMARKS', id: 'BENCHMARKS',
label: $localize`Benchmarks`, label: $localize`Benchmarks`,
type: <Filter['type']>'PRESET_ID' type: 'PRESET_ID' as Filter['type']
}, },
{ {
id: 'CURRENCIES', id: 'CURRENCIES',
label: $localize`Currencies`, label: $localize`Currencies`,
type: <Filter['type']>'PRESET_ID' type: 'PRESET_ID' as Filter['type']
}, },
{ {
id: 'ETF_WITHOUT_COUNTRIES', id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`, label: $localize`ETFs without Countries`,
type: <Filter['type']>'PRESET_ID' type: 'PRESET_ID' as Filter['type']
}, },
{ {
id: 'ETF_WITHOUT_SECTORS', id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`, label: $localize`ETFs without Sectors`,
type: <Filter['type']>'PRESET_ID' type: 'PRESET_ID' as Filter['type']
} }
]); ]);
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> = public dataSource = new MatTableDataSource<AdminMarketDataItem>();
new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public displayedColumns: string[] = []; public displayedColumns: string[] = [];
@ -275,7 +274,7 @@ export class AdminMarketDataComponent
this.adminService this.adminService
.gatherProfileData() .gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onGatherProfileDataBySymbol({ public onGatherProfileDataBySymbol({
@ -285,14 +284,14 @@ export class AdminMarketDataComponent
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onOpenAssetProfileDialog({ public onOpenAssetProfileDialog({
@ -386,14 +385,14 @@ export class AdminMarketDataComponent
const dialogRef = this.dialog.open(AssetProfileDialog, { const dialogRef = this.dialog.open(AssetProfileDialog, {
autoFocus: false, autoFocus: false,
data: <AssetProfileDialogParams>{ data: {
dataSource, dataSource,
symbol, symbol,
colorScheme: this.user?.settings.colorScheme, colorScheme: this.user?.settings.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, } as AssetProfileDialogParams,
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -415,10 +414,10 @@ export class AdminMarketDataComponent
const dialogRef = this.dialog.open(CreateAssetProfileDialog, { const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
autoFocus: false, autoFocus: false,
data: <CreateAssetProfileDialogParams>{ data: {
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, } as CreateAssetProfileDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

3
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -59,10 +59,9 @@ export class AdminMarketDataService {
}), }),
finalize(() => { finalize(() => {
window.location.reload(); window.location.reload();
setTimeout(() => {}, 300);
}) })
) )
.subscribe(() => {}); .subscribe();
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?` title: $localize`Do you really want to delete these profiles?`

6
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -213,14 +213,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onGatherSymbolMissingOnly({ public onGatherSymbolMissingOnly({
@ -230,7 +230,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService this.adminService
.gatherSymbolMissingOnly({ dataSource, symbol }) .gatherSymbolMissingOnly({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe();
} }
public onImportHistoricalData() { public onImportHistoricalData() {

4
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -225,10 +225,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
$localize`Please set your system message:`, $localize`Please set your system message:`,
JSON.stringify( JSON.stringify(
this.systemMessage ?? this.systemMessage ??
<SystemMessage>{ ({
message: '⚒️ Scheduled maintenance in progress...', message: '⚒️ Scheduled maintenance in progress...',
targetGroups: ['Basic', 'Premium'] targetGroups: ['Basic', 'Premium']
} } as SystemMessage)
) )
); );

6
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -34,7 +34,7 @@ import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog
export class AdminPlatformComponent implements OnInit, OnDestroy { export class AdminPlatformComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Platform> = new MatTableDataSource(); public dataSource = new MatTableDataSource<Platform>();
public deviceType: string; public deviceType: string;
public displayedColumns = ['name', 'url', 'accounts', 'actions']; public displayedColumns = ['name', 'url', 'accounts', 'actions'];
public platforms: Platform[]; public platforms: Platform[];
@ -139,7 +139,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null url: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -176,7 +176,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url url
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

35
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -1,4 +1,39 @@
<div class="container"> <div class="container">
<div class="d-md-block d-none mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Data Providers</h2>
<mat-card appearance="outlined">
<mat-card-content>
<div class="align-items-center d-flex my-3">
<div class="w-50">
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
>
<span class="badge badge-warning mr-1" i18n>NEW</span>
Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
</a>
</div>
<div class="w-50">
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API Key</span>
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Platforms</h2> <h2 class="text-center" i18n>Platforms</h2>

53
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -1,10 +1,18 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Subject } from 'rxjs'; import { MatDialog } from '@angular/material/dialog';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -12,12 +20,49 @@ import { Subject } from 'rxjs';
styleUrls: ['./admin-settings.component.scss'], styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html' templateUrl: './admin-settings.component.html'
}) })
export class AdminSettingsComponent implements OnInit, OnDestroy { export class AdminSettingsComponent implements OnDestroy, OnInit {
public pricingUrl: string;
private deviceType: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
private user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
private matDialog: MatDialog,
private userService: UserService
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
public constructor() {} this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
public ngOnInit() {} this.pricingUrl =
`https://ghostfol.io/${this.user.settings.language}/` +
$localize`:snake-case:pricing`;
this.changeDetectorRef.markForCheck();
}
});
}
public onSetGhostfolioApiKey() {
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType,
pricingUrl: this.pricingUrl
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

6
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,8 +1,11 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AdminSettingsComponent } from './admin-settings.component'; import { AdminSettingsComponent } from './admin-settings.component';
@ -13,6 +16,9 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule, CommonModule,
GfAdminPlatformModule, GfAdminPlatformModule,
GfAdminTagModule, GfAdminTagModule,
GfPremiumIndicatorComponent,
MatButtonModule,
MatCardModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

39
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts

@ -0,0 +1,39 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfPremiumIndicatorComponent,
MatButtonModule,
MatDialogModule
],
selector: 'gf-ghostfolio-premium-api-dialog',
standalone: true,
styleUrls: ['./ghostfolio-premium-api-dialog.scss'],
templateUrl: './ghostfolio-premium-api-dialog.html'
})
export class GfGhostfolioPremiumApiDialogComponent {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
) {}
public onCancel() {
this.dialogRef.close();
}
}

42
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -0,0 +1,42 @@
<gf-dialog-header
mat-dialog-title
position="center"
title="Ghostfolio Premium Data Provider"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>
<div class="text-center" mat-dialog-content>
<p class="gf-text-wrap-balance mb-1">
The official
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="data.pricingUrl"
>Ghostfolio Premium
<gf-premium-indicator class="d-inline-block ml-1" [enableLink]="false" />
</a>
data provider <strong>for self-hosters</strong>, offering
<strong>100’000+ tickers</strong> from over <strong>50 exchanges</strong>,
is coming soon!
</p>
<p i18n>
Want to stay updated? Click below to get notified as soon as it’s available.
</p>
<div>
<a
color="primary"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards"
i18n
mat-flat-button
>
Notify me
</a>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>

2
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.scss

@ -0,0 +1,2 @@
:host {
}

4
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts

@ -0,0 +1,4 @@
export interface GhostfolioPremiumApiDialogParams {
deviceType: string;
pricingUrl: string;
}

6
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -34,7 +34,7 @@ import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or
export class AdminTagComponent implements OnInit, OnDestroy { export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource(); public dataSource = new MatTableDataSource<Tag>();
public deviceType: string; public deviceType: string;
public displayedColumns = [ public displayedColumns = [
'name', 'name',
@ -144,7 +144,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name: null name: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -180,7 +180,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name name
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

3
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -24,8 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminUsers['users'][0]> = public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns: string[] = []; public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag; public getEmojiFlag = getEmojiFlag;

2
apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts

@ -26,8 +26,6 @@ export class GfAssetProfileIconComponent implements OnChanges {
public src: string; public src: string;
public constructor() {}
public ngOnChanges() { public ngOnChanges() {
if (this.dataSource && this.symbol) { if (this.dataSource && this.symbol) {
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`; this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`;

21
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -29,12 +29,13 @@ import { SymbolProfile } from '@prisma/client';
import { import {
Chart, Chart,
ChartData, ChartData,
LinearScale,
LineController, LineController,
LineElement, LineElement,
LinearScale,
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip Tooltip,
TooltipPosition
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
@ -50,7 +51,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmarks: Partial<SymbolProfile>[]; @Input() benchmarks: Partial<SymbolProfile>[];
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() daysInMarket: number;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[]; @Input() performanceDataItems: LineChartItem[];
@ -75,7 +75,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (elements, position) => Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position); getTooltipPositionerMapTop(this.chart, position);
} }
@ -102,7 +102,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
private initialize() { private initialize() {
const benchmarkDataValues: { [date: string]: number } = {}; const benchmarkDataValues: Record<string, number> = {};
for (const { date, value } of this.benchmarkDataItems) { for (const { date, value } of this.benchmarkDataItems) {
benchmarkDataValues[date] = value; benchmarkDataValues[date] = value;
@ -147,9 +147,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>( this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() this.getTooltipPluginConfiguration() as unknown;
);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -168,7 +167,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
interaction: { intersect: false, mode: 'index' }, interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: <unknown>{ plugins: {
annotation: { annotation: {
annotations: { annotations: {
yAxis: { yAxis: {
@ -187,7 +186,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
}, } as unknown,
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -252,7 +251,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
unit: '%' unit: '%'
}), }),
mode: 'index', mode: 'index',
position: <unknown>'top', position: 'top' as unknown,
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

7
apps/client/src/app/components/dialog-footer/dialog-footer.component.ts

@ -3,7 +3,6 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
@ -14,15 +13,11 @@ import {
templateUrl: './dialog-footer.component.html', templateUrl: './dialog-footer.component.html',
styleUrls: ['./dialog-footer.component.scss'] styleUrls: ['./dialog-footer.component.scss']
}) })
export class DialogFooterComponent implements OnInit { export class DialogFooterComponent {
@Input() deviceType: string; @Input() deviceType: string;
@Output() closeButtonClicked = new EventEmitter<void>(); @Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() { public onClickCloseButton() {
this.closeButtonClicked.emit(); this.closeButtonClicked.emit();
} }

7
apps/client/src/app/components/dialog-header/dialog-header.component.ts

@ -3,7 +3,6 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
@ -14,17 +13,13 @@ import {
templateUrl: './dialog-header.component.html', templateUrl: './dialog-header.component.html',
styleUrls: ['./dialog-header.component.scss'] styleUrls: ['./dialog-header.component.scss']
}) })
export class DialogHeaderComponent implements OnInit { export class DialogHeaderComponent {
@Input() deviceType: string; @Input() deviceType: string;
@Input() position: 'center' | 'left' = 'left'; @Input() position: 'center' | 'left' = 'left';
@Input() title: string; @Input() title: string;
@Output() closeButtonClicked = new EventEmitter<void>(); @Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() { public onClickCloseButton() {
this.closeButtonClicked.emit(); this.closeButtonClicked.emit();
} }

9
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts

@ -5,8 +5,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input, Input,
OnChanges, OnChanges
OnInit
} from '@angular/core'; } from '@angular/core';
@Component({ @Component({
@ -15,16 +14,12 @@ import {
templateUrl: './fear-and-greed-index.component.html', templateUrl: './fear-and-greed-index.component.html',
styleUrls: ['./fear-and-greed-index.component.scss'] styleUrls: ['./fear-and-greed-index.component.scss']
}) })
export class FearAndGreedIndexComponent implements OnChanges, OnInit { export class FearAndGreedIndexComponent implements OnChanges {
@Input() fearAndGreedIndex: number; @Input() fearAndGreedIndex: number;
public fearAndGreedIndexEmoji: string; public fearAndGreedIndexEmoji: string;
public fearAndGreedIndexText: string; public fearAndGreedIndexText: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex); const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex);

3
apps/client/src/app/components/header/header.component.html

@ -180,7 +180,8 @@
<ng-container i18n>Upgrade Plan</ng-container> <ng-container i18n>Upgrade Plan</ng-container>
} @else if ( } @else if (
user.subscription.offer === 'renewal' || user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird' user.subscription.offer === 'renewal-early-bird-2023' ||
user.subscription.offer === 'renewal-early-bird-2024'
) { ) {
<ng-container i18n>Renew Plan</ng-container> <ng-container i18n>Renew Plan</ng-container>
} }

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -150,7 +150,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
tags: <string[]>[] tags: [] as string[]
}); });
const filters: Filter[] = [ const filters: Filter[] = [

7
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -4,14 +4,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types';
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';

3
apps/client/src/app/components/home-market/home-market.component.ts

@ -29,7 +29,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalDataItems: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;
public isLoading = true;
public readonly numberOfDays = 365; public readonly numberOfDays = 365;
public user: User; public user: User;
@ -43,7 +42,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.isLoading = true;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -89,7 +87,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => { .subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

10
apps/client/src/app/components/home-market/home-market.html

@ -36,16 +36,6 @@
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"
/> />
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-2 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
</div> </div>
</div> </div>

4
apps/client/src/app/components/home-market/home-market.module.ts

@ -4,7 +4,6 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@ -15,8 +14,7 @@ import { HomeMarketComponent } from './home-market.component';
CommonModule, CommonModule,
GfBenchmarkComponent, GfBenchmarkComponent,
GfFearAndGreedIndexModule, GfFearAndGreedIndexModule,
GfLineChartComponent, GfLineChartComponent
NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

35
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -29,17 +29,17 @@ import {
BarElement, BarElement,
Chart, Chart,
ChartData, ChartData,
LinearScale,
LineController, LineController,
LineElement, LineElement,
LinearScale,
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip Tooltip,
TooltipPosition
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { isAfter, isValid, min, subDays } from 'date-fns'; import { isAfter } from 'date-fns';
import { first } from 'lodash';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -52,7 +52,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataLabel = ''; @Input() benchmarkDataLabel = '';
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() currency: string; @Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy; @Input() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercent = false;
@ -79,7 +78,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (elements, position) => Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position); getTooltipPositionerMapTop(this.chart, position);
} }
@ -153,23 +152,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}; };
if (this.chartCanvas) { if (this.chartCanvas) {
let scaleXMin: string;
if (this.daysInMarket) {
const minDate = min([
parseDate(first(this.investments)?.date),
subDays(new Date().setHours(0, 0, 0, 0), this.daysInMarket)
]);
scaleXMin = isValid(minDate) ? minDate.toISOString() : undefined;
}
if (this.chart) { if (this.chart) {
this.chart.data = chartData; this.chart.data = chartData;
this.chart.options.plugins.tooltip = <unknown>( this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() this.getTooltipPluginConfiguration() as unknown;
);
this.chart.options.scales.x.min = scaleXMin;
if ( if (
this.savingsRate && this.savingsRate &&
@ -199,7 +185,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}, },
interaction: { intersect: false, mode: 'index' }, interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: <unknown>{ plugins: {
annotation: { annotation: {
annotations: { annotations: {
savingsRate: this.savingsRate savingsRate: this.savingsRate
@ -240,7 +226,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
}, } as unknown,
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -252,7 +238,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: { grid: {
display: false display: false
}, },
min: scaleXMin,
type: 'time', type: 'time',
time: { time: {
tooltipFormat: getDateFormatString(this.locale), tooltipFormat: getDateFormatString(this.locale),
@ -308,7 +293,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
unit: this.isInPercent ? '%' : undefined unit: this.isInPercent ? '%' : undefined
}), }),
mode: 'index', mode: 'index',
position: <unknown>'top', position: 'top' as unknown,
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

2
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -28,8 +28,6 @@ export class LoginWithAccessTokenDialog {
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService
) {} ) {}
ngOnInit() {}
public onChangeStaySignedIn(aValue: MatCheckboxChange) { public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting( this.settingsStorageService.setSetting(
KEY_STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,

7
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -8,7 +8,6 @@ import {
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -19,7 +18,7 @@ import { formatDistanceToNow } from 'date-fns';
templateUrl: './portfolio-summary.component.html', templateUrl: './portfolio-summary.component.html',
styleUrls: ['./portfolio-summary.component.scss'] styleUrls: ['./portfolio-summary.component.scss']
}) })
export class PortfolioSummaryComponent implements OnChanges, OnInit { export class PortfolioSummaryComponent implements OnChanges {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@ -35,10 +34,6 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
); );
public timeInMarket: string; public timeInMarket: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if (this.summary.firstOrderDate) { if (this.summary.firstOrderDate) {

6
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -1,5 +1,9 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import {
PortfolioReportRule,
XRayRulesSettings
} from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams { export interface IRuleSettingsDialogParams {
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
} }

14
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,4 +1,4 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
@ -9,8 +9,7 @@ import {
MatDialogModule, MatDialogModule,
MatDialogRef MatDialogRef
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSliderModule } from '@angular/material/slider';
import { MatInputModule } from '@angular/material/input';
import { IRuleSettingsDialogParams } from './interfaces/interfaces'; import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@ -20,8 +19,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
FormsModule, FormsModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatSliderModule
MatInputModule
], ],
selector: 'gf-rule-settings-dialog', selector: 'gf-rule-settings-dialog',
standalone: true, standalone: true,
@ -29,12 +27,10 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'
}) })
export class GfRuleSettingsDialogComponent { export class GfRuleSettingsDialogComponent {
public settings: PortfolioReportRule['settings']; public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) { ) {}
this.settings = this.data.rule.settings;
}
} }

90
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,37 +1,85 @@
<div mat-dialog-title>{{ data.rule.name }}</div> <div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
<mat-form-field <div
appearance="outline"
class="w-100" class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMin === undefined }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
> >
<mat-label i18n>Threshold Min</mat-label> <h6 class="mb-0">
<input <ng-container i18n>Threshold Min</ng-container>:
matInput @if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMin }}
}
</h6>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
name="thresholdMin" name="thresholdMin"
type="number" [max]="data.rule.configuration.threshold.max"
[(ngModel)]="settings.thresholdMin" [min]="data.rule.configuration.threshold.min"
/> [step]="data.rule.configuration.threshold.step"
</mat-form-field> >
<mat-form-field <input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
appearance="outline" </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
<div
class="w-100" class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMax === undefined }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
> >
<mat-label i18n>Threshold Max</mat-label> <h6 class="mb-0">
<input <ng-container i18n>Threshold Max</ng-container>:
matInput @if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMax | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMax }}
}
</h6>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
name="thresholdMax" name="thresholdMax"
type="number" [max]="data.rule.configuration.threshold.max"
[(ngModel)]="settings.thresholdMax" [min]="data.rule.configuration.threshold.min"
/> [step]="data.rule.configuration.threshold.step"
</mat-form-field> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div> </div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button i18n mat-button (click)="dialogRef.close()">Close</button>
<button color="primary" mat-flat-button (click)="dialogRef.close(settings)"> <button
color="primary"
mat-flat-button
(click)="dialogRef.close(data.settings)"
>
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>

2
apps/client/src/app/components/rule/rule.component.html

@ -62,7 +62,7 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings)) { @if (rule?.isActive && rule?.configuration) {
<button mat-menu-item (click)="onCustomizeRule(rule)"> <button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>... <ng-container i18n>Customize</ng-container>...
</button> </button>

19
apps/client/src/app/components/rule/rule.component.ts

@ -1,5 +1,9 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import {
PortfolioReportRule,
XRayRulesSettings
} from '@ghostfolio/common/interfaces';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -10,7 +14,6 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { isEmpty } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -27,11 +30,10 @@ export class RuleComponent implements OnInit {
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() rule: PortfolioReportRule; @Input() rule: PortfolioReportRule;
@Input() settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>(); @Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
public isEmpty = isEmpty;
private deviceType: string; private deviceType: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -46,16 +48,17 @@ export class RuleComponent implements OnInit {
public onCustomizeRule(rule: PortfolioReportRule) { public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, { const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: <IRuleSettingsDialogParams>{ data: {
rule rule,
}, settings: this.settings
} as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((settings: PortfolioReportRule['settings']) => { .subscribe((settings: RuleSettings) => {
if (settings) { if (settings) {
this.ruleUpdated.emit({ this.ruleUpdated.emit({
xRayRules: { xRayRules: {

1
apps/client/src/app/components/rules/rules.component.html

@ -12,6 +12,7 @@
hasPermissionToUpdateUserSettings hasPermissionToUpdateUserSettings
" "
[rule]="rule" [rule]="rule"
[settings]="settings?.[rule.key]"
(ruleUpdated)="onRuleUpdated($event)" (ruleUpdated)="onRuleUpdated($event)"
/> />
} }

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

Loading…
Cancel
Save