Browse Source

Merge commit '0be83cf033558bc67d4d498e425773b2dd484e6d' into feature/oidc-user-link

pull/6075/head
Germán Martín 4 weeks ago
parent
commit
fca069b2db
  1. 2
      .gitignore
  2. 94
      CHANGELOG.md
  3. 2
      README.md
  4. 7
      apps/api/src/app/activities/activities.controller.ts
  5. 4
      apps/api/src/app/activities/activities.module.ts
  6. 2
      apps/api/src/app/activities/activities.service.ts
  7. 4
      apps/api/src/app/admin/admin.module.ts
  8. 6
      apps/api/src/app/admin/admin.service.ts
  9. 9
      apps/api/src/app/admin/queue/queue.module.ts
  10. 24
      apps/api/src/app/admin/queue/queue.service.ts
  11. 4
      apps/api/src/app/app.module.ts
  12. 29
      apps/api/src/app/auth-device/auth-device.controller.ts
  13. 4
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  14. 5
      apps/api/src/app/export/export.controller.ts
  15. 5
      apps/api/src/app/export/export.service.ts
  16. 16
      apps/api/src/app/import/import.controller.ts
  17. 4
      apps/api/src/app/import/import.module.ts
  18. 4
      apps/api/src/app/info/info.module.ts
  19. 175
      apps/api/src/app/info/info.service.ts
  20. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  21. 2
      apps/api/src/app/subscription/subscription.service.ts
  22. 252
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  23. 25
      apps/api/src/environments/environment.prod.ts
  24. 4
      apps/api/src/events/events.module.ts
  25. 2
      apps/api/src/models/rule.ts
  26. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  27. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  28. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  29. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  30. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  31. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  32. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  33. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  34. 7
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  35. 7
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  36. 7
      apps/api/src/models/rules/liquidity/buying-power.ts
  37. 4
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  38. 4
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  39. 4
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  40. 4
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  41. 4
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  42. 6
      apps/api/src/services/cron/cron.module.ts
  43. 9
      apps/api/src/services/cron/cron.service.ts
  44. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  45. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  46. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  47. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  48. 2
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  49. 36
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  50. 212
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  51. 40
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.service.ts
  52. 97
      apps/client/src/app/app.component.ts
  53. 11
      apps/client/src/app/components/access-table/access-table.component.html
  54. 76
      apps/client/src/app/components/access-table/access-table.component.ts
  55. 101
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  56. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  57. 74
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  58. 307
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  59. 54
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  60. 92
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  61. 10
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts
  62. 13
      apps/client/src/app/components/admin-overview/admin-overview.html
  63. 36
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  64. 10
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  65. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  66. 57
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts
  67. 8
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts
  68. 83
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  69. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  70. 14
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  71. 21
      apps/client/src/app/components/markets/markets.component.ts
  72. 16
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts
  73. 99
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  74. 4
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  75. 29
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  76. 29
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  77. 43
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  78. 19
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  79. 8
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  80. 8
      apps/client/src/app/interfaces/interfaces.ts
  81. 58
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  82. 6
      apps/client/src/app/pages/admin/admin-page.component.ts
  83. 28
      apps/client/src/app/pages/api/api-page.component.ts
  84. 16
      apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts
  85. 16
      apps/client/src/app/pages/faq/saas/saas-page.component.ts
  86. 12
      apps/client/src/app/pages/markets/markets-page.component.ts
  87. 16
      apps/client/src/app/pages/open/open-page.component.ts
  88. 100
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  89. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  90. 265
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  91. 10
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  92. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts
  93. 20
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  94. 51
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  95. 32
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  96. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  97. 21
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  98. 87
      apps/client/src/app/pages/public/public-page.component.ts
  99. 14
      apps/client/src/app/pages/public/public-page.html
  100. 19
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts

2
.gitignore

@ -25,6 +25,8 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.claude/settings.local.json
.claude/worktrees
.cursor/rules/nx-rules.mdc .cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod

94
CHANGELOG.md

@ -5,6 +5,96 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.255.0 - 2026-03-20
### Changed
- Sorted the activity types alphabetically on the activities page (experimental)
- Sorted the asset classes of the assistant alphabetically
- Sorted the tags of the assistant alphabetically
- Upgraded `angular` from version `21.1.1` to `21.2.7`
- Upgraded `Nx` from version `22.5.3` to `22.6.4`
- Upgraded `prettier` from version `3.8.1` to `3.8.2`
- Upgraded `svgmap` from version `2.19.2` to `2.19.3`
- Upgraded `yahoo-finance2` from version `3.13.2` to `3.14.0`
### Fixed
- Fixed the missing value column of the accounts table component on mobile
## 2.254.0 - 2026-03-10
### Added
- Added loan as an asset sub class
### Changed
- Extended the asset profile details dialog in the admin control panel to support editing countries for all asset types
- Extended the asset profile details dialog in the admin control panel to support editing sectors for all asset types
- Migrated the data collection for the _Open Startup_ (`/open`) page to the queue design pattern
- Improved the language localization for German (`de`)
- Upgraded `lodash` from version `4.17.23` to `4.18.1`
### Fixed
- Improved the style of the activity type component
## 2.253.0 - 2026-03-06
### Added
- Added support for filtering by activity type on the activities page (experimental)
- Extended the admin control panel by adding a copy-to-clipboard button for the application version
### Changed
- Extended the terms of service for the _Ghostfolio_ SaaS (cloud) to include _Paid Plans_ and _Refund Policy_
- Upgraded `prisma` from version `6.19.0` to `6.19.3`
### Fixed
- Fixed the allocations by account chart on the allocations page in the _Presenter View_
- Fixed the allocations by asset class chart on the allocations page in the _Presenter View_
- Fixed the allocations by currency chart on the allocations page in the _Presenter View_
- Fixed the allocations by ETF provider chart on the allocations page in the _Presenter View_
- Fixed the allocations by platform chart on the allocations page in the _Presenter View_
## 2.252.0 - 2026-03-02
### Added
- Added support for a copy-to-clipboard functionality in the value component
- Extended the holding detail dialog by adding a copy-to-clipboard button for the ISIN number (experimental)
- Extended the holding detail dialog by adding a copy-to-clipboard button for the symbol (experimental)
- Extended the user detail dialog of the admin control panel’s users section by adding a copy-to-clipboard button for the user id
### Changed
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countries-list` from version `3.2.2` to `3.3.0`
- Upgraded `ng-extract-i18n-merge` from `3.2.1` to `3.3.0`
- Upgraded `stripe` from version `20.3.0` to `20.4.1`
## 2.251.0 - 2026-03-24
### Added
- Added the quantity column to the holdings table of the portfolio holdings page
### Changed
- Hardened the endpoint `DELETE /api/v1/auth-device/:id` by improving the user validation
- Improved the allocations by ETF holding on the allocations page by refining the grouping of the same assets with diverging names (experimental)
- Improved the language localization for Polish (`pl`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.2` to `6.0.2`
### Fixed
- Fixed an issue by adding a missing guard in the public access for portfolio sharing
## 2.250.0 - 2026-03-17 ## 2.250.0 - 2026-03-17
### Added ### Added
@ -5965,10 +6055,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema - Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription - Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022 ## 1.169.0 - 14.07.2022
### Added ### Added

2
README.md

@ -309,7 +309,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.

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

@ -37,7 +37,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma } from '@prisma/client'; import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -112,6 +112,7 @@ export class ActivitiesController {
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange, @Query('range') dateRange?: DateRange,
@ -139,6 +140,9 @@ export class ActivitiesController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
@ -147,6 +151,7 @@ export class ActivitiesController {
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate, startDate,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,

4
apps/api/src/app/activities/activities.module.ts

@ -10,7 +10,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { ActivitiesService } from './activities.service';
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,

2
apps/api/src/app/activities/activities.service.ts

@ -629,7 +629,7 @@ export class ActivitiesService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types?.length > 0) {
where.type = { in: types }; where.type = { in: types };
} }

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

@ -9,7 +9,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { QueueModule } from './queue/queue.module';
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
DemoModule, DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,

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

@ -625,23 +625,23 @@ export class AdminService {
const symbolProfileOverrides = { const symbolProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray,
name: name as string, name: name as string,
sectors: sectors as Prisma.JsonArray,
url: url as string url: url as string
}; };
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries,
currency, currency,
dataSource, dataSource,
holdings, holdings,
isActive, isActive,
scraperConfiguration, scraperConfiguration,
sectors,
symbol, symbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' ...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url } ? { assetClass, assetSubClass, countries, name, sectors, url }
: { : {
SymbolProfileOverrides: { SymbolProfileOverrides: {
upsert: { upsert: {

9
apps/api/src/app/admin/queue/queue.module.ts

@ -1,5 +1,6 @@
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,7 +9,11 @@ import { QueueService } from './queue.service';
@Module({ @Module({
controllers: [QueueController], controllers: [QueueController],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule], imports: [
DataGatheringQueueModule,
PortfolioSnapshotQueueModule,
StatisticsGatheringQueueModule
],
providers: [QueueService] providers: [QueueService]
}) })
export class QueueModule {} export class QueueModule {}

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

@ -1,7 +1,8 @@
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST QUEUE_JOB_STATUS_LIST,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -15,7 +16,9 @@ export class QueueService {
@InjectQueue(DATA_GATHERING_QUEUE) @InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue, private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue private readonly portfolioSnapshotQueue: Queue,
@InjectQueue(STATISTICS_GATHERING_QUEUE)
private readonly statisticsGatheringQueue: Queue
) {} ) {}
public async deleteJob(aId: string) { public async deleteJob(aId: string) {
@ -38,6 +41,7 @@ export class QueueService {
await this.dataGatheringQueue.clean(300, queueStatus); await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus); await this.portfolioSnapshotQueue.clean(300, queueStatus);
await this.statisticsGatheringQueue.clean(300, queueStatus);
} }
} }
@ -58,13 +62,19 @@ export class QueueService {
limit?: number; limit?: number;
status?: JobStatus[]; status?: JobStatus[];
}): Promise<AdminJobs> { }): Promise<AdminJobs> {
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ const [dataGatheringJobs, portfolioSnapshotJobs, statisticsGatheringJobs] =
this.dataGatheringQueue.getJobs(status), await Promise.all([
this.portfolioSnapshotQueue.getJobs(status) this.dataGatheringQueue.getJobs(status),
]); this.portfolioSnapshotQueue.getJobs(status),
this.statisticsGatheringQueue.getJobs(status)
]);
const jobsWithState = await Promise.all( const jobsWithState = await Promise.all(
[...dataGatheringJobs, ...portfolioSnapshotJobs] [
...dataGatheringJobs,
...portfolioSnapshotJobs,
...statisticsGatheringJobs
]
.filter((job) => { .filter((job) => {
return job; return job;
}) })

4
apps/api/src/app/app.module.ts

@ -8,7 +8,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
@ -109,7 +109,7 @@ import { UserModule } from './user/user.module';
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule, CronModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
EventsModule, EventsModule,

29
apps/api/src/app/auth-device/auth-device.controller.ts

@ -2,18 +2,43 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {} public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAuthDevice) @HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
const originalAuthDevice = await this.authDeviceService.authDevice({
id,
userId: this.request.user.id
});
if (!originalAuthDevice) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

4
apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -5,7 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -17,7 +17,7 @@ import { WatchlistService } from './watchlist.service';
controllers: [WatchlistController], controllers: [WatchlistController],
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,

5
apps/api/src/app/export/export.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@ -33,12 +34,15 @@ export class ExportController {
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -49,6 +53,7 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings

5
apps/api/src/app/export/export.service.ts

@ -10,7 +10,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
@ -24,11 +24,13 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
@ -44,6 +46,7 @@ export class ExportService {
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'asc', sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency, userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

16
apps/api/src/app/import/import.controller.ts

@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -62,7 +63,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium' this.request.user.subscription.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
@ -100,6 +101,17 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<ImportResponse> { ): Promise<ImportResponse> {
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
@ -107,6 +119,6 @@ export class ImportController {
userId: this.request.user.id userId: this.request.user.id
}); });
return { activities }; return { activities: activities.slice(0, maxActivitiesToImport) };
} }
} }

4
apps/api/src/app/import/import.module.ts

@ -12,7 +12,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -29,7 +29,7 @@ import { ImportService } from './import.service';
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,

4
apps/api/src/app/info/info.module.ts

@ -8,7 +8,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -22,7 +22,7 @@ import { InfoService } from './info.service';
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
JwtModule.register({ JwtModule.register({

175
apps/api/src/app/info/info.service.ts

@ -7,26 +7,24 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { encodeDataSource } from '@ghostfolio/common/helper';
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import { subDays } from 'date-fns';
import { format, subDays } from 'date-fns'; import { isNil } from 'lodash';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -149,68 +147,6 @@ export class InfoService {
}); });
} }
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { pull_count: number };
return pull_count;
} catch (error) {
Logger.error(error, 'InfoService - DockerHub');
return undefined;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.text());
const $ = cheerio.load(body);
return extractNumberFromString({
value: $(
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text()
});
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count;
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countNewUsers(aDays: number) { private async countNewUsers(aDays: number) {
return this.userService.count({ return this.userService.count({
where: { where: {
@ -230,12 +166,6 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS
);
}
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = await this.propertyService.getByKey<string>( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
@ -267,65 +197,56 @@ export class InfoService {
} }
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const [
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const dockerHubPulls = await this.countDockerHubPulls();
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
const uptime = await this.getUptime();
statistics = {
activeUsers1d, activeUsers1d,
activeUsers30d, activeUsers30d,
newUsers30d,
dockerHubPulls, dockerHubPulls,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d,
slackCommunityUsers, slackCommunityUsers,
uptime uptime
] = await Promise.all([
this.countActiveUsers(1),
this.countActiveUsers(30),
this.countNewUsers(30),
this.propertyService.getByKey<string>(PROPERTY_DOCKER_HUB_PULLS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_CONTRIBUTORS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_STARGAZERS),
this.propertyService.getByKey<string>(PROPERTY_SLACK_COMMUNITY_USERS),
this.propertyService.getByKey<string>(PROPERTY_UPTIME)
]);
statistics = {
activeUsers1d,
activeUsers30d,
newUsers30d,
dockerHubPulls: dockerHubPulls
? Number.parseInt(dockerHubPulls, 10)
: undefined,
gitHubContributors: gitHubContributors
? Number.parseInt(gitHubContributors, 10)
: undefined,
gitHubStargazers: gitHubStargazers
? Number.parseInt(gitHubStargazers, 10)
: undefined,
slackCommunityUsers: slackCommunityUsers
? Number.parseInt(slackCommunityUsers, 10)
: undefined,
uptime: uptime ? Number.parseFloat(uptime) : undefined
}; };
await this.redisCacheService.set( if (
InfoService.CACHE_KEY_STATISTICS, Object.values(statistics).every((value) => {
JSON.stringify(statistics) return !isNil(value);
); })
) {
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
}
return statistics; return statistics;
} }
private async getUptime(): Promise<number> {
{
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'InfoService - Better Stack');
return undefined;
}
}
}
} }

4
apps/api/src/app/portfolio/portfolio.module.ts

@ -17,7 +17,7 @@ import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -38,7 +38,7 @@ import { RulesService } from './rules.service';
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2026-01-28.clover' apiVersion: '2026-02-25.clover'
} }
); );
} }

252
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

25
apps/api/src/environments/environment.prod.ts

@ -1,7 +1,30 @@
import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config'; import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const getVersion = () => {
if (process.env.APP_VERSION) {
return process.env.APP_VERSION;
}
if (process.env.npm_package_version) {
return process.env.npm_package_version;
}
try {
const packageJson = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf8')
);
return packageJson.version ?? 'dev';
} catch {
return 'dev';
}
};
export const environment = { export const environment = {
production: true, production: true,
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`, rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
version: `${require('../../../../package.json').version}` version: getVersion()
}; };

4
apps/api/src/events/events.module.ts

@ -3,7 +3,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -14,7 +14,7 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
RedisCacheModule RedisCacheModule

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

@ -70,8 +70,6 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult; public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getCategoryName(): string;
public abstract getConfiguration(): Partial< public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration'] PortfolioReportRule['configuration']
>; >;

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

@ -98,13 +98,6 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

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

@ -57,13 +57,6 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

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

@ -82,13 +82,6 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

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

@ -75,13 +75,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

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

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

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

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

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

@ -40,13 +40,6 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts

@ -56,13 +56,6 @@ export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/liquidity/buying-power.ts

@ -63,13 +63,6 @@ export class BuyingPower extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -72,10 +72,6 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

6
apps/api/src/services/cron/cron.module.ts

@ -2,7 +2,8 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,9 +13,10 @@ import { CronService } from './cron.service';
@Module({ @Module({
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PropertyModule, PropertyModule,
StatisticsGatheringQueueModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

9
apps/api/src/services/cron/cron.service.ts

@ -3,6 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
@ -25,10 +26,18 @@ export class CronService {
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly statisticsGatheringService: StatisticsGatheringService,
private readonly twitterBotService: TwitterBotService, private readonly twitterBotService: TwitterBotService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
await this.statisticsGatheringService.addJobsToQueue();
}
}
@Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)
public async runEveryHourAtRandomMinute() { public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {

10
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -16,7 +16,7 @@ import {
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import Alphavantage from 'alphavantage'; import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
@ -24,12 +24,16 @@ import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable() @Injectable()
export class AlphaVantageService implements DataProviderInterface { export class AlphaVantageService
implements DataProviderInterface, OnModuleInit
{
public alphaVantage; public alphaVantage;
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
this.alphaVantage = Alphavantage({ this.alphaVantage = Alphavantage({
key: this.configurationService.get('API_KEY_ALPHA_VANTAGE') key: this.configurationService.get('API_KEY_ALPHA_VANTAGE')
}); });

12
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -17,7 +17,7 @@ import {
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -27,13 +27,15 @@ import {
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly apiUrl: string; private apiUrl: string;
private readonly headers: HeadersInit = {}; private headers: HeadersInit = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO'); const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO'); const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');

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

@ -22,7 +22,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -33,14 +33,18 @@ import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit
{
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
} }

10
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -23,7 +23,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -44,7 +44,9 @@ import {
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = { private static countriesMapping = {
'Korea (the Republic of)': 'South Korea', 'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia', 'Russian Federation': 'Russia',
@ -57,7 +59,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP' 'API_KEY_FINANCIAL_MODELING_PREP'
); );

2
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -50,4 +50,4 @@ import { DataGatheringProcessor } from './data-gathering.processor';
providers: [DataGatheringProcessor, DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [BullModule, DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringQueueModule {}

36
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -0,0 +1,36 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { StatisticsGatheringProcessor } from './statistics-gathering.processor';
import { StatisticsGatheringService } from './statistics-gathering.service';
@Module({
exports: [BullModule, StatisticsGatheringService],
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE,
options: {
displayName: 'Statistics Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
name: STATISTICS_GATHERING_QUEUE
}),
ConfigurationModule,
PropertyModule
],
providers: [StatisticsGatheringProcessor, StatisticsGatheringService]
})
export class StatisticsGatheringQueueModule {}

212
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -0,0 +1,212 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_UPTIME,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns';
@Injectable()
@Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService
) {}
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() {
Logger.log(
'Docker Hub pulls statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const dockerHubPulls = await this.countDockerHubPulls();
await this.propertyService.put({
key: PROPERTY_DOCKER_HUB_PULLS,
value: String(dockerHubPulls)
});
Logger.log(
'Docker Hub pulls statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() {
Logger.log(
'GitHub contributors statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubContributors = await this.countGitHubContributors();
await this.propertyService.put({
key: PROPERTY_GITHUB_CONTRIBUTORS,
value: String(gitHubContributors)
});
Logger.log(
'GitHub contributors statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() {
Logger.log(
'GitHub stargazers statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubStargazers = await this.countGitHubStargazers();
await this.propertyService.put({
key: PROPERTY_GITHUB_STARGAZERS,
value: String(gitHubStargazers)
});
Logger.log(
'GitHub stargazers statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME)
public async gatherUptimeStatistics() {
Logger.log(
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime();
await this.propertyService.put({
key: PROPERTY_UPTIME,
value: String(uptime)
});
Logger.log(
'Uptime statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { pull_count: number };
return pull_count;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub');
throw error;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.text());
const $ = cheerio.load(body);
const value = $(
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text();
if (!value) {
throw new Error('Could not find the contributors count in the page');
}
return extractNumberFromString({
value
});
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub');
throw error;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub');
throw error;
}
}
private async getUptime(): Promise<number> {
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - Better Stack');
throw error;
}
}
}

40
apps/api/src/services/queues/statistics-gathering/statistics-gathering.service.ts

@ -0,0 +1,40 @@
import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_PROCESS_JOB_OPTIONS,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
@Injectable()
export class StatisticsGatheringService {
public constructor(
@InjectQueue(STATISTICS_GATHERING_QUEUE)
private readonly statisticsGatheringQueue: Queue
) {}
public async addJobsToQueue() {
return Promise.all(
[
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
].map((jobName) => {
return this.statisticsGatheringQueue.add(
jobName,
{},
{
...GATHER_STATISTICS_PROCESS_JOB_OPTIONS,
jobId: jobName
}
);
})
);
}
}

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

@ -13,7 +13,7 @@ import {
DestroyRef, DestroyRef,
DOCUMENT, DOCUMENT,
HostBinding, HostBinding,
Inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -36,7 +36,7 @@ import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component'; import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component'; import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from './components/holding-detail-dialog/interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@ -48,10 +48,6 @@ import { UserService } from './services/user/user.service';
templateUrl: './app.component.html' templateUrl: './app.component.html'
}) })
export class GfAppComponent implements OnInit { export class GfAppComponent implements OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string; public currentSubRoute: string;
@ -66,43 +62,47 @@ export class GfAppComponent implements OnInit {
public pageTitle: string; public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public showFooter = false; public showFooter = false;
public user: User; public user: User | undefined;
public constructor( private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly deviceService = inject(DeviceDetectorService);
private deviceService: DeviceDetectorService, private readonly dialog = inject(MatDialog);
private dialog: MatDialog, private readonly document = inject(DOCUMENT);
@Inject(DOCUMENT) private document: Document, private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private notificationService: NotificationService, );
private route: ActivatedRoute, private readonly notificationService = inject(NotificationService);
private router: Router, private readonly route = inject(ActivatedRoute);
private title: Title, private readonly router = inject(Router);
private userService: UserService private readonly title = inject(Title);
) { private readonly userService = inject(UserService);
public constructor() {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe(
if ( ({ dataSource, holdingDetailDialog, symbol }: GfAppQueryParams) => {
params['dataSource'] && if (dataSource && holdingDetailDialog && symbol) {
params['holdingDetailDialog'] && this.openHoldingDetailDialog({
params['symbol'] dataSource,
) { symbol
this.openHoldingDetailDialog({ });
dataSource: params['dataSource'], }
symbol: params['symbol']
});
} }
}); );
addIcons({ openOutline }); addIcons({ openOutline });
} }
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
@ -128,7 +128,7 @@ export class GfAppComponent implements OnInit {
!this.currentSubRoute) || !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.home.path && (this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
@ -141,18 +141,18 @@ export class GfAppComponent implements OnInit {
if ( if (
(this.currentRoute === internalRoutes.home.path && (this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute) || !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.activities.path) || internalRoutes.portfolio.subRoutes?.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.allocations.path) || internalRoutes.portfolio.subRoutes?.allocations.path) ||
(this.currentRoute === internalRoutes.zen.path && (this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) internalRoutes.home.subRoutes?.holdings.path)
) { ) {
this.hasPermissionToChangeFilters = true; this.hasPermissionToChangeFilters = true;
} else { } else {
@ -223,11 +223,17 @@ export class GfAppComponent implements OnInit {
} }
public onClickSystemMessage() { public onClickSystemMessage() {
if (this.user.systemMessage.routerLink) { const systemMessage = this.user?.systemMessage;
this.router.navigate(this.user.systemMessage.routerLink);
if (!systemMessage) {
return;
}
if (systemMessage.routerLink) {
void this.router.navigate(systemMessage.routerLink);
} else { } else {
this.notificationService.alert({ this.notificationService.alert({
title: this.user.systemMessage.message title: systemMessage.message
}); });
} }
} }
@ -269,10 +275,7 @@ export class GfAppComponent implements OnInit {
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
GfHoldingDetailDialogComponent,
HoldingDetailDialogParams
>(GfHoldingDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
dataSource, dataSource,
@ -313,7 +316,7 @@ export class GfAppComponent implements OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.router.navigate([], { void this.router.navigate([], {
queryParams: { queryParams: {
dataSource: null, dataSource: null,
holdingDetailDialog: null, holdingDetailDialog: null,
@ -339,6 +342,6 @@ export class GfAppComponent implements OnInit {
this.document this.document
.querySelector('meta[name="theme-color"]') .querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor); ?.setAttribute('content', themeColor);
} }
} }

11
apps/client/src/app/components/access-table/access-table.component.html

@ -39,7 +39,7 @@
getPublicUrl(element.id) getPublicUrl(element.id)
}}</a> }}</a>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) { @if (user()?.settings?.isExperimentalFeatures) {
<div> <div>
<code <code
>GET {{ baseUrl }}/api/v1/public/{{ >GET {{ baseUrl }}/api/v1/public/{{
@ -69,7 +69,7 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
> >
@if (user?.settings?.isExperimentalFeatures) { @if (user()?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)"> <button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
@ -86,7 +86,8 @@
</button> </button>
} }
@if ( @if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC' user()?.settings?.isExperimentalFeatures ||
element.type === 'PUBLIC'
) { ) {
<hr class="my-0" /> <hr class="my-0" />
} }
@ -100,7 +101,7 @@
</td> </td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns()" mat-row></tr>
</table> </table>
</div> </div>

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

@ -7,11 +7,12 @@ import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
EventEmitter, effect,
Input, inject,
OnChanges, input,
Output output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -46,23 +47,32 @@ import ms from 'ms';
templateUrl: './access-table.component.html', templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss'] styleUrls: ['./access-table.component.scss']
}) })
export class GfAccessTableComponent implements OnChanges { export class GfAccessTableComponent {
@Input() accesses: Access[]; public readonly accesses = input.required<Access[]>();
@Input() showActions: boolean; public readonly showActions = input<boolean>(false);
@Input() user: User; public readonly user = input.required<User>();
@Output() accessDeleted = new EventEmitter<string>(); public readonly accessDeleted = output<string>();
@Output() accessToUpdate = new EventEmitter<string>(); public readonly accessToUpdate = output<string>();
public baseUrl = window.location.origin; protected readonly baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; protected readonly dataSource = new MatTableDataSource<Access>();
public displayedColumns = [];
protected readonly displayedColumns = computed(() => {
public constructor( const columns = ['alias', 'grantee', 'type', 'details'];
private clipboard: Clipboard,
private notificationService: NotificationService, if (this.showActions()) {
private snackBar: MatSnackBar columns.push('actions');
) { }
return columns;
});
private readonly clipboard = inject(Clipboard);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
public constructor() {
addIcons({ addIcons({
copyOutline, copyOutline,
createOutline, createOutline,
@ -72,27 +82,19 @@ export class GfAccessTableComponent implements OnChanges {
lockOpenOutline, lockOpenOutline,
removeCircleOutline removeCircleOutline
}); });
}
public ngOnChanges() { effect(() => {
this.displayedColumns = ['alias', 'grantee', 'type', 'details']; this.dataSource.data = this.accesses() ?? [];
});
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
} }
public getPublicUrl(aId: string): string { protected getPublicUrl(aId: string) {
const languageCode = this.user.settings.language; const languageCode = this.user().settings.language;
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
} }
public onCopyUrlToClipboard(aId: string): void { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));
this.snackBar.open( this.snackBar.open(
@ -104,7 +106,7 @@ export class GfAccessTableComponent implements OnChanges {
); );
} }
public onDeleteAccess(aId: string) { protected onDeleteAccess(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.accessDeleted.emit(aId); this.accessDeleted.emit(aId);
@ -114,7 +116,7 @@ export class GfAccessTableComponent implements OnChanges {
}); });
} }
public onUpdateAccess(aId: string) { protected onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId); this.accessToUpdate.emit(aId);
} }
} }

101
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -12,7 +12,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -27,7 +26,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@ -78,41 +77,42 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'account-detail-dialog.html' templateUrl: 'account-detail-dialog.html'
}) })
export class GfAccountDetailDialogComponent implements OnInit { export class GfAccountDetailDialogComponent implements OnInit {
public accountBalances: AccountBalancesResponse['balances']; protected accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; protected activitiesCount: number;
public activitiesCount: number; protected balance: number;
public balance: number; protected balancePrecision = 2;
public balancePrecision = 2; protected currency: string | null;
public currency: string; protected dataSource: MatTableDataSource<Activity>;
public dataSource: MatTableDataSource<Activity>; protected dividendInBaseCurrency: number;
public dividendInBaseCurrency: number; protected dividendInBaseCurrencyPrecision = 2;
public dividendInBaseCurrencyPrecision = 2; protected equity: number | null;
public equity: number; protected equityPrecision = 2;
public equityPrecision = 2; protected hasPermissionToDeleteAccountBalance: boolean;
public hasPermissionToDeleteAccountBalance: boolean; protected historicalDataItems: HistoricalDataItem[];
public historicalDataItems: HistoricalDataItem[]; protected holdings: PortfolioPosition[];
public holdings: PortfolioPosition[]; protected interestInBaseCurrency: number;
public interestInBaseCurrency: number; protected interestInBaseCurrencyPrecision = 2;
public interestInBaseCurrencyPrecision = 2; protected isLoadingActivities: boolean;
public isLoadingActivities: boolean; protected isLoadingChart: boolean;
public isLoadingChart: boolean; protected name: string | null;
public name: string; protected platformName: string;
public platformName: string; protected sortColumn = 'date';
public sortColumn = 'date'; protected sortDirection: SortDirection = 'desc';
public sortDirection: SortDirection = 'desc'; protected totalItems: number;
public totalItems: number; protected user: User;
public user: User; protected valueInBaseCurrency: number;
public valueInBaseCurrency: number;
protected readonly data = inject<AccountDetailDialogParams>(MAT_DIALOG_DATA);
public constructor(
private changeDetectorRef: ChangeDetectorRef, private readonly changeDetectorRef = inject(ChangeDetectorRef);
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly dialogRef =
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>, inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef);
private router: Router, private readonly router = inject(Router);
private userService: UserService private readonly userService = inject(UserService);
) {
public constructor() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -135,7 +135,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.initialize(); this.initialize();
} }
public onCloneActivity(aActivity: Activity) { protected onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -146,11 +146,11 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService this.dataService
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -159,7 +159,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onDeleteAccountBalance(aId: string) { protected onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -168,7 +168,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onExport() { protected onExport() {
const activityIds = this.dataSource.data.map(({ id }) => { const activityIds = this.dataSource.data.map(({ id }) => {
return id; return id;
}); });
@ -180,7 +180,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
downloadAsFile({ downloadAsFile({
content: data, content: data,
fileName: `ghostfolio-export-${this.name fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-') ?.replace(/\s+/g, '-')
.toLowerCase()}-${format( .toLowerCase()}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
@ -190,14 +190,14 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onSortChanged({ active, direction }: Sort) { protected onSortChanged({ active, direction }: Sort) {
this.sortColumn = active; this.sortColumn = active;
this.sortDirection = direction; this.sortDirection = direction;
this.fetchActivities(); this.fetchActivities();
} }
public onUpdateActivity(aActivity: Activity) { protected onUpdateActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -208,6 +208,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView
);
}
private fetchAccount() { private fetchAccount() {
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
@ -324,7 +330,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
next: ({ accountBalances, portfolioPerformance }) => { next: ({ accountBalances, portfolioPerformance }) => {
this.accountBalances = accountBalances.balances; this.accountBalances = accountBalances.balances;
if (portfolioPerformance.chart.length > 0) { if (
portfolioPerformance.chart &&
portfolioPerformance.chart.length > 0
) {
this.historicalDataItems = portfolioPerformance.chart.map( this.historicalDataItems = portfolioPerformance.chart.map(
({ date, netWorth, netWorthInPercentage }) => ({ ({ date, netWorth, netWorthInPercentage }) => ({
date, date,

8
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -24,9 +24,7 @@
class="h-100" class="h-100"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isInPercent]=" [isInPercentage]="showValuesInPercentage()"
data.hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingChart" [isLoading]="isLoadingChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -118,9 +116,7 @@
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="!showValuesInPercentage()"
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

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

@ -20,12 +20,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
inject,
OnInit, OnInit,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
ReactiveFormsModule ReactiveFormsModule
@ -74,19 +75,25 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './admin-jobs.html' templateUrl: './admin-jobs.html'
}) })
export class GfAdminJobsComponent implements OnInit { export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort; protected readonly sort = viewChild.required(MatSort);
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW; protected readonly DATA_GATHERING_QUEUE_PRIORITY_HIGH =
public DATA_GATHERING_QUEUE_PRIORITY_HIGH =
DATA_GATHERING_QUEUE_PRIORITY_HIGH; DATA_GATHERING_QUEUE_PRIORITY_HIGH;
public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
protected readonly DATA_GATHERING_QUEUE_PRIORITY_LOW =
DATA_GATHERING_QUEUE_PRIORITY_LOW;
protected readonly DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM; DATA_GATHERING_QUEUE_PRIORITY_MEDIUM;
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>(); protected dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string; protected defaultDateTimeFormat: string;
public filterForm: FormGroup;
protected readonly filterForm = new FormGroup({
status: new FormControl<JobStatus | null>(null)
});
public displayedColumns = [ protected readonly displayedColumns = [
'index', 'index',
'type', 'type',
'symbol', 'symbol',
@ -99,21 +106,20 @@ export class GfAdminJobsComponent implements OnInit {
'actions' 'actions'
]; ];
public hasPermissionToAccessBullBoard = false; protected hasPermissionToAccessBullBoard = false;
public isLoading = false; protected isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
private user: User; private user: User;
public constructor( private readonly adminService = inject(AdminService);
private adminService: AdminService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly notificationService = inject(NotificationService);
private formBuilder: FormBuilder, private readonly tokenStorageService = inject(TokenStorageService);
private notificationService: NotificationService, private readonly userService = inject(UserService);
private tokenStorageService: TokenStorageService,
private userService: UserService public constructor() {
) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -148,21 +154,17 @@ export class GfAdminJobsComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.filterForm = this.formBuilder.group({
status: []
});
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.controls.status.value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
this.fetchJobs(); this.fetchJobs();
} }
public onDeleteJob(aId: string) { protected onDeleteJob(aId: string) {
this.adminService this.adminService
.deleteJob(aId) .deleteJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -171,18 +173,18 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
public onDeleteJobs() { protected onDeleteJobs() {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.controls.status.value;
this.adminService this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined }) .deleteJobs({ status: currentFilter ? [currentFilter] : [] })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
} }
public onExecuteJob(aId: string) { protected onExecuteJob(aId: string) {
this.adminService this.adminService
.executeJob(aId) .executeJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -191,7 +193,7 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
public onOpenBullBoard() { protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken(); const token = this.tokenStorageService.getToken();
document.cookie = [ document.cookie = [
@ -203,13 +205,13 @@ export class GfAdminJobsComponent implements OnInit {
window.open(BULL_BOARD_ROUTE, '_blank'); window.open(BULL_BOARD_ROUTE, '_blank');
} }
public onViewData(aData: AdminJobs['jobs'][0]['data']) { protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')
}); });
} }
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) { protected onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aStacktrace, null, ' ') title: JSON.stringify(aStacktrace, null, ' ')
}); });
@ -223,7 +225,7 @@ export class GfAdminJobsComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs); this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
this.isLoading = false; this.isLoading = false;

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

@ -19,7 +19,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { validateObjectForForm } from '@ghostfolio/common/utils'; import { jsonValidator, validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -87,6 +87,7 @@ import {
readerOutline, readerOutline,
serverOutline serverOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isBoolean } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -129,52 +130,56 @@ export class GfAssetProfileDialogComponent implements OnInit {
)};123.45`; )};123.45`;
@ViewChild('assetProfileFormElement') @ViewChild('assetProfileFormElement')
assetProfileFormElement: ElementRef<HTMLFormElement>; public readonly assetProfileFormElement: ElementRef<HTMLFormElement>;
public assetClassLabel: string; protected assetClassLabel: string;
public assetSubClassLabel: string; protected assetSubClassLabel: string;
public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass) protected readonly assetClassOptions: AssetClassSelectorOption[] =
.map((id) => { Object.keys(AssetClass)
return { id, label: translate(id) } as AssetClassSelectorOption; .map((id) => {
}) return { id, label: translate(id) } as AssetClassSelectorOption;
.sort((a, b) => { })
return a.label.localeCompare(b.label); .sort((a, b) => {
}); return a.label.localeCompare(b.label);
});
public assetSubClassOptions: AssetClassSelectorOption[] = []; protected assetSubClassOptions: AssetClassSelectorOption[] = [];
public assetProfile: AdminMarketDataDetails['assetProfile']; protected assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ protected readonly assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined), assetClass: new FormControl<AssetClass | null>(null),
assetSubClass: new FormControl<AssetSubClass>(undefined), assetSubClass: new FormControl<AssetSubClass | null>(null),
comment: '', comment: '',
countries: '', countries: ['', jsonValidator()],
currency: '', currency: '',
historicalData: this.formBuilder.group({ historicalData: this.formBuilder.group({
csvString: '' csvString: ''
}), }),
isActive: [true], isActive: [true],
name: ['', Validators.required], name: ['', Validators.required],
scraperConfiguration: this.formBuilder.group({ scraperConfiguration: this.formBuilder.group<
defaultMarketPrice: null, Omit<ScraperConfiguration, 'headers'> & {
headers: JSON.stringify({}), headers: FormControl<string | null>;
}
>({
defaultMarketPrice: undefined,
headers: new FormControl(JSON.stringify({}), jsonValidator()),
locale: '', locale: '',
mode: '', mode: 'lazy',
selector: '', selector: '',
url: '' url: ''
}), }),
sectors: '', sectors: ['', jsonValidator()],
symbolMapping: '', symbolMapping: ['', jsonValidator()],
url: '' url: ''
}); });
public assetProfileIdentifierForm = this.formBuilder.group( protected readonly assetProfileIdentifierForm = this.formBuilder.group(
{ {
assetProfileIdentifier: new FormControl<AssetProfileIdentifier>( assetProfileIdentifier: new FormControl<
{ symbol: null, dataSource: null }, AssetProfileIdentifier | { dataSource: null; symbol: null }
[Validators.required] >({ dataSource: null, symbol: null }, [Validators.required])
)
}, },
{ {
validators: (control) => { validators: (control) => {
@ -183,16 +188,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
); );
public benchmarks: Partial<SymbolProfile>[]; protected canEditAssetProfile = true;
public canEditAssetProfile = true;
public countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: string[] = []; protected currencies: string[] = [];
public dateRangeOptions = [ protected readonly dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
value: 'wtd' value: 'wtd'
@ -218,14 +222,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
public historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
public isBenchmark = false; protected isBenchmark = false;
public isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
public isEditAssetProfileIdentifierMode = false; protected isEditAssetProfileIdentifierMode = false;
public isUUID = isUUID; protected readonly isUUID = isUUID;
public marketDataItems: MarketData[] = []; protected marketDataItems: MarketData[] = [];
public modeValues = [ protected readonly modeValues = [
{ {
value: 'lazy', value: 'lazy',
viewValue: $localize`Lazy` + ' (' + $localize`end of day` + ')' viewValue: $localize`Lazy` + ' (' + $localize`end of day` + ')'
@ -236,20 +240,22 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
]; ];
public sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public user: User; protected user: User;
private benchmarks: Partial<SymbolProfile>[];
public constructor( public constructor(
public adminMarketDataService: AdminMarketDataService, protected adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>, private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@ -264,7 +270,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public get canSaveAssetProfileIdentifier() { protected get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty && this.canEditAssetProfile; return !this.assetProfileForm.dirty && this.canEditAssetProfile;
} }
@ -277,8 +283,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize(); this.initialize();
} }
public initialize() { protected initialize() {
this.historicalDataItems = undefined; this.historicalDataItems = [];
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
@ -298,10 +304,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
}); });
this.assetProfileForm this.assetProfileForm.controls.assetClass.valueChanges
.get('assetClass') .pipe(takeUntilDestroyed(this.destroyRef))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => { .subscribe((assetClass) => {
if (!assetClass) {
return;
}
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
this.assetSubClassOptions = assetSubClasses this.assetSubClassOptions = assetSubClasses
@ -313,7 +322,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}) })
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
this.assetProfileForm.get('assetSubClass').setValue(null); this.assetProfileForm.controls.assetSubClass.setValue(null);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -327,8 +336,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.assetClassLabel = translate(this.assetProfile?.assetClass); this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass); this.assetSubClassLabel = translate(
this.assetProfile?.assetSubClass ?? ''
);
this.canEditAssetProfile = !isCurrency( this.canEditAssetProfile = !isCurrency(
getCurrencyFromSymbol(this.data.symbol) getCurrencyFromSymbol(this.data.symbol)
@ -350,7 +361,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.marketDataItems = marketData; this.marketDataItems = marketData;
this.sectors = {}; this.sectors = {};
if (this.assetProfile?.countries?.length > 0) { if (
this.assetProfile?.countries &&
this.assetProfile.countries.length > 0
) {
for (const { code, name, weight } of this.assetProfile.countries) { for (const { code, name, weight } of this.assetProfile.countries) {
this.countries[code] = { this.countries[code] = {
name, name,
@ -359,7 +373,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
} }
if (this.assetProfile?.sectors?.length > 0) { if (
this.assetProfile?.sectors &&
this.assetProfile.sectors.length > 0
) {
for (const { name, weight } of this.assetProfile.sectors) { for (const { name, weight } of this.assetProfile.sectors) {
this.sectors[name] = { this.sectors[name] = {
name, name,
@ -377,12 +394,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
return { code, weight }; return { code, weight };
}) ?? [] }) ?? []
), ),
currency: this.assetProfile?.currency, currency: this.assetProfile?.currency ?? null,
historicalData: { historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
}, },
isActive: this.assetProfile?.isActive, isActive: isBoolean(this.assetProfile?.isActive)
name: this.assetProfile.name ?? this.assetProfile.symbol, ? this.assetProfile.isActive
: null,
name: this.assetProfile.name ?? this.assetProfile.symbol ?? null,
scraperConfiguration: { scraperConfiguration: {
defaultMarketPrice: defaultMarketPrice:
this.assetProfile?.scraperConfiguration?.defaultMarketPrice ?? this.assetProfile?.scraperConfiguration?.defaultMarketPrice ??
@ -410,7 +429,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onCancelEditAssetProfileIdentifierMode() { protected onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false; this.isEditAssetProfileIdentifierMode = false;
if (this.canEditAssetProfile) { if (this.canEditAssetProfile) {
@ -420,17 +439,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileIdentifierForm.reset(); this.assetProfileIdentifierForm.reset();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { protected onDeleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close(); this.dialogRef.close();
} }
public onGatherProfileDataBySymbol({ protected onGatherProfileDataBySymbol({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
@ -440,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
public onGatherSymbol({ protected onGatherSymbol({
dataSource, dataSource,
range, range,
symbol symbol
@ -453,13 +475,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
public onMarketDataChanged(withRefresh: boolean = false) { protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
} }
} }
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { protected onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.postBenchmark({ dataSource, symbol }) .postBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -472,50 +494,48 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onSetEditAssetProfileIdentifierMode() { protected onSetEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = true; this.isEditAssetProfileIdentifierMode = true;
this.assetProfileForm.disable(); this.assetProfileForm.disable();
} }
public async onSubmitAssetProfileForm() { protected async onSubmitAssetProfileForm() {
let countries = []; let countries: Prisma.InputJsonArray = [];
let scraperConfiguration: ScraperConfiguration = { let scraperConfiguration: Prisma.InputJsonObject | undefined = {
selector: '', selector: '',
url: '' url: ''
}; };
let sectors = []; let sectors: Prisma.InputJsonArray = [];
let symbolMapping = {}; let symbolMapping: Record<string, string> = {};
try { try {
countries = JSON.parse(this.assetProfileForm.get('countries').value); countries = JSON.parse(
this.assetProfileForm.controls.countries.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {} } catch {}
try { try {
scraperConfiguration = { scraperConfiguration = {
defaultMarketPrice: defaultMarketPrice:
(this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls
'defaultMarketPrice' .defaultMarketPrice?.value ?? undefined,
].value as number) || undefined,
headers: JSON.parse( headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.headers
'headers' .value ?? '{}'
].value ) as Record<string, string>,
),
locale: locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.locale
'locale' ?.value ?? undefined,
].value || undefined, mode:
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.mode
'mode' ?.value ?? undefined,
].value as ScraperConfiguration['mode'],
selector: selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.selector
'selector' .value ?? '',
].value, url:
url: this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.url
'url' .value ?? ''
].value
}; };
if (!scraperConfiguration.selector || !scraperConfiguration.url) { if (!scraperConfiguration.selector || !scraperConfiguration.url) {
@ -536,28 +556,32 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
try { try {
sectors = JSON.parse(this.assetProfileForm.get('sectors').value); sectors = JSON.parse(
this.assetProfileForm.controls.sectors.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {} } catch {}
try { try {
symbolMapping = JSON.parse( symbolMapping = JSON.parse(
this.assetProfileForm.get('symbolMapping').value this.assetProfileForm.controls.symbolMapping.value ?? '{}'
); ) as Record<string, string>;
} catch {} } catch {}
const assetProfile: UpdateAssetProfileDto = { const assetProfile: UpdateAssetProfileDto = {
countries, countries,
scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
assetClass: this.assetProfileForm.get('assetClass').value, assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass: this.assetProfileForm.get('assetSubClass').value, assetSubClass:
comment: this.assetProfileForm.get('comment').value || null, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
currency: this.assetProfileForm.get('currency').value, comment: this.assetProfileForm.controls.comment.value ?? undefined,
isActive: this.assetProfileForm.get('isActive').value, currency: this.assetProfileForm.controls.currency.value ?? undefined,
name: this.assetProfileForm.get('name').value, isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
scraperConfiguration: ? this.assetProfileForm.controls.isActive.value
scraperConfiguration as unknown as Prisma.InputJsonObject, : undefined,
url: this.assetProfileForm.get('url').value || null name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
}; };
try { try {
@ -600,7 +624,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize(); this.initialize();
}, },
error: (error) => { error: (error: HttpErrorResponse) => {
console.error($localize`Could not save asset profile`, error); console.error($localize`Could not save asset profile`, error);
this.snackBar.open( this.snackBar.open(
@ -614,12 +638,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public async onSubmitAssetProfileIdentifierForm() { protected async onSubmitAssetProfileIdentifierForm() {
const assetProfileIdentifier: UpdateAssetProfileDto = { const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: this.assetProfileIdentifierForm.get('assetProfileIdentifier') dataSource:
.value.dataSource, this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
symbol: this.assetProfileIdentifierForm.get('assetProfileIdentifier') ?.dataSource ?? undefined,
.value.symbol symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
}; };
try { try {
@ -676,38 +702,33 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onTestMarketData() { protected onTestMarketData() {
this.adminService this.adminService
.testMarketData({ .testMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
scraperConfiguration: { scraperConfiguration: {
defaultMarketPrice: this.assetProfileForm.controls[ defaultMarketPrice:
'scraperConfiguration' this.assetProfileForm.controls.scraperConfiguration.controls
].controls['defaultMarketPrice'].value as number, .defaultMarketPrice?.value,
headers: JSON.parse( headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.headers
'headers' .value ?? '{}'
].value ) as Record<string, string>,
),
locale: locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.locale
'locale' ?.value || undefined,
].value || undefined, mode: this.assetProfileForm.controls.scraperConfiguration.controls
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[ .mode?.value,
'mode'
].value,
selector: selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls
'selector' .selector.value,
].value, url: this.assetProfileForm.controls.scraperConfiguration.controls.url
url: this.assetProfileForm.controls['scraperConfiguration'].controls[ .value
'url'
].value
}, },
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe( .pipe(
catchError(({ error }) => { catchError(({ error }: HttpErrorResponse) => {
this.notificationService.alert({ this.notificationService.alert({
message: error?.message, message: error?.message,
title: $localize`Error` title: $localize`Error`
@ -723,26 +744,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
' ' + ' ' +
price + price +
' ' + ' ' +
this.assetProfileForm.get('currency').value this.assetProfileForm.controls.currency.value
}); });
}); });
} }
public onToggleIsActive({ checked }: MatCheckboxChange) { protected onToggleIsActive({ checked }: MatCheckboxChange) {
if (checked) { if (checked) {
this.assetProfileForm.get('isActive')?.setValue(true); this.assetProfileForm.controls.isActive.setValue(true);
} else { } else {
this.assetProfileForm.get('isActive')?.setValue(false); this.assetProfileForm.controls.isActive.setValue(false);
} }
if (checked === this.assetProfile.isActive) { if (checked === this.assetProfile.isActive) {
this.assetProfileForm.get('isActive')?.markAsPristine(); this.assetProfileForm.controls.isActive.markAsPristine();
} else { } else {
this.assetProfileForm.get('isActive')?.markAsDirty(); this.assetProfileForm.controls.isActive.markAsDirty();
} }
} }
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { protected onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -755,15 +776,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onTriggerSubmitAssetProfileForm() { protected onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm.valid) { if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm(); this.onSubmitAssetProfileForm();
} }
} }
private isNewSymbolValid(control: AbstractControl): ValidationErrors { private isNewSymbolValid(control: AbstractControl): ValidationErrors | null {
const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined = const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined =
control.get('assetProfileIdentifier').value; control.get('assetProfileIdentifier')?.value;
if ( if (
currentAssetProfileIdentifier?.dataSource === this.data?.dataSource && currentAssetProfileIdentifier?.dataSource === this.data?.dataSource &&
@ -773,5 +794,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
equalsPreviousProfileIdentifier: true equalsPreviousProfileIdentifier: true
}; };
} }
return null;
} }
} }

54
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -280,7 +280,7 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="sectors" [data]="sectors"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
/> />
@ -290,7 +290,7 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="countries" [data]="countries"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
/> />
@ -393,39 +393,37 @@
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
@if (assetProfile?.dataSource === 'MANUAL') { <div>
<div> <mat-form-field appearance="outline" class="w-100">
<mat-form-field appearance="outline" class="w-100"> <mat-label i18n>Sectors</mat-label>
<mat-label i18n>Sectors</mat-label> <textarea
<textarea cdkTextareaAutosize
cdkTextareaAutosize formControlName="sectors"
formControlName="sectors" matInput
matInput type="text"
type="text" ></textarea>
></textarea> </mat-form-field>
</mat-form-field> </div>
</div> <div>
<div> <mat-form-field appearance="outline" class="w-100">
<mat-form-field appearance="outline" class="w-100"> <mat-label i18n>Countries</mat-label>
<mat-label i18n>Countries</mat-label> <textarea
<textarea cdkTextareaAutosize
cdkTextareaAutosize formControlName="countries"
formControlName="countries" matInput
matInput type="text"
type="text" ></textarea>
></textarea> </mat-form-field>
</mat-form-field> </div>
</div>
}
<div> <div>
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" /> <input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) { @if (assetProfileForm.controls.url.value) {
<gf-entity-logo <gf-entity-logo
class="mr-3" class="mr-3"
matSuffix matSuffix
[url]="assetProfileForm.get('url').value" [url]="assetProfileForm.controls.url.value"
/> />
} }
</mat-form-field> </mat-form-field>

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

@ -3,6 +3,7 @@ import {
ghostfolioPrefix, ghostfolioPrefix,
PROPERTY_CURRENCIES PROPERTY_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
@ -11,6 +12,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -18,7 +20,6 @@ import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
FormControl, FormControl,
FormGroup,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
ValidationErrors, ValidationErrors,
@ -34,7 +35,10 @@ import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode } from 'class-validator';
import { switchMap } from 'rxjs'; import { switchMap } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; import type {
CreateAssetProfileDialogMode,
CreateAssetProfileForm
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -54,32 +58,44 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
templateUrl: 'create-asset-profile-dialog.html' templateUrl: 'create-asset-profile-dialog.html'
}) })
export class GfCreateAssetProfileDialogComponent implements OnInit { export class GfCreateAssetProfileDialogComponent implements OnInit {
public createAssetProfileForm: FormGroup; protected createAssetProfileForm: CreateAssetProfileForm;
public ghostfolioPrefix = `${ghostfolioPrefix}_`; protected readonly ghostfolioPrefix = `${ghostfolioPrefix}_`;
public mode: CreateAssetProfileDialogMode; protected mode: CreateAssetProfileDialogMode;
private customCurrencies: string[]; private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource; private dataSourceForExchangeRates: DataSource;
public constructor( private readonly adminService = inject(AdminService);
public readonly adminService: AdminService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private readonly dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private readonly destroyRef: DestroyRef, private readonly dialogRef =
public readonly dialogRef: MatDialogRef<GfCreateAssetProfileDialogComponent>, inject<MatDialogRef<GfCreateAssetProfileDialogComponent>>(MatDialogRef);
public readonly formBuilder: FormBuilder private readonly formBuilder = inject(FormBuilder);
) {}
protected get showCurrencyErrorMessage() {
const addCurrencyFormControl =
this.createAssetProfileForm.controls.addCurrency;
if (addCurrencyFormControl.hasError('invalidCurrency')) {
return true;
}
return false;
}
public ngOnInit() { public ngOnInit() {
this.initialize(); this.initialize();
this.createAssetProfileForm = this.formBuilder.group( this.createAssetProfileForm = this.formBuilder.group(
{ {
addCurrency: new FormControl(null, [ addCurrency: new FormControl<string | null>(null, [
this.iso4217CurrencyCodeValidator() this.iso4217CurrencyCodeValidator()
]), ]),
addSymbol: new FormControl(null, [Validators.required]), addSymbol: new FormControl<string | null>(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl<AssetProfileIdentifier | null>(null, [
Validators.required
])
}, },
{ {
validators: this.atLeastOneValid validators: this.atLeastOneValid
@ -104,12 +120,11 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
this.dialogRef.close({ this.dialogRef.close({
addAssetProfile: true, addAssetProfile: true,
dataSource: dataSource:
this.createAssetProfileForm.get('searchSymbol').value.dataSource, this.createAssetProfileForm.controls.searchSymbol.value?.dataSource,
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol symbol: this.createAssetProfileForm.controls.searchSymbol.value?.symbol
}); });
} else if (this.mode === 'currency') { } else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm.get('addCurrency') const currency = this.createAssetProfileForm.controls.addCurrency.value;
.value as string;
const currencies = Array.from( const currencies = Array.from(
new Set([...this.customCurrencies, currency]) new Set([...this.customCurrencies, currency])
@ -139,26 +154,15 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
this.dialogRef.close({ this.dialogRef.close({
addAssetProfile: true, addAssetProfile: true,
dataSource: 'MANUAL', dataSource: 'MANUAL',
symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.get('addSymbol').value}` symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.controls.addSymbol.value}`
}); });
} }
} }
public get showCurrencyErrorMessage() { private atLeastOneValid(control: CreateAssetProfileForm): ValidationErrors {
const addCurrencyFormControl = const addCurrencyControl = control.controls.addCurrency;
this.createAssetProfileForm.get('addCurrency'); const addSymbolControl = control.controls.addSymbol;
const searchSymbolControl = control.controls.searchSymbol;
if (addCurrencyFormControl.hasError('invalidCurrency')) {
return true;
}
return false;
}
private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addCurrencyControl = control.get('addCurrency');
const addSymbolControl = control.get('addSymbol');
const searchSymbolControl = control.get('searchSymbol');
if ( if (
addCurrencyControl.valid && addCurrencyControl.valid &&
@ -170,11 +174,8 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
if ( if (
addCurrencyControl.valid || addCurrencyControl.valid ||
!addCurrencyControl ||
addSymbolControl.valid || addSymbolControl.valid ||
!addSymbolControl || searchSymbolControl.valid
searchSymbolControl.valid ||
!searchSymbolControl
) { ) {
return { atLeastOneValid: false }; return { atLeastOneValid: false };
} }
@ -189,11 +190,14 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
.subscribe(({ dataProviders, settings }) => { .subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
const { dataSource } = dataProviders.find(({ useForExchangeRates }) => { const { dataSource } =
return useForExchangeRates; dataProviders.find(({ useForExchangeRates }) => {
}); return useForExchangeRates;
}) ?? {};
this.dataSourceForExchangeRates = dataSource; if (dataSource) {
this.dataSourceForExchangeRates = dataSource;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

10
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts

@ -1,6 +1,16 @@
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { FormControl, FormGroup } from '@angular/forms';
export interface CreateAssetProfileDialogParams { export interface CreateAssetProfileDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
} }
export type CreateAssetProfileDialogMode = 'auto' | 'currency' | 'manual'; export type CreateAssetProfileDialogMode = 'auto' | 'currency' | 'manual';
export type CreateAssetProfileForm = FormGroup<{
addCurrency: FormControl<string | null>;
addSymbol: FormControl<string | null>;
searchSymbol: FormControl<AssetProfileIdentifier | null>;
}>;

13
apps/client/src/app/components/admin-overview/admin-overview.html

@ -6,7 +6,10 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Version</div> <div class="w-50" i18n>Version</div>
<div class="w-50"> <div class="w-50">
<gf-value [value]="version" /> <gf-value
[enableCopyToClipboardButton]="true"
[value]="version"
/>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
@ -104,7 +107,13 @@
<table> <table>
@for (coupon of coupons; track coupon) { @for (coupon of coupons; track coupon) {
<tr> <tr>
<td class="text-monospace">{{ coupon.code }}</td> <td>
<gf-value
class="text-monospace"
[enableCopyToClipboardButton]="true"
[value]="coupon.code"
/>
</td>
<td class="pl-2 text-right"> <td class="pl-2 text-right">
{{ formatStringValue(coupon.duration) }} {{ formatStringValue(coupon.duration) }}
</td> </td>

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

@ -35,10 +35,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
Inject, Inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
@ -67,8 +68,7 @@ import {
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { switchMap } from 'rxjs/operators';
import { switchMap, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -102,7 +102,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./holding-detail-dialog.component.scss'], styleUrls: ['./holding-detail-dialog.component.scss'],
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number; public activitiesCount: number;
public accounts: Account[]; public accounts: Account[];
public assetClass: string; public assetClass: string;
@ -158,11 +158,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public user: User; public user: User;
public value: number; public value: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -192,7 +191,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.holdingForm this.holdingForm
.get('tags') .get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
@ -217,7 +216,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
switchMap(() => { switchMap(() => {
return this.userService.get(true); return this.userService.get(true);
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(); .subscribe();
} else { } else {
@ -227,7 +226,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
}); });
@ -236,7 +235,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.fetchAccounts({ .fetchAccounts({
filters filters
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts }) => { .subscribe(({ accounts }) => {
this.accounts = accounts; this.accounts = accounts;
@ -249,7 +248,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
@ -261,7 +260,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
({ ({
activitiesCount, activitiesCount,
@ -524,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -582,7 +581,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.postActivity(activity) .postActivity(activity)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink internalRoutes.portfolio.subRoutes.activities.routerLink
@ -599,7 +598,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchExport({ activityIds }) .fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: data, content: data,
@ -629,18 +628,13 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchMarketData() { private fetchMarketData() {
this.dataService this.dataService
.fetchMarketDataBySymbol({ .fetchMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ marketData }) => { .subscribe(({ marketData }) => {
this.marketDataItems = marketData; this.marketDataItems = marketData;

10
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -286,7 +286,7 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="sectors" [data]="sectors"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
@ -298,7 +298,7 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="countries" [data]="countries"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
@ -310,6 +310,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.symbol" [hidden]="!SymbolProfile?.symbol"
[value]="SymbolProfile?.symbol" [value]="SymbolProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
@ -318,6 +321,9 @@
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
size="medium" size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.isin" [hidden]="!SymbolProfile?.isin"
[value]="SymbolProfile?.isin" [value]="SymbolProfile?.isin"
>ISIN</gf-value >ISIN</gf-value

1
apps/client/src/app/components/home-holdings/home-holdings.html

@ -46,7 +46,6 @@
} }
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }"> <div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
<gf-holdings-table <gf-holdings-table
[hasPermissionToShowQuantities]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"

57
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts

@ -1,14 +1,8 @@
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormControl, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
@ -19,7 +13,8 @@ import {
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { Subject } from 'rxjs';
import { CreateWatchlistItemForm } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -36,51 +31,41 @@ import { Subject } from 'rxjs';
styleUrls: ['./create-watchlist-item-dialog.component.scss'], styleUrls: ['./create-watchlist-item-dialog.component.scss'],
templateUrl: 'create-watchlist-item-dialog.html' templateUrl: 'create-watchlist-item-dialog.html'
}) })
export class GfCreateWatchlistItemDialogComponent implements OnDestroy, OnInit { export class GfCreateWatchlistItemDialogComponent {
public createWatchlistItemForm: FormGroup; protected readonly createWatchlistItemForm: CreateWatchlistItemForm =
new FormGroup(
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly dialogRef: MatDialogRef<GfCreateWatchlistItemDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createWatchlistItemForm = this.formBuilder.group(
{ {
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl<AssetProfileIdentifier | null>(null, [
Validators.required
])
}, },
{ {
validators: this.validator validators: this.validator
} }
); );
}
public onCancel() { private readonly dialogRef =
inject<MatDialogRef<GfCreateWatchlistItemDialogComponent>>(MatDialogRef);
protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSubmit() { protected onSubmit() {
this.dialogRef.close({ this.dialogRef.close({
dataSource: dataSource:
this.createWatchlistItemForm.get('searchSymbol').value.dataSource, this.createWatchlistItemForm.controls.searchSymbol.value?.dataSource,
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol symbol: this.createWatchlistItemForm.controls.searchSymbol.value?.symbol
}); });
} }
public ngOnDestroy() { private validator(control: CreateWatchlistItemForm): ValidationErrors {
this.unsubscribeSubject.next(); const searchSymbolControl = control.controls.searchSymbol;
this.unsubscribeSubject.complete();
}
private validator(control: AbstractControl): ValidationErrors {
const searchSymbolControl = control.get('searchSymbol');
if ( if (
searchSymbolControl.valid && searchSymbolControl.valid &&
searchSymbolControl.value.dataSource && searchSymbolControl.value?.dataSource &&
searchSymbolControl.value.symbol searchSymbolControl.value?.symbol
) { ) {
return { incomplete: false }; return { incomplete: false };
} }

8
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts

@ -1,4 +1,12 @@
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { FormControl, FormGroup } from '@angular/forms';
export interface CreateWatchlistItemDialogParams { export interface CreateWatchlistItemDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
} }
export type CreateWatchlistItemForm = FormGroup<{
searchSymbol: FormControl<AssetProfileIdentifier | null>;
}>;

83
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,5 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -14,10 +15,13 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -25,8 +29,6 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons'; import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces'; import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@ -45,37 +47,39 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
styleUrls: ['./home-watchlist.scss'], styleUrls: ['./home-watchlist.scss'],
templateUrl: './home-watchlist.html' templateUrl: './home-watchlist.html'
}) })
export class GfHomeWatchlistComponent implements OnDestroy, OnInit { export class GfHomeWatchlistComponent implements OnInit {
public deviceType: string; protected hasImpersonationId: boolean;
public hasImpersonationId: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean; protected user: User;
public user: User; protected watchlist: Benchmark[];
public watchlist: Benchmark[];
protected readonly deviceType = computed(
private unsubscribeSubject = new Subject<void>(); () => this.deviceDetectorService.deviceInfo().deviceType
);
public constructor(
private changeDetectorRef: ChangeDetectorRef, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private dataService: DataService, private readonly dataService = inject(DataService);
private deviceService: DeviceDetectorService, private readonly destroyRef = inject(DestroyRef);
private dialog: MatDialog, private readonly deviceDetectorService = inject(DeviceDetectorService);
private impersonationStorageService: ImpersonationStorageService, private readonly dialog = inject(MatDialog);
private route: ActivatedRoute, private readonly impersonationStorageService = inject(
private router: Router, ImpersonationStorageService
private userService: UserService );
) { private readonly route = inject(ActivatedRoute);
this.deviceType = this.deviceService.getDeviceInfo().deviceType; private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['createWatchlistItemDialog']) { if (params['createWatchlistItemDialog']) {
this.openCreateWatchlistItemDialog(); this.openCreateWatchlistItemDialog();
@ -83,7 +87,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -112,13 +116,13 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
this.loadWatchlistData(); this.loadWatchlistData();
} }
public onWatchlistItemDeleted({ protected onWatchlistItemDeleted({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.dataService this.dataService
.deleteWatchlistItem({ dataSource, symbol }) .deleteWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
return this.loadWatchlistData(); return this.loadWatchlistData();
@ -126,15 +130,10 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadWatchlistData() { private loadWatchlistData() {
this.dataService this.dataService
.fetchWatchlist() .fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => { .subscribe(({ watchlist }) => {
this.watchlist = watchlist; this.watchlist = watchlist;
@ -145,7 +144,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
private openCreateWatchlistItemDialog() { private openCreateWatchlistItemDialog() {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -155,20 +154,20 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
>(GfCreateWatchlistItemDialogComponent, { >(GfCreateWatchlistItemDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType, deviceType: this.deviceType(),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale ?? defaultLocale
}, },
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => { .subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) { if (dataSource && symbol) {
this.dataService this.dataService
.postWatchlistItem({ dataSource, symbol }) .postWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => this.loadWatchlistData() next: () => this.loadWatchlistData()
}); });

2
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -11,7 +11,7 @@
<div class="col-xs-12 col-md-10 offset-md-1"> <div class="col-xs-12 col-md-10 offset-md-1">
<gf-benchmark <gf-benchmark
[benchmarks]="watchlist" [benchmarks]="watchlist"
[deviceType]="deviceType" [deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"

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

@ -61,7 +61,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string; @Input() currency: string;
@Input() groupBy: GroupBy; @Input() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercentage = false;
@Input() isLoading = false; @Input() isLoading = false;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ -119,7 +119,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
data: this.investments.map(({ date, investment }) => { data: this.investments.map(({ date, investment }) => {
return { return {
x: parseDate(date).getTime(), x: parseDate(date).getTime(),
y: this.isInPercent ? investment * 100 : investment y: this.isInPercentage ? investment * 100 : investment
}; };
}), }),
label: this.benchmarkDataLabel, label: this.benchmarkDataLabel,
@ -139,7 +139,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
data: this.values.map(({ date, value }) => { data: this.values.map(({ date, value }) => {
return { return {
x: parseDate(date).getTime(), x: parseDate(date).getTime(),
y: this.isInPercent ? value * 100 : value y: this.isInPercentage ? value * 100 : value
}; };
}), }),
fill: false, fill: false,
@ -251,7 +251,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
border: { border: {
display: false display: false
}, },
display: !this.isInPercent, display: !this.isInPercentage,
grid: { grid: {
color: ({ scale, tick }) => { color: ({ scale, tick }) => {
if ( if (
@ -292,10 +292,10 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
currency: this.isInPercent ? undefined : this.currency, currency: this.isInPercentage ? undefined : this.currency,
groupBy: this.groupBy, groupBy: this.groupBy,
locale: this.isInPercent ? undefined : this.locale, locale: this.isInPercentage ? undefined : this.locale,
unit: this.isInPercent ? '%' : undefined unit: this.isInPercentage ? '%' : undefined
}), }),
mode: 'index', mode: 'index',
position: 'top', position: 'top',

21
apps/client/src/app/components/markets/markets.component.ts

@ -19,12 +19,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -39,7 +38,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./markets.scss'], styleUrls: ['./markets.scss'],
templateUrl: './markets.html' templateUrl: './markets.html'
}) })
export class GfMarketsComponent implements OnDestroy, OnInit { export class GfMarketsComponent implements OnInit {
public benchmarks: Benchmark[]; public benchmarks: Benchmark[];
public deviceType: string; public deviceType: string;
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
@ -55,18 +54,17 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365; public readonly numberOfDays = 365;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -79,7 +77,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.dataService this.dataService
.fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ fearAndGreedIndex }) => { .subscribe(({ fearAndGreedIndex }) => {
this.fearAndGreedIndexData = fearAndGreedIndex; this.fearAndGreedIndexData = fearAndGreedIndex;
@ -90,7 +88,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchBenchmarks() .fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => { .subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
@ -119,9 +117,4 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.initialize(); this.initialize();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

16
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts

@ -7,9 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject, Inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -21,8 +23,8 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { arrowForwardOutline, checkmarkCircleOutline } from 'ionicons/icons'; import { arrowForwardOutline, checkmarkCircleOutline } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { interval, Subject } from 'rxjs'; import { interval } from 'rxjs';
import { take, takeUntil, tap } from 'rxjs/operators'; import { take, tap } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces'; import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@ -51,11 +53,10 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public routerLinkPricing = publicRoutes.pricing.routerLink; public routerLinkPricing = publicRoutes.pricing.routerLink;
public variantIndex: number; public variantIndex: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams, @Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfSubscriptionInterstitialDialogComponent> public dialogRef: MatDialogRef<GfSubscriptionInterstitialDialogComponent>
) { ) {
this.variantIndex = Math.floor( this.variantIndex = Math.floor(
@ -76,7 +77,7 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(); .subscribe();
} }
@ -84,9 +85,4 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public closeDialog() { public closeDialog() {
this.dialogRef.close({}); this.dialogRef.close({});
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

99
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -3,14 +3,16 @@ import { validateObjectForForm } from '@ghostfolio/common/utils';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import type { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, DestroyRef,
OnDestroy, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -28,7 +30,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs'; import { EMPTY, catchError } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -48,22 +50,25 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'], styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class GfCreateOrUpdateAccessDialogComponent export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
implements OnDestroy, OnInit protected accessForm: FormGroup;
{ protected mode: 'create' | 'update';
public accessForm: FormGroup;
public mode: 'create' | 'update'; private readonly changeDetectorRef = inject(ChangeDetectorRef);
private unsubscribeSubject = new Subject<void>(); private readonly data =
inject<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA);
public constructor(
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, private readonly destroyRef = inject(DestroyRef);
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private dataService: DataService, private readonly dialogRef =
private formBuilder: FormBuilder, inject<MatDialogRef<GfCreateOrUpdateAccessDialogComponent>>(MatDialogRef);
private notificationService: NotificationService
) { private readonly formBuilder = inject(FormBuilder);
private readonly notificationService = inject(NotificationService);
public constructor() {
this.mode = this.data.access?.id ? 'update' : 'create'; this.mode = this.data.access?.id ? 'update' : 'create';
} }
@ -83,22 +88,25 @@ export class GfCreateOrUpdateAccessDialogComponent
] ]
}); });
this.accessForm.get('type').valueChanges.subscribe((accessType) => { this.accessForm
const granteeUserIdControl = this.accessForm.get('granteeUserId'); .get('type')
const permissionsControl = this.accessForm.get('permissions'); ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions');
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required); granteeUserIdControl?.setValidators(Validators.required);
} else { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl.setValue(null); granteeUserIdControl?.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]); permissionsControl?.setValue(this.data.access.permissions[0]);
} }
granteeUserIdControl.updateValueAndValidity(); granteeUserIdControl?.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
public onCancel() { public onCancel() {
@ -113,16 +121,11 @@ export class GfCreateOrUpdateAccessDialogComponent
} }
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async createAccess() { private async createAccess() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions')?.value]
}; };
try { try {
@ -135,7 +138,7 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService this.dataService
.postAccess(access) .postAccess(access)
.pipe( .pipe(
catchError((error) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.BAD_REQUEST) { if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not grant access.` title: $localize`Oops! Could not grant access.`
@ -144,7 +147,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.dialogRef.close(access); this.dialogRef.close(access);
@ -156,10 +159,10 @@ export class GfCreateOrUpdateAccessDialogComponent
private async updateAccess() { private async updateAccess() {
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: this.data.access.id, id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions')?.value]
}; };
try { try {
@ -172,8 +175,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService this.dataService
.putAccess(access) .putAccess(access)
.pipe( .pipe(
catchError(({ status }) => { catchError(({ status }: HttpErrorResponse) => {
if (status.status === StatusCodes.BAD_REQUEST) { if (status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not update access.` title: $localize`Oops! Could not update access.`
}); });
@ -181,7 +184,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.dialogRef.close(access); this.dialogRef.close(access);

4
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -38,13 +38,13 @@
<mat-label i18n>Permission</mat-label> <mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions"> <mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option> <mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if (accessForm.get('type').value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<mat-option i18n value="READ">View</mat-option> <mat-option i18n value="READ">View</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@if (accessForm.get('type').value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label> <mat-label>

29
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -13,9 +13,10 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
@ -26,8 +27,8 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { GfCreateOrUpdateAccessDialogComponent } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; import { GfCreateOrUpdateAccessDialogComponent } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dialog/interfaces/interfaces'; import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dialog/interfaces/interfaces';
@ -51,7 +52,7 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
styleUrls: ['./user-account-access.scss'], styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html' templateUrl: './user-account-access.html'
}) })
export class GfUserAccountAccessComponent implements OnDestroy, OnInit { export class GfUserAccountAccessComponent implements OnInit {
public accessesGet: Access[]; public accessesGet: Access[];
public accessesGive: Access[]; public accessesGive: Access[];
public deviceType: string; public deviceType: string;
@ -64,11 +65,10 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -85,7 +85,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -110,7 +110,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateAccessDialog(); this.openCreateAccessDialog();
@ -131,7 +131,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
public onDeleteAccess(aId: string) { public onDeleteAccess(aId: string) {
this.dataService this.dataService
.deleteAccess(aId) .deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.update(); this.update();
@ -154,7 +154,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ accessToken }) => { .subscribe(({ accessToken }) => {
this.notificationService.alert({ this.notificationService.alert({
@ -179,11 +179,6 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog() { private openCreateAccessDialog() {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfCreateOrUpdateAccessDialogComponent, GfCreateOrUpdateAccessDialogComponent,
@ -217,8 +212,6 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
if (!access) { if (!access) {
console.log('Could not find access.');
return; return;
} }
@ -261,7 +254,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchAccesses() .fetchAccesses()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accesses) => { .subscribe((accesses) => {
this.accessesGive = accesses; this.accessesGive = accesses;

29
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -14,15 +14,16 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy DestroyRef
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,7 +39,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-membership.scss'], styleUrls: ['./user-account-membership.scss'],
templateUrl: './user-account-membership.html' templateUrl: './user-account-membership.html'
}) })
export class GfUserAccountMembershipComponent implements OnDestroy { export class GfUserAccountMembershipComponent {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
@ -54,11 +55,10 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
@ -73,7 +73,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -118,7 +118,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ sessionUrl }) => { .subscribe(({ sessionUrl }) => {
window.location.href = sessionUrl; window.location.href = sessionUrl;
@ -142,7 +142,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ apiKey }) => { .subscribe(({ apiKey }) => {
this.notificationService.alert({ this.notificationService.alert({
@ -180,7 +180,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
const snackBarRef = this.snackBar.open( const snackBarRef = this.snackBar.open(
@ -193,14 +193,14 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
snackBarRef snackBarRef
.afterDismissed() .afterDismissed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
window.location.reload(); window.location.reload();
}); });
snackBarRef snackBarRef
.onAction() .onAction()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
window.location.reload(); window.location.reload();
}); });
@ -210,9 +210,4 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
title: $localize`Please enter your coupon code.` title: $localize`Please enter your coupon code.`
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

43
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -19,9 +19,10 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormsModule, FormsModule,
@ -44,8 +45,8 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons'; import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs'; import { EMPTY, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -66,7 +67,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-settings.scss'], styleUrls: ['./user-account-settings.scss'],
templateUrl: './user-account-settings.html' templateUrl: './user-account-settings.html'
}) })
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { export class GfUserAccountSettingsComponent implements OnInit {
public appearancePlaceholder = $localize`Auto`; public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string; public baseCurrency: string;
public canLinkOidc = false; public canLinkOidc = false;
@ -103,12 +104,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
]; ];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
@ -134,7 +134,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -231,11 +231,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onChangeUserSetting(aKey: string, aValue: string) { public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService this.dataService
.putUserSetting({ [aKey]: aValue }) .putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -299,7 +299,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.userService.signOut(); this.userService.signOut();
@ -315,11 +315,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) .putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -331,7 +331,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExport() { public onExport() {
this.dataService this.dataService
.fetchExport() .fetchExport()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
for (const activity of data.activities) { for (const activity of data.activities) {
delete activity.id; delete activity.id;
@ -351,11 +351,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onRestrictedViewChange(aEvent: MatSlideToggleChange) { public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked }) .putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -390,11 +390,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onViewModeChange(aEvent: MatSlideToggleChange) { public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -403,11 +403,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deregisterDevice() { private deregisterDevice() {
this.webAuthnService this.webAuthnService
.deregister() .deregister()
@ -417,7 +412,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.update(); this.update();
@ -447,7 +442,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return error; return error;
}); });
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe({ .subscribe({
next: () => { next: () => {

19
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -7,10 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject, Inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@ -18,8 +19,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons'; import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { UserDetailDialogParams } from './interfaces/interfaces'; import { UserDetailDialogParams } from './interfaces/interfaces';
@ -38,15 +39,14 @@ import { UserDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./user-detail-dialog.component.scss'], styleUrls: ['./user-detail-dialog.component.scss'],
templateUrl: './user-detail-dialog.html' templateUrl: './user-detail-dialog.html'
}) })
export class GfUserDetailDialogComponent implements OnDestroy, OnInit { export class GfUserDetailDialogComponent implements OnInit {
public user: AdminUserResponse; public user: AdminUserResponse;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent> public dialogRef: MatDialogRef<GfUserDetailDialogComponent>
) { ) {
addIcons({ addIcons({
@ -58,7 +58,7 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.fetchUserById(this.data.userId) .fetchUserById(this.data.userId)
.pipe( .pipe(
takeUntil(this.unsubscribeSubject), takeUntilDestroyed(this.destroyRef),
catchError(() => { catchError(() => {
this.dialogRef.close(); this.dialogRef.close();
@ -82,9 +82,4 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

8
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -27,7 +27,13 @@
<div class="container p-0"> <div class="container p-0">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="user?.id">User ID</gf-value> <gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[value]="user?.id"
>User ID</gf-value
>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="user?.role">Role</gf-value> <gf-value i18n size="medium" [value]="user?.role">Role</gf-value>

8
apps/client/src/app/interfaces/interfaces.ts

@ -0,0 +1,8 @@
import type { Params } from '@angular/router';
import type { DataSource } from '@prisma/client';
export interface GfAppQueryParams extends Params {
dataSource?: DataSource;
holdingDetailDialog?: string;
symbol?: string;
}

58
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -5,7 +5,7 @@ import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { CommonModule, NgClass } from '@angular/common'; import { CommonModule, NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -51,17 +51,17 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html' templateUrl: 'create-or-update-account-dialog.html'
}) })
export class GfCreateOrUpdateAccountDialogComponent { export class GfCreateOrUpdateAccountDialogComponent {
public accountForm: FormGroup; protected accountForm: FormGroup;
public currencies: string[] = []; protected currencies: string[] = [];
public filteredPlatforms: Observable<Platform[]>; protected filteredPlatforms: Observable<Platform[]> | undefined;
public platforms: Platform[] = []; protected platforms: Platform[] = [];
public constructor( protected readonly data =
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams, inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private dataService: DataService, private readonly dataService = inject(DataService);
public dialogRef: MatDialogRef<GfCreateOrUpdateAccountDialogComponent>, private readonly dialogRef =
private formBuilder: FormBuilder inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
) {} private readonly formBuilder = inject(FormBuilder);
public ngOnInit() { public ngOnInit() {
const { currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
@ -93,18 +93,18 @@ export class GfCreateOrUpdateAccountDialogComponent {
this.filteredPlatforms = this.accountForm this.filteredPlatforms = this.accountForm
.get('platformId') .get('platformId')
.valueChanges.pipe( ?.valueChanges.pipe(
startWith(''), startWith(''),
map((value) => { map((value: Platform | string) => {
const name = typeof value === 'string' ? value : value?.name; const name = typeof value === 'string' ? value : value?.name;
return name ? this.filter(name as string) : this.platforms.slice(); return name ? this.filter(name) : this.platforms.slice();
}) })
); );
}); });
} }
public autoCompleteCheck() { protected autoCompleteCheck() {
const inputValue = this.accountForm.get('platformId').value; const inputValue = this.accountForm.get('platformId')?.value;
if (typeof inputValue === 'string') { if (typeof inputValue === 'string') {
const matchingEntry = this.platforms.find(({ name }) => { const matchingEntry = this.platforms.find(({ name }) => {
@ -112,28 +112,28 @@ export class GfCreateOrUpdateAccountDialogComponent {
}); });
if (matchingEntry) { if (matchingEntry) {
this.accountForm.get('platformId').setValue(matchingEntry); this.accountForm.get('platformId')?.setValue(matchingEntry);
} }
} }
} }
public displayFn(platform: Platform) { protected displayFn(platform: Platform) {
return platform?.name ?? ''; return platform?.name ?? '';
} }
public onCancel() { protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onSubmit() { protected async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = { const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.get('balance').value, balance: this.accountForm.get('balance')?.value,
comment: this.accountForm.get('comment').value || null, comment: this.accountForm.get('comment')?.value || null,
currency: this.accountForm.get('currency').value, currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId').value, id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded').value, isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name').value, name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId').value?.id || null platformId: this.accountForm.get('platformId')?.value?.id || null
}; };
try { try {
@ -177,7 +177,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
const filterValue = value.toLowerCase(); const filterValue = value.toLowerCase();
return this.platforms.filter(({ name }) => { return this.platforms.filter(({ name }) => {
return name.toLowerCase().startsWith(filterValue); return name?.toLowerCase().startsWith(filterValue);
}); });
} }
} }

6
apps/client/src/app/pages/admin/admin-page.component.ts

@ -47,11 +47,7 @@ export class AdminPageComponent implements OnInit {
}, },
{ {
iconName: 'settings-outline', iconName: 'settings-outline',
label: label: internalRoutes.adminControl.subRoutes.settings.title,
internalRoutes.adminControl.subRoutes.settings.title +
'<span class="badge badge-pill badge-secondary ml-2 text-uppercase">' +
$localize`new` +
'</span>',
routerLink: internalRoutes.adminControl.subRoutes.settings.routerLink routerLink: internalRoutes.adminControl.subRoutes.settings.routerLink
}, },
{ {

28
apps/client/src/app/pages/api/api-page.component.ts

@ -14,9 +14,10 @@ import {
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { format, startOfYear } from 'date-fns'; import { format, startOfYear } from 'date-fns';
import { map, Observable, Subject, takeUntil } from 'rxjs'; import { map, Observable } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -35,9 +36,11 @@ export class GfApiPageComponent implements OnInit {
public status$: Observable<DataProviderGhostfolioStatusResponse>; public status$: Observable<DataProviderGhostfolioStatusResponse>;
private apiKey: string; private apiKey: string;
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {} public constructor(
private destroyRef: DestroyRef,
private http: HttpClient
) {}
public ngOnInit() { public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
@ -51,18 +54,13 @@ export class GfApiPageComponent implements OnInit {
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAssetProfile({ symbol }: { symbol: string }) { private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http return this.http
.get<DataProviderGhostfolioAssetProfileResponse>( .get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() } { headers: this.getHeaders() }
) )
.pipe(takeUntil(this.unsubscribeSubject)); .pipe(takeUntilDestroyed(this.destroyRef));
} }
private fetchDividends({ symbol }: { symbol: string }) { private fetchDividends({ symbol }: { symbol: string }) {
@ -82,7 +80,7 @@ export class GfApiPageComponent implements OnInit {
map(({ dividends }) => { map(({ dividends }) => {
return dividends; return dividends;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -103,7 +101,7 @@ export class GfApiPageComponent implements OnInit {
map(({ historicalData }) => { map(({ historicalData }) => {
return historicalData; return historicalData;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -129,7 +127,7 @@ export class GfApiPageComponent implements OnInit {
map(({ items }) => { map(({ items }) => {
return items; return items;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -145,7 +143,7 @@ export class GfApiPageComponent implements OnInit {
map(({ quotes }) => { map(({ quotes }) => {
return quotes; return quotes;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -155,7 +153,7 @@ export class GfApiPageComponent implements OnInit {
'/api/v2/data-providers/ghostfolio/status', '/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() } { headers: this.getHeaders() }
) )
.pipe(takeUntil(this.unsubscribeSubject)); .pipe(takeUntilDestroyed(this.destroyRef));
} }
private getHeaders() { private getHeaders() {

16
apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts

@ -7,11 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy DestroyRef
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -21,21 +21,20 @@ import { Subject, takeUntil } from 'rxjs';
styleUrls: ['./faq-overview-page.scss'], styleUrls: ['./faq-overview-page.scss'],
templateUrl: './faq-overview-page.html' templateUrl: './faq-overview-page.html'
}) })
export class GfFaqOverviewPageComponent implements OnDestroy { export class GfFaqOverviewPageComponent {
public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`; public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -44,9 +43,4 @@ export class GfFaqOverviewPageComponent implements OnDestroy {
} }
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

16
apps/client/src/app/pages/faq/saas/saas-page.component.ts

@ -7,11 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy DestroyRef
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -21,7 +21,7 @@ import { Subject, takeUntil } from 'rxjs';
styleUrls: ['./saas-page.scss'], styleUrls: ['./saas-page.scss'],
templateUrl: './saas-page.html' templateUrl: './saas-page.html'
}) })
export class GfSaasPageComponent implements OnDestroy { export class GfSaasPageComponent {
public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`; public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`;
public routerLinkAccount = internalRoutes.account.routerLink; public routerLinkAccount = internalRoutes.account.routerLink;
public routerLinkAccountMembership = public routerLinkAccountMembership =
@ -30,16 +30,15 @@ export class GfSaasPageComponent implements OnDestroy {
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -48,9 +47,4 @@ export class GfSaasPageComponent implements OnDestroy {
} }
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

12
apps/client/src/app/pages/markets/markets-page.component.ts

@ -1,7 +1,6 @@
import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component';
import { Component, OnDestroy } from '@angular/core'; import { Component } from '@angular/core';
import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -10,11 +9,4 @@ import { Subject } from 'rxjs';
styleUrls: ['./markets-page.scss'], styleUrls: ['./markets-page.scss'],
templateUrl: './markets-page.html' templateUrl: './markets-page.html'
}) })
export class GfMarketsPageComponent implements OnDestroy { export class GfMarketsPageComponent {}
private unsubscribeSubject = new Subject<void>();
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

16
apps/client/src/app/pages/open/open-page.component.ts

@ -7,11 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -21,15 +21,14 @@ import { Subject, takeUntil } from 'rxjs';
styleUrls: ['./open-page.scss'], styleUrls: ['./open-page.scss'],
templateUrl: './open-page.html' templateUrl: './open-page.html'
}) })
export class GfOpenPageComponent implements OnDestroy, OnInit { export class GfOpenPageComponent implements OnInit {
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private userService: UserService private userService: UserService
) { ) {
const { statistics } = this.dataService.fetchInfo(); const { statistics } = this.dataService.fetchInfo();
@ -39,7 +38,7 @@ export class GfOpenPageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -48,9 +47,4 @@ export class GfOpenPageComponent implements OnDestroy, OnInit {
} }
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

100
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -14,7 +14,13 @@ import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
@ -27,8 +33,7 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons'; import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component'; import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces';
@ -48,7 +53,8 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
styleUrls: ['./activities-page.scss'], styleUrls: ['./activities-page.scss'],
templateUrl: './activities-page.html' templateUrl: './activities-page.html'
}) })
export class GfActivitiesPageComponent implements OnDestroy, OnInit { export class GfActivitiesPageComponent implements OnInit {
public activityTypesFilter: string[] = [];
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -59,14 +65,13 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public totalItems: number; public totalItems: number | undefined;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService, private icsService: IcsService,
@ -76,13 +81,13 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
if (params['activityId']) { if (params['activityId']) {
this.dataService this.dataService
.fetchActivity(params['activityId']) .fetchActivity(params['activityId'])
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity) => { .subscribe((activity) => {
this.openCreateActivityDialog(activity); this.openCreateActivityDialog(activity);
}); });
@ -93,7 +98,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
if (params['activityId']) { if (params['activityId']) {
this.dataService this.dataService
.fetchActivity(params['activityId']) .fetchActivity(params['activityId'])
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity) => { .subscribe((activity) => {
this.openUpdateActivityDialog(activity); this.openUpdateActivityDialog(activity);
}); });
@ -111,13 +116,13 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.updateUser(state.user); this.updateUser(state.user);
@ -130,20 +135,26 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
} }
public fetchActivities() { public fetchActivities() {
const dateRange = this.user?.settings?.dateRange; // Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined; const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
range, range,
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
skip: this.pageIndex * this.pageSize, skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection, sortDirection: this.sortDirection,
take: this.pageSize take: this.pageSize
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => { .subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
@ -184,28 +195,32 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
.deleteActivities({ .deleteActivities({
filters: this.userService.getFilters() filters: this.userService.getFilters()
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
public onDeleteActivity(aId: string) { public onDeleteActivity(aId: string) {
this.dataService this.dataService
.deleteActivity(aId) .deleteActivity(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -213,12 +228,17 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
let fetchExportParams: any = { activityIds }; let fetchExportParams: any = { activityIds };
if (!activityIds) { if (!activityIds) {
fetchExportParams = { filters: this.userService.getFilters() }; fetchExportParams = {
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters()
};
} }
this.dataService this.dataService
.fetchExport(fetchExportParams) .fetchExport(fetchExportParams)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
for (const activity of data.activities) { for (const activity of data.activities) {
delete activity.id; delete activity.id;
@ -238,7 +258,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
public onExportDrafts(activityIds?: string[]) { public onExportDrafts(activityIds?: string[]) {
this.dataService this.dataService
.fetchExport({ activityIds }) .fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent( content: this.icsService.transformActivitiesToIcsContent(
@ -268,14 +288,16 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -295,14 +317,16 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -314,6 +338,13 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
this.fetchActivities(); this.fetchActivities();
} }
public onTypesFilterChanged(aTypes: string[]) {
this.activityTypesFilter = aTypes;
this.pageIndex = 0;
this.fetchActivities();
}
public onUpdateActivity(aActivity: Activity) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true } queryParams: { activityId: aActivity.id, editDialog: true }
@ -336,15 +367,17 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity: UpdateOrderDto) => { .subscribe((activity: UpdateOrderDto) => {
if (activity) { if (activity) {
this.dataService this.dataService
.putActivity(activity) .putActivity(activity)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
} }
}); });
} }
@ -353,11 +386,6 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private isCalendarYear(dateRange: DateRange) { private isCalendarYear(dateRange: DateRange) {
if (!dateRange) { if (!dateRange) {
return false; return false;
@ -369,7 +397,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
private openCreateActivityDialog(aActivity?: Activity) { private openCreateActivityDialog(aActivity?: Activity) {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.updateUser(user); this.updateUser(user);
@ -396,17 +424,19 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((transaction: CreateOrderDto | null) => { .subscribe((transaction: CreateOrderDto | null) => {
if (transaction) { if (transaction) {
this.dataService.postActivity(transaction).subscribe({ this.dataService.postActivity(transaction).subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
} }
}); });
} }

2
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -10,6 +10,7 @@
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
@ -32,6 +33,7 @@
(importDividends)="onImportDividends()" (importDividends)="onImportDividends()"
(pageChanged)="onChangePage($event)" (pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)" (sortChanged)="onSortChanged($event)"
(typesFilterChanged)="onTypesFilterChanged($event)"
/> />
</div> </div>
</div> </div>

265
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
@ -20,9 +21,10 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, DestroyRef,
OnDestroy Inject
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -46,8 +48,8 @@ import { AssetClass, Tag, Type } from '@prisma/client';
import { isAfter, isToday } from 'date-fns'; import { isAfter, isToday } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { calendarClearOutline, refreshOutline } from 'ionicons/icons'; import { calendarClearOutline, refreshOutline } from 'ionicons/icons';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, delay, takeUntil } from 'rxjs/operators'; import { catchError, delay } from 'rxjs/operators';
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
import { ActivityType } from './types/activity-type.type'; import { ActivityType } from './types/activity-type.type';
@ -75,7 +77,7 @@ import { ActivityType } from './types/activity-type.type';
styleUrls: ['./create-or-update-activity-dialog.scss'], styleUrls: ['./create-or-update-activity-dialog.scss'],
templateUrl: 'create-or-update-activity-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy { export class GfCreateOrUpdateActivityDialogComponent {
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass) public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass)
@ -88,11 +90,11 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
public assetSubClassOptions: AssetClassSelectorOption[] = []; public assetSubClassOptions: AssetClassSelectorOption[] = [];
public currencies: string[] = []; public currencies: string[] = [];
public currencyOfAssetProfile: string; public currencyOfAssetProfile: string | undefined;
public currentMarketPrice = null; public currentMarketPrice: number | null = null;
public defaultDateFormat: string; public defaultDateFormat: string;
public defaultLookupItems: LookupItem[] = []; public defaultLookupItems: LookupItem[] = [];
public hasPermissionToCreateOwnTag: boolean; public hasPermissionToCreateOwnTag: boolean | undefined;
public isLoading = false; public isLoading = false;
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update'; public mode: 'create' | 'update';
@ -101,13 +103,12 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
public typesTranslationMap = new Map<Type, string>(); public typesTranslationMap = new Map<Type, string>();
public Validators = Validators; public Validators = Validators;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<Date, string>,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>, public dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@Inject(MAT_DATE_LOCALE) private locale: string, @Inject(MAT_DATE_LOCALE) private locale: string,
@ -121,7 +122,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag); hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user.settings.locale ?? defaultLocale;
this.mode = this.data.activity?.id ? 'update' : 'create'; this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
@ -133,37 +134,28 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.defaultLookupItems = holdings this.defaultLookupItems = holdings
.filter(({ assetSubClass }) => { .filter(({ assetProfile }) => {
return !['CASH'].includes(assetSubClass); return !['CASH'].includes(assetProfile.assetSubClass);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.name?.localeCompare(b.name);
}) })
.map( .map(({ assetProfile }) => {
({ return {
assetClass, assetClass: assetProfile.assetClass,
assetSubClass, assetSubClass: assetProfile.assetSubClass,
currency, currency: assetProfile.currency ?? '',
dataSource, dataProviderInfo: {
name, isPremium: false
symbol },
}) => { dataSource: assetProfile.dataSource,
return { name: assetProfile.name ?? '',
assetClass, symbol: assetProfile.symbol
assetSubClass, };
currency, });
dataSource,
name,
symbol,
dataProviderInfo: {
isPremium: false
}
};
}
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -237,30 +229,30 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
// Slightly delay until the more specific form control value changes have // Slightly delay until the more specific form control value changes have
// completed // completed
delay(300), delay(300),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(async () => { .subscribe(async () => {
if ( if (
['BUY', 'FEE', 'VALUABLE'].includes( ['BUY', 'FEE', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
) { ) {
this.total = this.total =
this.activityForm.get('quantity').value * this.activityForm.get('quantity')?.value *
this.activityForm.get('unitPrice').value + this.activityForm.get('unitPrice')?.value +
(this.activityForm.get('fee').value ?? 0); (this.activityForm.get('fee')?.value ?? 0);
} else { } else {
this.total = this.total =
this.activityForm.get('quantity').value * this.activityForm.get('quantity')?.value *
this.activityForm.get('unitPrice').value - this.activityForm.get('unitPrice')?.value -
(this.activityForm.get('fee').value ?? 0); (this.activityForm.get('fee')?.value ?? 0);
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('accountId').valueChanges.subscribe((accountId) => { this.activityForm.get('accountId')?.valueChanges.subscribe((accountId) => {
const type = this.activityForm.get('type').value; const type = this.activityForm.get('type')?.value;
if (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(type)) { if (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(type)) {
const currency = const currency =
@ -268,15 +260,15 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
return id === accountId; return id === accountId;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) { if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.get('accountId').value) { if (this.activityForm.get('accountId')?.value) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
} }
} }
@ -284,7 +276,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.activityForm this.activityForm
.get('assetClass') .get('assetClass')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => { .subscribe((assetClass) => {
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
@ -297,28 +289,28 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
}) })
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
this.activityForm.get('assetSubClass').setValue(null); this.activityForm.get('assetSubClass')?.setValue(null);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('date').valueChanges.subscribe(() => { this.activityForm.get('date')?.valueChanges.subscribe(() => {
if (isToday(this.activityForm.get('date').value)) { if (isToday(this.activityForm.get('date')?.value)) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('searchSymbol').valueChanges.subscribe(() => { this.activityForm.get('searchSymbol')?.valueChanges.subscribe(() => {
if (this.activityForm.get('searchSymbol').invalid) { if (this.activityForm.get('searchSymbol')?.invalid) {
this.data.activity.SymbolProfile = null; this.data.activity.SymbolProfile = null;
} else if ( } else if (
['BUY', 'DIVIDEND', 'SELL'].includes( ['BUY', 'DIVIDEND', 'SELL'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
) { ) {
this.updateAssetProfile(); this.updateAssetProfile();
@ -327,7 +319,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('tags').valueChanges.subscribe((tags: Tag[]) => { this.activityForm.get('tags')?.valueChanges.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
}); });
@ -335,9 +327,9 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
if (newTag && this.hasPermissionToCreateOwnTag) { if (newTag && this.hasPermissionToCreateOwnTag) {
this.dataService this.dataService
.postTag({ ...newTag, userId: this.data.user.id }) .postTag({ ...newTag, userId: this.data.user.id })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag) => { .subscribe((tag) => {
this.activityForm.get('tags').setValue( this.activityForm.get('tags')?.setValue(
tags.map((currentTag) => { tags.map((currentTag) => {
if (currentTag.id === undefined) { if (currentTag.id === undefined) {
return tag; return tag;
@ -349,7 +341,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
}); });
} }
@ -357,106 +349,106 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.activityForm this.activityForm
.get('type') .get('type')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((type: ActivityType) => { .subscribe((type: ActivityType) => {
if ( if (
type === 'VALUABLE' || type === 'VALUABLE' ||
(this.activityForm.get('dataSource').value === 'MANUAL' && (this.activityForm.get('dataSource')?.value === 'MANUAL' &&
type === 'BUY') type === 'BUY')
) { ) {
const currency = const currency =
this.data.accounts.find(({ id }) => { this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value; return id === this.activityForm.get('accountId')?.value;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
this.activityForm.get('fee').setValue(0); this.activityForm.get('fee')?.setValue(0);
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name')?.setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
if (type === 'VALUABLE') { if (type === 'VALUABLE') {
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity')?.setValue(1);
} }
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
const currency = const currency =
this.data.accounts.find(({ id }) => { this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value; return id === this.activityForm.get('accountId')?.value;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
if (['INTEREST', 'LIABILITY'].includes(type)) { if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('fee').setValue(0); this.activityForm.get('fee')?.setValue(0);
} }
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name')?.setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('quantity').setValue(0); this.activityForm.get('quantity')?.setValue(0);
} else if (['INTEREST', 'LIABILITY'].includes(type)) { } else if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity')?.setValue(1);
} }
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('unitPrice').setValue(0); this.activityForm.get('unitPrice')?.setValue(0);
} }
if ( if (
['FEE', 'INTEREST'].includes(type) && ['FEE', 'INTEREST'].includes(type) &&
this.activityForm.get('accountId').value this.activityForm.get('accountId')?.value
) { ) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
} else { } else {
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.setValidators(Validators.required); ?.setValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
this.activityForm.get('name').removeValidators(Validators.required); this.activityForm.get('name')?.removeValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.setValidators(Validators.required); ?.setValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('type').setValue(this.data.activity?.type); this.activityForm.get('type')?.setValue(this.data.activity?.type);
if (this.data.activity?.id) { if (this.data.activity?.id) {
this.activityForm.get('searchSymbol').disable(); this.activityForm.get('searchSymbol')?.disable();
this.activityForm.get('type').disable(); this.activityForm.get('type')?.disable();
} }
if (this.data.activity?.SymbolProfile?.symbol) { if (this.data.activity?.SymbolProfile?.symbol) {
@ -465,7 +457,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
dataSource: this.data.activity?.SymbolProfile?.dataSource, dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol symbol: this.data.activity?.SymbolProfile?.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;
@ -476,7 +468,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
public applyCurrentMarketPrice() { public applyCurrentMarketPrice() {
this.activityForm.patchValue({ this.activityForm.patchValue({
currencyOfUnitPrice: this.activityForm.get('currency').value, currencyOfUnitPrice: this.activityForm.get('currency')?.value,
unitPrice: this.currentMarketPrice unitPrice: this.currentMarketPrice
}); });
} }
@ -495,42 +487,42 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
public async onSubmit() { public async onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.get('accountId').value, accountId: this.activityForm.get('accountId')?.value,
assetClass: this.activityForm.get('assetClass').value, assetClass: this.activityForm.get('assetClass')?.value,
assetSubClass: this.activityForm.get('assetSubClass').value, assetSubClass: this.activityForm.get('assetSubClass')?.value,
comment: this.activityForm.get('comment').value || null, comment: this.activityForm.get('comment')?.value || null,
currency: this.activityForm.get('currency').value, currency: this.activityForm.get('currency')?.value,
customCurrency: this.activityForm.get('currencyOfUnitPrice').value, customCurrency: this.activityForm.get('currencyOfUnitPrice')?.value,
dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
? 'MANUAL' ? 'MANUAL'
: this.activityForm.get('dataSource').value, : this.activityForm.get('dataSource')?.value,
date: this.activityForm.get('date').value, date: this.activityForm.get('date')?.value,
fee: this.activityForm.get('fee').value, fee: this.activityForm.get('fee')?.value,
quantity: this.activityForm.get('quantity').value, quantity: this.activityForm.get('quantity')?.value,
symbol: symbol:
(['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
? undefined ? undefined
: this.activityForm.get('searchSymbol')?.value?.symbol) ?? : this.activityForm.get('searchSymbol')?.value?.symbol) ??
this.activityForm.get('name')?.value, this.activityForm.get('name')?.value,
tags: this.activityForm.get('tags').value?.map(({ id }) => { tags: this.activityForm.get('tags')?.value?.map(({ id }) => {
return id; return id;
}), }),
type: type:
this.activityForm.get('type').value === 'VALUABLE' this.activityForm.get('type')?.value === 'VALUABLE'
? 'BUY' ? 'BUY'
: this.activityForm.get('type').value, : this.activityForm.get('type')?.value,
unitPrice: this.activityForm.get('unitPrice').value unitPrice: this.activityForm.get('unitPrice')?.value
}; };
try { try {
if (this.mode === 'create') { if (this.mode === 'create') {
activity.updateAccountBalance = this.activityForm.get( activity.updateAccountBalance = this.activityForm.get(
'updateAccountBalance' 'updateAccountBalance'
).value; )?.value;
await validateObjectForForm({ await validateObjectForForm({
classDto: CreateOrderDto, classDto: CreateOrderDto,
@ -557,19 +549,14 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
} }
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private updateAssetProfile() { private updateAssetProfile() {
this.isLoading = true; this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: this.activityForm.get('searchSymbol').value.dataSource, dataSource: this.activityForm.get('searchSymbol')?.value.dataSource,
symbol: this.activityForm.get('searchSymbol').value.symbol symbol: this.activityForm.get('searchSymbol')?.value.symbol
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
@ -581,13 +568,13 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
if (this.mode === 'create') { if (this.mode === 'create') {
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm.get('dataSource').setValue(dataSource); this.activityForm.get('dataSource')?.setValue(dataSource);
} }
this.currencyOfAssetProfile = currency; this.currencyOfAssetProfile = currency;

10
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -15,7 +15,7 @@
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-select-trigger>{{ <mat-select-trigger>{{
typesTranslationMap[activityForm.get('type').value] typesTranslationMap[activityForm.get('type')?.value]
}}</mat-select-trigger> }}</mat-select-trigger>
<mat-option value="BUY"> <mat-option value="BUY">
<span <span
@ -113,7 +113,7 @@
[ngClass]="{ [ngClass]="{
'd-none': !activityForm 'd-none': !activityForm
.get('searchSymbol') .get('searchSymbol')
.hasValidator(Validators.required) ?.hasValidator(Validators.required)
}" }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -128,7 +128,7 @@
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ [ngClass]="{
'd-none': !activityForm.get('name').hasValidator(Validators.required) 'd-none': !activityForm.get('name')?.hasValidator(Validators.required)
}" }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -228,7 +228,7 @@
</mat-form-field> </mat-form-field>
@if ( @if (
currencyOfAssetProfile === currencyOfAssetProfile ===
activityForm.get('currencyOfUnitPrice').value && activityForm.get('currencyOfUnitPrice')?.value &&
currentMarketPrice && currentMarketPrice &&
['BUY', 'SELL'].includes(data.activity.type) && ['BUY', 'SELL'].includes(data.activity.type) &&
isToday(activityForm.get('date')?.value) isToday(activityForm.get('date')?.value)
@ -262,7 +262,7 @@
matTextSuffix matTextSuffix
[ngClass]="{ 'd-none': !activityForm.get('currency')?.value }" [ngClass]="{ 'd-none': !activityForm.get('currency')?.value }"
> >
{{ activityForm.get('currencyOfUnitPrice').value }} {{ activityForm.get('currencyOfUnitPrice')?.value }}
</div> </div>
</mat-form-field> </mat-form-field>
</div> </div>

4
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts

@ -4,6 +4,8 @@ import { Account } from '@prisma/client';
export interface CreateOrUpdateActivityDialogParams { export interface CreateOrUpdateActivityDialogParams {
accounts: Account[]; accounts: Account[];
activity: Activity; activity: Omit<Activity, 'SymbolProfile'> & {
SymbolProfile: Activity['SymbolProfile'] | null;
};
user: User; user: User;
} }

20
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -21,9 +21,10 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, DestroyRef,
OnDestroy Inject
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -52,7 +53,6 @@ import { cloudUploadOutline, warningOutline } from 'ionicons/icons';
import { isArray, sortBy } from 'lodash'; import { isArray, sortBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { ImportStep } from './enums/import-step'; import { ImportStep } from './enums/import-step';
import { ImportActivitiesDialogParams } from './interfaces/interfaces'; import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@ -81,7 +81,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
styleUrls: ['./import-activities-dialog.scss'], styleUrls: ['./import-activities-dialog.scss'],
templateUrl: 'import-activities-dialog.html' templateUrl: 'import-activities-dialog.html'
}) })
export class GfImportActivitiesDialogComponent implements OnDestroy { export class GfImportActivitiesDialogComponent {
public accounts: CreateAccountWithBalancesDto[] = []; public accounts: CreateAccountWithBalancesDto[] = [];
public activities: Activity[] = []; public activities: Activity[] = [];
public assetProfileForm: FormGroup; public assetProfileForm: FormGroup;
@ -104,12 +104,11 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
public tags: CreateTagDto[] = []; public tags: CreateTagDto[] = [];
public totalItems: number; public totalItems: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams, @Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
public dialogRef: MatDialogRef<GfImportActivitiesDialogComponent>, public dialogRef: MatDialogRef<GfImportActivitiesDialogComponent>,
@ -152,7 +151,7 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
], ],
range: 'max' range: 'max'
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = sortBy(holdings, ({ name }) => { this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
@ -237,7 +236,7 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
dataSource, dataSource,
symbol symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.activities = activities; this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse()); this.dataSource = new MatTableDataSource(activities.reverse());
@ -284,11 +283,6 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async handleFile({ private async handleFile({
file, file,
stepper stepper

51
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -159,10 +159,9 @@ export class GfAllocationsPageComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.worldMapChartFormat = this.worldMapChartFormat = this.showValuesInPercentage()
this.hasImpersonationId || this.user.settings.isRestrictedView ? `{0}%`
? `{0}%` : `{0} ${this.user?.settings?.baseCurrency}`;
: `{0} ${this.user?.settings?.baseCurrency}`;
this.isLoading = true; this.isLoading = true;
@ -310,7 +309,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.accounts)) { ] of Object.entries(this.portfolioDetails.accounts)) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = valueInPercentage; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
@ -328,7 +327,7 @@ export class GfAllocationsPageComponent implements OnInit {
)) { )) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = position.allocationInPercentage; value = position.allocationInPercentage;
} else { } else {
value = position.valueInBaseCurrency; value = position.valueInBaseCurrency;
@ -405,17 +404,22 @@ export class GfAllocationsPageComponent implements OnInit {
} }
if (position.holdings.length > 0) { if (position.holdings.length > 0) {
for (const holding of position.holdings) { for (const {
const { allocationInPercentage, name, valueInBaseCurrency } = allocationInPercentage,
holding; name,
valueInBaseCurrency
if (this.topHoldingsMap[name]?.value) { } of position.holdings) {
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency) const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) {
this.topHoldingsMap[normalizedAssetName].value += isNumber(
valueInBaseCurrency
)
? valueInBaseCurrency ? valueInBaseCurrency
: allocationInPercentage * : allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage; this.portfolioDetails.holdings[symbol].valueInPercentage;
} else { } else {
this.topHoldingsMap[name] = { this.topHoldingsMap[normalizedAssetName] = {
name, name,
value: isNumber(valueInBaseCurrency) value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency ? valueInBaseCurrency
@ -486,7 +490,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.platforms)) { ] of Object.entries(this.portfolioDetails.platforms)) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = valueInPercentage; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
@ -501,7 +505,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.topHoldings = Object.values(this.topHoldingsMap) this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }) => { .map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) { if (this.showValuesInPercentage()) {
return { return {
name, name,
allocationInPercentage: value, allocationInPercentage: value,
@ -518,7 +522,10 @@ export class GfAllocationsPageComponent implements OnInit {
if (holding.holdings.length > 0) { if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find( const currentParentHolding = holding.holdings.find(
(parentHolding) => { (parentHolding) => {
return parentHolding.name === name; return (
this.normalizeAssetName(parentHolding.name) ===
this.normalizeAssetName(name)
);
} }
); );
@ -555,6 +562,14 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
private normalizeAssetName(name: string) {
if (!name) {
return '';
}
return name.trim().toLowerCase();
}
private openAccountDetailDialog(aAccountId: string) { private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent, GfAccountDetailDialogComponent,
@ -581,4 +596,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
public showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
} }

32
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -25,11 +25,9 @@
<mat-card-content> <mat-card-content>
<mat-progress-bar <mat-progress-bar
mode="determinate" mode="determinate"
[title]=" [title]="`${(
`${( portfolioDetails?.summary?.filteredValueInPercentage * 100
portfolioDetails?.summary?.filteredValueInPercentage * 100 ).toFixed(2)}%`"
).toFixed(2)}%`
"
[value]="portfolioDetails?.summary?.filteredValueInPercentage * 100" [value]="portfolioDetails?.summary?.filteredValueInPercentage * 100"
/> />
</mat-card-content> </mat-card-content>
@ -49,7 +47,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="platforms" [data]="platforms"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['id']" [keys]="['id']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -71,7 +69,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="holdings" [data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['currency']" [keys]="['currency']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -93,7 +91,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="holdings" [data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['assetClassLabel', 'assetSubClassLabel']" [keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -114,7 +112,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="symbols" [data]="symbols"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['symbol']" [keys]="['symbol']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
@ -138,7 +136,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="sectors" [data]="sectors"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10" [maxItems]="10"
@ -161,7 +159,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="continents" [data]="continents"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -183,7 +181,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced" [data]="marketsAdvanced"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
</mat-card-content> </mat-card-content>
@ -206,9 +204,7 @@
<gf-world-map-chart <gf-world-map-chart
[countries]="countries" [countries]="countries"
[format]="worldMapChartFormat" [format]="worldMapChartFormat"
[isInPercent]=" [isInPercentage]="showValuesInPercentage()"
hasImpersonationId || user.settings.isRestrictedView
"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
</div> </div>
@ -272,7 +268,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="countries" [data]="countries"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10" [maxItems]="10"
@ -291,7 +287,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="accounts" [data]="accounts"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['id']" [keys]="['id']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
(proportionChartClicked)="onAccountChartClicked($event)" (proportionChartClicked)="onAccountChartClicked($event)"
@ -314,7 +310,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="holdings" [data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="showValuesInPercentage()"
[keys]="['etfProvider']" [keys]="['etfProvider']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />

12
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -417,7 +417,9 @@
[benchmarkDataLabel]="portfolioEvolutionDataLabel" [benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -473,7 +475,9 @@
[benchmarkDataLabel]="investmentTimelineDataLabel" [benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingInvestmentTimelineChart" [isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="savingsRate" [savingsRate]="savingsRate"
@ -508,7 +512,9 @@
[benchmarkDataLabel]="dividendTimelineDataLabel" [benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingDividendTimelineChart" [isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />

21
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -12,9 +12,10 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
@ -27,8 +28,8 @@ import {
informationCircleOutline informationCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { StringValue } from 'ms'; import { StringValue } from 'ms';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -46,7 +47,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./pricing-page.scss'], styleUrls: ['./pricing-page.scss'],
templateUrl: './pricing-page.html' templateUrl: './pricing-page.html'
}) })
export class GfPricingPageComponent implements OnDestroy, OnInit { export class GfPricingPageComponent implements OnInit {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
@ -92,11 +93,10 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService, private notificationService: NotificationService,
private userService: UserService private userService: UserService
) { ) {
@ -124,7 +124,7 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
this.price = subscriptionOffer?.price; this.price = subscriptionOffer?.price;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -161,15 +161,10 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ sessionUrl }) => { .subscribe(({ sessionUrl }) => {
window.location.href = sessionUrl; window.location.href = sessionUrl;
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

87
apps/client/src/app/pages/public/public-page.component.ts

@ -15,11 +15,14 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -52,48 +55,50 @@ import { catchError } from 'rxjs/operators';
templateUrl: './public-page.html' templateUrl: './public-page.html'
}) })
export class GfPublicPageComponent implements OnInit { export class GfPublicPageComponent implements OnInit {
public continents: { protected continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public defaultAlias = $localize`someone`; protected readonly defaultAlias = $localize`someone`;
public deviceType: string; protected readonly deviceType = computed(
public hasPermissionForSubscription: boolean; () => this.deviceDetectorService.deviceInfo().deviceType
public holdings: PublicPortfolioResponse['holdings'][string][]; );
public info: InfoItem; protected hasPermissionForSubscription: boolean;
public latestActivitiesDataSource: MatTableDataSource< protected holdings: PublicPortfolioResponse['holdings'][string][];
protected info: InfoItem;
protected latestActivitiesDataSource: MatTableDataSource<
PublicPortfolioResponse['latestActivities'][0] PublicPortfolioResponse['latestActivities'][0]
>; >;
public markets: { protected markets: {
[key in Market]: { id: Market; valueInPercentage: number }; [key in Market]: { id: Market; valueInPercentage: number };
}; };
public pageSize = Number.MAX_SAFE_INTEGER; protected readonly pageSize = Number.MAX_SAFE_INTEGER;
public positions: { protected positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
value: number; value: number;
}; };
}; };
public publicPortfolioDetails: PublicPortfolioResponse; protected publicPortfolioDetails: PublicPortfolioResponse;
public sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public symbols: { protected symbols: {
[name: string]: { name: string; symbol: string; value: number }; [name: string]: { name: string; symbol: string; value: number };
}; };
public UNKNOWN_KEY = UNKNOWN_KEY; protected readonly UNKNOWN_KEY = UNKNOWN_KEY;
private readonly activatedRoute = inject(ActivatedRoute);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly router = inject(Router);
private accessId: string; private accessId: string;
public constructor( public constructor() {
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private router: Router
) {
this.activatedRoute.params.subscribe((params) => { this.activatedRoute.params.subscribe((params) => {
this.accessId = params['id']; this.accessId = params['id'];
}); });
@ -107,13 +112,11 @@ export class GfPublicPageComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService this.dataService
.fetchPublicPortfolio(this.accessId) .fetchPublicPortfolio(this.accessId)
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
catchError((error) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.NOT_FOUND) { if (error.status === StatusCodes.NOT_FOUND) {
console.error(error); console.error(error);
this.router.navigate(['/']); this.router.navigate(['/']);
@ -135,7 +138,7 @@ export class GfPublicPageComponent implements OnInit {
}); });
} }
public initializeAnalysisData() { private initializeAnalysisData() {
this.continents = { this.continents = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
name: UNKNOWN_KEY, name: UNKNOWN_KEY,
@ -185,36 +188,38 @@ export class GfPublicPageComponent implements OnInit {
if (this.continents[continent]?.value) { if (this.continents[continent]?.value) {
this.continents[continent].value += this.continents[continent].value +=
weight * position.valueInBaseCurrency; weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: continent, name: continent,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency .valueInBaseCurrency ?? 0)
}; };
} }
if (this.countries[code]?.value) { if (this.countries[code]?.value) {
this.countries[code].value += this.countries[code].value +=
weight * position.valueInBaseCurrency; weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.countries[code] = { this.countries[code] = {
name, name,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency .valueInBaseCurrency ?? 0)
}; };
} }
} }
} else { } else {
this.continents[UNKNOWN_KEY].value += this.continents[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency; this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
this.countries[UNKNOWN_KEY].value += this.countries[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency; this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
} }
if (position.sectors.length > 0) { if (position.sectors.length > 0) {
@ -222,20 +227,22 @@ export class GfPublicPageComponent implements OnInit {
const { name, weight } = sector; const { name, weight } = sector;
if (this.sectors[name]?.value) { if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.valueInBaseCurrency; this.sectors[name].value +=
weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name, name,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency .valueInBaseCurrency ?? 0)
}; };
} }
} }
} else { } else {
this.sectors[UNKNOWN_KEY].value += this.sectors[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency; this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
} }
} }
@ -244,7 +251,7 @@ export class GfPublicPageComponent implements OnInit {
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
value: isNumber(position.valueInBaseCurrency) value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
: position.valueInPercentage : (position.valueInPercentage ?? 0)
}; };
} }
} }

14
apps/client/src/app/pages/public/public-page.html

@ -73,9 +73,9 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
class="mx-auto" class="mx-auto"
[data]="symbols" [data]="symbols"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['symbol']" [keys]="['symbol']"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType() !== 'mobile'"
/> />
<gf-holdings-table <gf-holdings-table
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
@ -98,7 +98,7 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="positions" [data]="positions"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['currency']" [keys]="['currency']"
[maxItems]="10" [maxItems]="10"
/> />
@ -115,7 +115,7 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="sectors" [data]="sectors"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
/> />
@ -134,7 +134,7 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="continents" [data]="continents"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
/> />
</mat-card-content> </mat-card-content>
@ -154,7 +154,7 @@
<gf-world-map-chart <gf-world-map-chart
format="{0}%" format="{0}%"
[countries]="countries" [countries]="countries"
[isInPercent]="true" [isInPercentage]="true"
/> />
</div> </div>
<div class="row"> <div class="row">
@ -213,7 +213,7 @@
<mat-card-content> <mat-card-content>
<gf-activities-table <gf-activities-table
[dataSource]="latestActivitiesDataSource" [dataSource]="latestActivitiesDataSource"
[deviceType]="deviceType" [deviceType]="deviceType()"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="false" [hasPermissionToExportActivities]="false"

19
apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts

@ -8,10 +8,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject, Inject,
OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@ -27,8 +28,6 @@ import {
checkmarkOutline, checkmarkOutline,
copyOutline copyOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UserAccountRegistrationDialogParams } from './interfaces/interfaces'; import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
@ -53,7 +52,7 @@ import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
styleUrls: ['./user-account-registration-dialog.scss'], styleUrls: ['./user-account-registration-dialog.scss'],
templateUrl: 'user-account-registration-dialog.html' templateUrl: 'user-account-registration-dialog.html'
}) })
export class GfUserAccountRegistrationDialogComponent implements OnDestroy { export class GfUserAccountRegistrationDialogComponent {
@ViewChild(MatStepper) stepper!: MatStepper; @ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string; public accessToken: string;
@ -64,12 +63,11 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public routerLinkAboutTermsOfService = public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink; publicRoutes.about.subRoutes.termsOfService.routerLink;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams,
private dataService: DataService private dataService: DataService,
private destroyRef: DestroyRef
) { ) {
addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline }); addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline });
} }
@ -77,7 +75,7 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public createAccount() { public createAccount() {
this.dataService this.dataService
.postUser() .postUser()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accessToken, authToken, role }) => { .subscribe(({ accessToken, authToken, role }) => {
this.accessToken = accessToken; this.accessToken = accessToken;
this.authToken = authToken; this.authToken = authToken;
@ -96,9 +94,4 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public onChangeDislaimerChecked() { public onChangeDislaimerChecked() {
this.isDisclaimerChecked = !this.isDisclaimerChecked; this.isDisclaimerChecked = !this.isDisclaimerChecked;
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

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

Loading…
Cancel
Save