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
/.angular/cache
.claude/settings.local.json
.claude/worktrees
.cursor/rules/nx-rules.mdc
.env
.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/),
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
### 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 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
### Added

2
README.md

@ -309,7 +309,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## 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.

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

@ -37,7 +37,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -112,6 +112,7 @@ export class ActivitiesController {
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@ -139,6 +140,9 @@ export class ActivitiesController {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({
@ -147,6 +151,7 @@ export class ActivitiesController {
sortColumn,
sortDirection,
startDate,
types,
userCurrency,
includeDrafts: true,
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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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 { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { ActivitiesService } from './activities.service';
imports: [
ApiModule,
CacheModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,

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

@ -629,7 +629,7 @@ export class ActivitiesService {
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
if (types?.length > 0) {
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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { QueueModule } from './queue/queue.module';
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
DemoModule,
ExchangeRateDataModule,

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

@ -625,23 +625,23 @@ export class AdminService {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray,
name: name as string,
sectors: sectors as Prisma.JsonArray,
url: url as string
};
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
holdings,
isActive,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
? { assetClass, assetSubClass, countries, name, sectors, url }
: {
SymbolProfileOverrides: {
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 { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { Module } from '@nestjs/common';
@ -8,7 +9,11 @@ import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
imports: [
DataGatheringQueueModule,
PortfolioSnapshotQueueModule,
StatisticsGatheringQueueModule
],
providers: [QueueService]
})
export class QueueModule {}

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

@ -1,7 +1,8 @@
import {
DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST
QUEUE_JOB_STATUS_LIST,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -15,7 +16,9 @@ export class QueueService {
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: 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) {
@ -38,6 +41,7 @@ export class QueueService {
await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus);
await this.statisticsGatheringQueue.clean(300, queueStatus);
}
}
@ -58,13 +62,19 @@ export class QueueService {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status)
]);
const [dataGatheringJobs, portfolioSnapshotJobs, statisticsGatheringJobs] =
await Promise.all([
this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status),
this.statisticsGatheringQueue.getJobs(status)
]);
const jobsWithState = await Promise.all(
[...dataGatheringJobs, ...portfolioSnapshotJobs]
[
...dataGatheringJobs,
...portfolioSnapshotJobs,
...statisticsGatheringJobs
]
.filter((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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 {
BULL_BOARD_ROUTE,
@ -109,7 +109,7 @@ import { UserModule } from './user/user.module';
ConfigModule.forRoot(),
ConfigurationModule,
CronModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
EventEmitterModule.forRoot(),
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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {}
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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 });
}
}

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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.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 { Module } from '@nestjs/common';
@ -17,7 +17,7 @@ import { WatchlistService } from './watchlist.service';
controllers: [WatchlistController],
imports: [
BenchmarkModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ImpersonationModule,
MarketDataModule,

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

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

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

@ -10,7 +10,7 @@ import {
} from '@ghostfolio/common/interfaces';
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';
@Injectable()
@ -24,11 +24,13 @@ export class ExportService {
public async export({
activityIds,
activityTypes,
filters,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[];
userId: string;
userSettings: UserSettings;
@ -44,6 +46,7 @@ export class ExportService {
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency,
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 { 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 { SubscriptionType } from '@ghostfolio/common/enums';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -62,7 +63,7 @@ export class ImportController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
this.request.user.subscription.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
@ -100,6 +101,17 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): 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({
dataSource,
symbol,
@ -107,6 +119,6 @@ export class ImportController {
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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -29,7 +29,7 @@ import { ImportService } from './import.service';
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { Module } from '@nestjs/common';
@ -22,7 +22,7 @@ import { InfoService } from './info.service';
imports: [
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
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 {
DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns';
import { subDays } from 'date-fns';
import { isNil } from 'lodash';
@Injectable()
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) {
return this.userService.count({
where: {
@ -230,12 +166,6 @@ export class InfoService {
});
}
private async countSlackCommunityUsers() {
return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS
);
}
private async getDemoAuthToken() {
const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID
@ -267,65 +197,56 @@ export class InfoService {
}
} catch {}
const activeUsers1d = await this.countActiveUsers(1);
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 = {
const [
activeUsers1d,
activeUsers30d,
newUsers30d,
dockerHubPulls,
gitHubContributors,
gitHubStargazers,
newUsers30d,
slackCommunityUsers,
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(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
if (
Object.values(statistics).every((value) => {
return !isNil(value);
})
) {
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -38,7 +38,7 @@ import { RulesService } from './rules.service';
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
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 { 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 = {
production: true,
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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';
@ -14,7 +14,7 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
imports: [
ActivitiesModule,
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
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 getCategoryName(): string;
public abstract getConfiguration(): Partial<
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() {
return {
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() {
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() {
return {
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() {
return {
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() {
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() {
return {
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() {
return {
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() {
return {
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() {
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() {
return {
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() {
return {
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() {
return {
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() {
return {
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() {
return {
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() {
return {
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() {
return {
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { Module } from '@nestjs/common';
@ -12,9 +13,10 @@ import { CronService } from './cron.service';
@Module({
imports: [
ConfigurationModule,
DataGatheringModule,
DataGatheringQueueModule,
ExchangeRateDataModule,
PropertyModule,
StatisticsGatheringQueueModule,
TwitterBotModule,
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 { PropertyService } from '@ghostfolio/api/services/property/property.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 {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
@ -25,10 +26,18 @@ export class CronService {
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService,
private readonly statisticsGatheringService: StatisticsGatheringService,
private readonly twitterBotService: TwitterBotService,
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)
public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) {

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

@ -16,7 +16,7 @@ import {
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import Alphavantage from 'alphavantage';
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';
@Injectable()
export class AlphaVantageService implements DataProviderInterface {
export class AlphaVantageService
implements DataProviderInterface, OnModuleInit
{
public alphaVantage;
public constructor(
private readonly configurationService: ConfigurationService
) {
) {}
public onModuleInit() {
this.alphaVantage = Alphavantage({
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
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -27,13 +27,15 @@ import {
import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private readonly apiUrl: string;
private readonly headers: HeadersInit = {};
export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private apiUrl: string;
private headers: HeadersInit = {};
public constructor(
private readonly configurationService: ConfigurationService
) {
) {}
public onModuleInit() {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
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';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -33,14 +33,18 @@ import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit
{
private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) {
) {}
public onModuleInit() {
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';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -44,7 +44,9 @@ import {
import { uniqBy } from 'lodash';
@Injectable()
export class FinancialModelingPrepService implements DataProviderInterface {
export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = {
'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia',
@ -57,7 +59,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService
) {
) {}
public onModuleInit() {
this.apiKey = this.configurationService.get(
'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],
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,
DOCUMENT,
HostBinding,
Inject,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -36,7 +36,7 @@ import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.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 { UserService } from './services/user/user.service';
@ -48,10 +48,6 @@ import { UserService } from './services/user/user.service';
templateUrl: './app.component.html'
})
export class GfAppComponent implements OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean;
public currentRoute: string;
public currentSubRoute: string;
@ -66,43 +62,47 @@ export class GfAppComponent implements OnInit {
public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink;
public showFooter = false;
public user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private title: Title,
private userService: UserService
) {
public user: User | undefined;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly document = inject(DOCUMENT);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly title = inject(Title);
private readonly userService = inject(UserService);
public constructor() {
this.initializeTheme();
this.user = undefined;
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
.subscribe(
({ dataSource, holdingDetailDialog, symbol }: GfAppQueryParams) => {
if (dataSource && holdingDetailDialog && symbol) {
this.openHoldingDetailDialog({
dataSource,
symbol
});
}
}
});
);
addIcons({ openOutline });
}
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
@ -128,7 +128,7 @@ export class GfAppComponent implements OnInit {
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN'
@ -141,18 +141,18 @@ export class GfAppComponent implements OnInit {
if (
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.activities.path) ||
internalRoutes.portfolio.subRoutes?.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.allocations.path) ||
internalRoutes.portfolio.subRoutes?.allocations.path) ||
(this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path)
internalRoutes.home.subRoutes?.holdings.path)
) {
this.hasPermissionToChangeFilters = true;
} else {
@ -223,11 +223,17 @@ export class GfAppComponent implements OnInit {
}
public onClickSystemMessage() {
if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink);
const systemMessage = this.user?.systemMessage;
if (!systemMessage) {
return;
}
if (systemMessage.routerLink) {
void this.router.navigate(systemMessage.routerLink);
} else {
this.notificationService.alert({
title: this.user.systemMessage.message
title: systemMessage.message
});
}
}
@ -269,10 +275,7 @@ export class GfAppComponent implements OnInit {
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open<
GfHoldingDetailDialogComponent,
HoldingDetailDialogParams
>(GfHoldingDetailDialogComponent, {
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false,
data: {
dataSource,
@ -313,7 +316,7 @@ export class GfAppComponent implements OnInit {
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.router.navigate([], {
void this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
@ -339,6 +342,6 @@ export class GfAppComponent implements OnInit {
this.document
.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)
}}</a>
</div>
@if (user?.settings?.isExperimentalFeatures) {
@if (user()?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
@ -69,7 +69,7 @@
class="no-max-width"
xPosition="before"
>
@if (user?.settings?.isExperimentalFeatures) {
@if (user()?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
@ -86,7 +86,8 @@
</button>
}
@if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC'
user()?.settings?.isExperimentalFeatures ||
element.type === 'PUBLIC'
) {
<hr class="my-0" />
}
@ -100,7 +101,7 @@
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns()" mat-row></tr>
</table>
</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 {
ChangeDetectionStrategy,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
Input,
OnChanges,
Output
effect,
inject,
input,
output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
@ -46,23 +47,32 @@ import ms from 'ms';
templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss']
})
export class GfAccessTableComponent implements OnChanges {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();
@Output() accessToUpdate = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public displayedColumns = [];
public constructor(
private clipboard: Clipboard,
private notificationService: NotificationService,
private snackBar: MatSnackBar
) {
export class GfAccessTableComponent {
public readonly accesses = input.required<Access[]>();
public readonly showActions = input<boolean>(false);
public readonly user = input.required<User>();
public readonly accessDeleted = output<string>();
public readonly accessToUpdate = output<string>();
protected readonly baseUrl = window.location.origin;
protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => {
const columns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions()) {
columns.push('actions');
}
return columns;
});
private readonly clipboard = inject(Clipboard);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
public constructor() {
addIcons({
copyOutline,
createOutline,
@ -72,27 +82,19 @@ export class GfAccessTableComponent implements OnChanges {
lockOpenOutline,
removeCircleOutline
});
}
public ngOnChanges() {
this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
effect(() => {
this.dataSource.data = this.accesses() ?? [];
});
}
public getPublicUrl(aId: string): string {
const languageCode = this.user.settings.language;
protected getPublicUrl(aId: string) {
const languageCode = this.user().settings.language;
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
}
public onCopyUrlToClipboard(aId: string): void {
protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId));
this.snackBar.open(
@ -104,7 +106,7 @@ export class GfAccessTableComponent implements OnChanges {
);
}
public onDeleteAccess(aId: string) {
protected onDeleteAccess(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
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);
}
}

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

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

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

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

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

@ -20,12 +20,13 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit,
ViewChild
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule
@ -74,19 +75,25 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './admin-jobs.html'
})
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;
public DATA_GATHERING_QUEUE_PRIORITY_HIGH =
protected readonly 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;
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
protected dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
protected defaultDateTimeFormat: string;
protected readonly filterForm = new FormGroup({
status: new FormControl<JobStatus | null>(null)
});
public displayedColumns = [
protected readonly displayedColumns = [
'index',
'type',
'symbol',
@ -99,21 +106,20 @@ export class GfAdminJobsComponent implements OnInit {
'actions'
];
public hasPermissionToAccessBullBoard = false;
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
protected hasPermissionToAccessBullBoard = false;
protected isLoading = false;
protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
private user: User;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
private readonly adminService = inject(AdminService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() {
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@ -148,21 +154,17 @@ export class GfAdminJobsComponent implements OnInit {
}
public ngOnInit() {
this.filterForm = this.formBuilder.group({
status: []
});
this.filterForm.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
const currentFilter = this.filterForm.get('status').value;
const currentFilter = this.filterForm.controls.status.value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
this.fetchJobs();
}
public onDeleteJob(aId: string) {
protected onDeleteJob(aId: string) {
this.adminService
.deleteJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef))
@ -171,18 +173,18 @@ export class GfAdminJobsComponent implements OnInit {
});
}
public onDeleteJobs() {
const currentFilter = this.filterForm.get('status').value;
protected onDeleteJobs() {
const currentFilter = this.filterForm.controls.status.value;
this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.deleteJobs({ status: currentFilter ? [currentFilter] : [] })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
}
public onExecuteJob(aId: string) {
protected onExecuteJob(aId: string) {
this.adminService
.executeJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef))
@ -191,7 +193,7 @@ export class GfAdminJobsComponent implements OnInit {
});
}
public onOpenBullBoard() {
protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
@ -203,13 +205,13 @@ export class GfAdminJobsComponent implements OnInit {
window.open(BULL_BOARD_ROUTE, '_blank');
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({
title: JSON.stringify(aData, null, ' ')
});
}
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
protected onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
this.notificationService.alert({
title: JSON.stringify(aStacktrace, null, ' ')
});
@ -223,7 +225,7 @@ export class GfAdminJobsComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort;
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get;
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
} from '@ghostfolio/common/interfaces';
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 { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -87,6 +87,7 @@ import {
readerOutline,
serverOutline
} from 'ionicons/icons';
import { isBoolean } from 'lodash';
import ms from 'ms';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@ -129,52 +130,56 @@ export class GfAssetProfileDialogComponent implements OnInit {
)};123.45`;
@ViewChild('assetProfileFormElement')
assetProfileFormElement: ElementRef<HTMLFormElement>;
public readonly assetProfileFormElement: ElementRef<HTMLFormElement>;
public assetClassLabel: string;
public assetSubClassLabel: string;
protected assetClassLabel: string;
protected assetSubClassLabel: string;
public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass)
.map((id) => {
return { id, label: translate(id) } as AssetClassSelectorOption;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
});
protected readonly assetClassOptions: AssetClassSelectorOption[] =
Object.keys(AssetClass)
.map((id) => {
return { id, label: translate(id) } as AssetClassSelectorOption;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
});
public assetSubClassOptions: AssetClassSelectorOption[] = [];
public assetProfile: AdminMarketDataDetails['assetProfile'];
protected assetSubClassOptions: AssetClassSelectorOption[] = [];
protected assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
protected readonly assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass | null>(null),
assetSubClass: new FormControl<AssetSubClass | null>(null),
comment: '',
countries: '',
countries: ['', jsonValidator()],
currency: '',
historicalData: this.formBuilder.group({
csvString: ''
}),
isActive: [true],
name: ['', Validators.required],
scraperConfiguration: this.formBuilder.group({
defaultMarketPrice: null,
headers: JSON.stringify({}),
scraperConfiguration: this.formBuilder.group<
Omit<ScraperConfiguration, 'headers'> & {
headers: FormControl<string | null>;
}
>({
defaultMarketPrice: undefined,
headers: new FormControl(JSON.stringify({}), jsonValidator()),
locale: '',
mode: '',
mode: 'lazy',
selector: '',
url: ''
}),
sectors: '',
symbolMapping: '',
sectors: ['', jsonValidator()],
symbolMapping: ['', jsonValidator()],
url: ''
});
public assetProfileIdentifierForm = this.formBuilder.group(
protected readonly assetProfileIdentifierForm = this.formBuilder.group(
{
assetProfileIdentifier: new FormControl<AssetProfileIdentifier>(
{ symbol: null, dataSource: null },
[Validators.required]
)
assetProfileIdentifier: new FormControl<
AssetProfileIdentifier | { dataSource: null; symbol: null }
>({ dataSource: null, symbol: null }, [Validators.required])
},
{
validators: (control) => {
@ -183,16 +188,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
);
public benchmarks: Partial<SymbolProfile>[];
public canEditAssetProfile = true;
protected canEditAssetProfile = true;
public countries: {
protected countries: {
[code: string]: { name: string; value: number };
};
public currencies: string[] = [];
protected currencies: string[] = [];
public dateRangeOptions = [
protected readonly dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
value: 'wtd'
@ -218,14 +222,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max'
}
];
public historicalDataItems: LineChartItem[];
public isBenchmark = false;
public isDataGatheringEnabled: boolean;
public isEditAssetProfileIdentifierMode = false;
public isUUID = isUUID;
public marketDataItems: MarketData[] = [];
public modeValues = [
protected historicalDataItems: LineChartItem[];
protected isBenchmark = false;
protected isDataGatheringEnabled: boolean;
protected isEditAssetProfileIdentifierMode = false;
protected readonly isUUID = isUUID;
protected marketDataItems: MarketData[] = [];
protected readonly modeValues = [
{
value: 'lazy',
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 };
};
public user: User;
protected user: User;
private benchmarks: Partial<SymbolProfile>[];
public constructor(
public adminMarketDataService: AdminMarketDataService,
protected adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
@Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
@ -264,7 +270,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public get canSaveAssetProfileIdentifier() {
protected get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty && this.canEditAssetProfile;
}
@ -277,8 +283,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize();
}
public initialize() {
this.historicalDataItems = undefined;
protected initialize() {
this.historicalDataItems = [];
this.adminService
.fetchAdminData()
@ -298,10 +304,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
});
this.assetProfileForm
.get('assetClass')
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
this.assetProfileForm.controls.assetClass.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => {
if (!assetClass) {
return;
}
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
this.assetSubClassOptions = assetSubClasses
@ -313,7 +322,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
})
.sort((a, b) => a.label.localeCompare(b.label));
this.assetProfileForm.get('assetSubClass').setValue(null);
this.assetProfileForm.controls.assetSubClass.setValue(null);
this.changeDetectorRef.markForCheck();
});
@ -327,8 +336,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile;
this.assetClassLabel = translate(this.assetProfile?.assetClass);
this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass);
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate(
this.assetProfile?.assetSubClass ?? ''
);
this.canEditAssetProfile = !isCurrency(
getCurrencyFromSymbol(this.data.symbol)
@ -350,7 +361,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.marketDataItems = marketData;
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) {
this.countries[code] = {
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) {
this.sectors[name] = {
name,
@ -377,12 +394,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
return { code, weight };
}) ?? []
),
currency: this.assetProfile?.currency,
currency: this.assetProfile?.currency ?? null,
historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
},
isActive: this.assetProfile?.isActive,
name: this.assetProfile.name ?? this.assetProfile.symbol,
isActive: isBoolean(this.assetProfile?.isActive)
? this.assetProfile.isActive
: null,
name: this.assetProfile.name ?? this.assetProfile.symbol ?? null,
scraperConfiguration: {
defaultMarketPrice:
this.assetProfile?.scraperConfiguration?.defaultMarketPrice ??
@ -410,7 +429,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public onCancelEditAssetProfileIdentifierMode() {
protected onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false;
if (this.canEditAssetProfile) {
@ -420,17 +439,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileIdentifierForm.reset();
}
public onClose() {
protected onClose() {
this.dialogRef.close();
}
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
protected onDeleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close();
}
public onGatherProfileDataBySymbol({
protected onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
@ -440,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe();
}
public onGatherSymbol({
protected onGatherSymbol({
dataSource,
range,
symbol
@ -453,13 +475,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe();
}
public onMarketDataChanged(withRefresh: boolean = false) {
protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
}
}
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
protected onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService
.postBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -472,50 +494,48 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public onSetEditAssetProfileIdentifierMode() {
protected onSetEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = true;
this.assetProfileForm.disable();
}
public async onSubmitAssetProfileForm() {
let countries = [];
let scraperConfiguration: ScraperConfiguration = {
protected async onSubmitAssetProfileForm() {
let countries: Prisma.InputJsonArray = [];
let scraperConfiguration: Prisma.InputJsonObject | undefined = {
selector: '',
url: ''
};
let sectors = [];
let symbolMapping = {};
let sectors: Prisma.InputJsonArray = [];
let symbolMapping: Record<string, string> = {};
try {
countries = JSON.parse(this.assetProfileForm.get('countries').value);
countries = JSON.parse(
this.assetProfileForm.controls.countries.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {}
try {
scraperConfiguration = {
defaultMarketPrice:
(this.assetProfileForm.controls['scraperConfiguration'].controls[
'defaultMarketPrice'
].value as number) || undefined,
this.assetProfileForm.controls.scraperConfiguration.controls
.defaultMarketPrice?.value ?? undefined,
headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[
'headers'
].value
),
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[
'locale'
].value || undefined,
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[
'mode'
].value as ScraperConfiguration['mode'],
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined,
mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined,
selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[
'selector'
].value,
url: this.assetProfileForm.controls['scraperConfiguration'].controls[
'url'
].value
this.assetProfileForm.controls.scraperConfiguration.controls.selector
.value ?? '',
url:
this.assetProfileForm.controls.scraperConfiguration.controls.url
.value ?? ''
};
if (!scraperConfiguration.selector || !scraperConfiguration.url) {
@ -536,28 +556,32 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
try {
sectors = JSON.parse(this.assetProfileForm.get('sectors').value);
sectors = JSON.parse(
this.assetProfileForm.controls.sectors.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {}
try {
symbolMapping = JSON.parse(
this.assetProfileForm.get('symbolMapping').value
);
this.assetProfileForm.controls.symbolMapping.value ?? '{}'
) as Record<string, string>;
} catch {}
const assetProfile: UpdateAssetProfileDto = {
countries,
scraperConfiguration,
sectors,
symbolMapping,
assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null,
currency: this.assetProfileForm.get('currency').value,
isActive: this.assetProfileForm.get('isActive').value,
name: this.assetProfileForm.get('name').value,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.InputJsonObject,
url: this.assetProfileForm.get('url').value || null
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass:
this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value ?? undefined,
currency: this.assetProfileForm.controls.currency.value ?? undefined,
isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value
: undefined,
name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
};
try {
@ -600,7 +624,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize();
},
error: (error) => {
error: (error: HttpErrorResponse) => {
console.error($localize`Could not save asset profile`, error);
this.snackBar.open(
@ -614,12 +638,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public async onSubmitAssetProfileIdentifierForm() {
protected async onSubmitAssetProfileIdentifierForm() {
const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: this.assetProfileIdentifierForm.get('assetProfileIdentifier')
.value.dataSource,
symbol: this.assetProfileIdentifierForm.get('assetProfileIdentifier')
.value.symbol
dataSource:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.dataSource ?? undefined,
symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
};
try {
@ -676,38 +702,33 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public onTestMarketData() {
protected onTestMarketData() {
this.adminService
.testMarketData({
dataSource: this.data.dataSource,
scraperConfiguration: {
defaultMarketPrice: this.assetProfileForm.controls[
'scraperConfiguration'
].controls['defaultMarketPrice'].value as number,
defaultMarketPrice:
this.assetProfileForm.controls.scraperConfiguration.controls
.defaultMarketPrice?.value,
headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[
'headers'
].value
),
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[
'locale'
].value || undefined,
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[
'mode'
].value,
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined,
mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value,
selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[
'selector'
].value,
url: this.assetProfileForm.controls['scraperConfiguration'].controls[
'url'
].value
this.assetProfileForm.controls.scraperConfiguration.controls
.selector.value,
url: this.assetProfileForm.controls.scraperConfiguration.controls.url
.value
},
symbol: this.data.symbol
})
.pipe(
catchError(({ error }) => {
catchError(({ error }: HttpErrorResponse) => {
this.notificationService.alert({
message: error?.message,
title: $localize`Error`
@ -723,26 +744,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
' ' +
price +
' ' +
this.assetProfileForm.get('currency').value
this.assetProfileForm.controls.currency.value
});
});
}
public onToggleIsActive({ checked }: MatCheckboxChange) {
protected onToggleIsActive({ checked }: MatCheckboxChange) {
if (checked) {
this.assetProfileForm.get('isActive')?.setValue(true);
this.assetProfileForm.controls.isActive.setValue(true);
} else {
this.assetProfileForm.get('isActive')?.setValue(false);
this.assetProfileForm.controls.isActive.setValue(false);
}
if (checked === this.assetProfile.isActive) {
this.assetProfileForm.get('isActive')?.markAsPristine();
this.assetProfileForm.controls.isActive.markAsPristine();
} else {
this.assetProfileForm.get('isActive')?.markAsDirty();
this.assetProfileForm.controls.isActive.markAsDirty();
}
}
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
protected onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService
.deleteBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -755,15 +776,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
});
}
public onTriggerSubmitAssetProfileForm() {
protected onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm();
}
}
private isNewSymbolValid(control: AbstractControl): ValidationErrors {
private isNewSymbolValid(control: AbstractControl): ValidationErrors | null {
const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined =
control.get('assetProfileIdentifier').value;
control.get('assetProfileIdentifier')?.value;
if (
currentAssetProfileIdentifier?.dataSource === this.data?.dataSource &&
@ -773,5 +794,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
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
[colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[maxItems]="10"
/>
@ -290,7 +290,7 @@
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[maxItems]="10"
/>
@ -393,39 +393,37 @@
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<textarea
cdkTextareaAutosize
formControlName="countries"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
}
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<textarea
cdkTextareaAutosize
formControlName="countries"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
@if (assetProfileForm.controls.url.value) {
<gf-entity-logo
class="mr-3"
matSuffix
[url]="assetProfileForm.get('url').value"
[url]="assetProfileForm.controls.url.value"
/>
}
</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,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
@ -11,6 +12,7 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -18,7 +20,6 @@ import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
@ -34,7 +35,10 @@ import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator';
import { switchMap } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
import type {
CreateAssetProfileDialogMode,
CreateAssetProfileForm
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -54,32 +58,44 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
templateUrl: 'create-asset-profile-dialog.html'
})
export class GfCreateAssetProfileDialogComponent implements OnInit {
public createAssetProfileForm: FormGroup;
public ghostfolioPrefix = `${ghostfolioPrefix}_`;
public mode: CreateAssetProfileDialogMode;
protected createAssetProfileForm: CreateAssetProfileForm;
protected readonly ghostfolioPrefix = `${ghostfolioPrefix}_`;
protected mode: CreateAssetProfileDialogMode;
private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource;
public constructor(
public readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dataService: DataService,
private readonly destroyRef: DestroyRef,
public readonly dialogRef: MatDialogRef<GfCreateAssetProfileDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
private readonly adminService = inject(AdminService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfCreateAssetProfileDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
protected get showCurrencyErrorMessage() {
const addCurrencyFormControl =
this.createAssetProfileForm.controls.addCurrency;
if (addCurrencyFormControl.hasError('invalidCurrency')) {
return true;
}
return false;
}
public ngOnInit() {
this.initialize();
this.createAssetProfileForm = this.formBuilder.group(
{
addCurrency: new FormControl(null, [
addCurrency: new FormControl<string | null>(null, [
this.iso4217CurrencyCodeValidator()
]),
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required])
addSymbol: new FormControl<string | null>(null, [Validators.required]),
searchSymbol: new FormControl<AssetProfileIdentifier | null>(null, [
Validators.required
])
},
{
validators: this.atLeastOneValid
@ -104,12 +120,11 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
this.dialogRef.close({
addAssetProfile: true,
dataSource:
this.createAssetProfileForm.get('searchSymbol').value.dataSource,
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
this.createAssetProfileForm.controls.searchSymbol.value?.dataSource,
symbol: this.createAssetProfileForm.controls.searchSymbol.value?.symbol
});
} else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm.get('addCurrency')
.value as string;
const currency = this.createAssetProfileForm.controls.addCurrency.value;
const currencies = Array.from(
new Set([...this.customCurrencies, currency])
@ -139,26 +154,15 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
this.dialogRef.close({
addAssetProfile: true,
dataSource: 'MANUAL',
symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.get('addSymbol').value}`
symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.controls.addSymbol.value}`
});
}
}
public get showCurrencyErrorMessage() {
const addCurrencyFormControl =
this.createAssetProfileForm.get('addCurrency');
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');
private atLeastOneValid(control: CreateAssetProfileForm): ValidationErrors {
const addCurrencyControl = control.controls.addCurrency;
const addSymbolControl = control.controls.addSymbol;
const searchSymbolControl = control.controls.searchSymbol;
if (
addCurrencyControl.valid &&
@ -170,11 +174,8 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
if (
addCurrencyControl.valid ||
!addCurrencyControl ||
addSymbolControl.valid ||
!addSymbolControl ||
searchSymbolControl.valid ||
!searchSymbolControl
searchSymbolControl.valid
) {
return { atLeastOneValid: false };
}
@ -189,11 +190,14 @@ export class GfCreateAssetProfileDialogComponent implements OnInit {
.subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
const { dataSource } = dataProviders.find(({ useForExchangeRates }) => {
return useForExchangeRates;
});
const { dataSource } =
dataProviders.find(({ useForExchangeRates }) => {
return useForExchangeRates;
}) ?? {};
this.dataSourceForExchangeRates = dataSource;
if (dataSource) {
this.dataSourceForExchangeRates = dataSource;
}
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 {
deviceType: string;
locale: string;
}
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="w-50" i18n>Version</div>
<div class="w-50">
<gf-value [value]="version" />
<gf-value
[enableCopyToClipboardButton]="true"
[value]="version"
/>
</div>
</div>
<div class="d-flex my-3">
@ -104,7 +107,13 @@
<table>
@for (coupon of coupons; track coupon) {
<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">
{{ formatStringValue(coupon.duration) }}
</td>

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

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

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

@ -286,7 +286,7 @@
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
@ -298,7 +298,7 @@
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
@ -310,6 +310,9 @@
<gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.symbol"
[value]="SymbolProfile?.symbol"
>Symbol</gf-value
@ -318,6 +321,9 @@
<div class="col-6 mb-3">
<gf-value
size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.isin"
[value]="SymbolProfile?.isin"
>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' }">
<gf-holdings-table
[hasPermissionToShowQuantities]="false"
[holdings]="holdings"
[locale]="user?.settings?.locale"
(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 { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
@ -19,7 +13,8 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Subject } from 'rxjs';
import { CreateWatchlistItemForm } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -36,51 +31,41 @@ import { Subject } from 'rxjs';
styleUrls: ['./create-watchlist-item-dialog.component.scss'],
templateUrl: 'create-watchlist-item-dialog.html'
})
export class GfCreateWatchlistItemDialogComponent implements OnDestroy, OnInit {
public createWatchlistItemForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly dialogRef: MatDialogRef<GfCreateWatchlistItemDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createWatchlistItemForm = this.formBuilder.group(
export class GfCreateWatchlistItemDialogComponent {
protected readonly createWatchlistItemForm: CreateWatchlistItemForm =
new FormGroup(
{
searchSymbol: new FormControl(null, [Validators.required])
searchSymbol: new FormControl<AssetProfileIdentifier | null>(null, [
Validators.required
])
},
{
validators: this.validator
}
);
}
public onCancel() {
private readonly dialogRef =
inject<MatDialogRef<GfCreateWatchlistItemDialogComponent>>(MatDialogRef);
protected onCancel() {
this.dialogRef.close();
}
public onSubmit() {
protected onSubmit() {
this.dialogRef.close({
dataSource:
this.createWatchlistItemForm.get('searchSymbol').value.dataSource,
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol
this.createWatchlistItemForm.controls.searchSymbol.value?.dataSource,
symbol: this.createWatchlistItemForm.controls.searchSymbol.value?.symbol
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validator(control: AbstractControl): ValidationErrors {
const searchSymbolControl = control.get('searchSymbol');
private validator(control: CreateWatchlistItemForm): ValidationErrors {
const searchSymbolControl = control.controls.searchSymbol;
if (
searchSymbolControl.valid &&
searchSymbolControl.value.dataSource &&
searchSymbolControl.value.symbol
searchSymbolControl.value?.dataSource &&
searchSymbolControl.value?.symbol
) {
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 {
deviceType: 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
Benchmark,
@ -14,10 +15,13 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -25,8 +29,6 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
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 { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@ -45,37 +47,39 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
styleUrls: ['./home-watchlist.scss'],
templateUrl: './home-watchlist.html'
})
export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean;
public user: User;
public watchlist: Benchmark[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
export class GfHomeWatchlistComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean;
protected user: User;
protected watchlist: Benchmark[];
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createWatchlistItemDialog']) {
this.openCreateWatchlistItemDialog();
@ -83,7 +87,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -112,13 +116,13 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
this.loadWatchlistData();
}
public onWatchlistItemDeleted({
protected onWatchlistItemDeleted({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.dataService
.deleteWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
return this.loadWatchlistData();
@ -126,15 +130,10 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadWatchlistData() {
this.dataService
.fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist;
@ -145,7 +144,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
private openCreateWatchlistItemDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -155,20 +154,20 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
>(GfCreateWatchlistItemDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType,
locale: this.user?.settings?.locale
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? defaultLocale
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
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">
<gf-benchmark
[benchmarks]="watchlist"
[deviceType]="deviceType"
[deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined"
[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() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false;
@Input() isInPercentage = false;
@Input() isLoading = false;
@Input() locale = getLocale();
@Input() savingsRate = 0;
@ -119,7 +119,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
data: this.investments.map(({ date, investment }) => {
return {
x: parseDate(date).getTime(),
y: this.isInPercent ? investment * 100 : investment
y: this.isInPercentage ? investment * 100 : investment
};
}),
label: this.benchmarkDataLabel,
@ -139,7 +139,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
data: this.values.map(({ date, value }) => {
return {
x: parseDate(date).getTime(),
y: this.isInPercent ? value * 100 : value
y: this.isInPercentage ? value * 100 : value
};
}),
fill: false,
@ -251,7 +251,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
border: {
display: false
},
display: !this.isInPercent,
display: !this.isInPercentage,
grid: {
color: ({ scale, tick }) => {
if (
@ -292,10 +292,10 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.isInPercent ? undefined : this.currency,
currency: this.isInPercentage ? undefined : this.currency,
groupBy: this.groupBy,
locale: this.isInPercent ? undefined : this.locale,
unit: this.isInPercent ? '%' : undefined
locale: this.isInPercentage ? undefined : this.locale,
unit: this.isInPercentage ? '%' : undefined
}),
mode: 'index',
position: 'top',

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

@ -19,12 +19,11 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -39,7 +38,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./markets.scss'],
templateUrl: './markets.html'
})
export class GfMarketsComponent implements OnDestroy, OnInit {
export class GfMarketsComponent implements OnInit {
public benchmarks: Benchmark[];
public deviceType: string;
public fearAndGreedIndex: number;
@ -55,18 +54,17 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -79,7 +77,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.dataService
.fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ fearAndGreedIndex }) => {
this.fearAndGreedIndexData = fearAndGreedIndex;
@ -90,7 +88,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
@ -119,9 +117,4 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
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,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
@ -21,8 +23,8 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline, checkmarkCircleOutline } from 'ionicons/icons';
import ms from 'ms';
import { interval, Subject } from 'rxjs';
import { take, takeUntil, tap } from 'rxjs/operators';
import { interval } from 'rxjs';
import { take, tap } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@ -51,11 +53,10 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public routerLinkPricing = publicRoutes.pricing.routerLink;
public variantIndex: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfSubscriptionInterstitialDialogComponent>
) {
this.variantIndex = Math.floor(
@ -76,7 +77,7 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck();
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
@ -84,9 +85,4 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public closeDialog() {
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 { DataService } from '@ghostfolio/ui/services';
import type { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormGroup,
@ -28,7 +30,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
import { EMPTY, catchError } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -48,22 +50,25 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html'
})
export class GfCreateOrUpdateAccessDialogComponent
implements OnDestroy, OnInit
{
public accessForm: FormGroup;
public mode: 'create' | 'update';
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private dataService: DataService,
private formBuilder: FormBuilder,
private notificationService: NotificationService
) {
export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
protected accessForm: FormGroup;
protected mode: 'create' | 'update';
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly data =
inject<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccessDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
private readonly notificationService = inject(NotificationService);
public constructor() {
this.mode = this.data.access?.id ? 'update' : 'create';
}
@ -83,22 +88,25 @@ export class GfCreateOrUpdateAccessDialogComponent
]
});
this.accessForm.get('type').valueChanges.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions');
this.accessForm
.get('type')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions');
if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required);
} else {
granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]);
}
if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required);
} else {
granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null);
permissionsControl?.setValue(this.data.access.permissions[0]);
}
granteeUserIdControl.updateValueAndValidity();
granteeUserIdControl?.updateValueAndValidity();
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public onCancel() {
@ -113,16 +121,11 @@ export class GfCreateOrUpdateAccessDialogComponent
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async createAccess() {
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value]
alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions')?.value]
};
try {
@ -135,7 +138,7 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not grant access.`
@ -144,7 +147,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close(access);
@ -156,10 +159,10 @@ export class GfCreateOrUpdateAccessDialogComponent
private async updateAccess() {
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value]
permissions: [this.accessForm.get('permissions')?.value]
};
try {
@ -172,8 +175,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService
.putAccess(access)
.pipe(
catchError(({ status }) => {
if (status.status === StatusCodes.BAD_REQUEST) {
catchError(({ status }: HttpErrorResponse) => {
if (status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not update access.`
});
@ -181,7 +184,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
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-select formControlName="permissions">
<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-select>
</mat-form-field>
</div>
@if (accessForm.get('type').value === 'PRIVATE') {
@if (accessForm.get('type')?.value === 'PRIVATE') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label>

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

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

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

@ -14,15 +14,16 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import ms, { StringValue } from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,7 +39,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-membership.scss'],
templateUrl: './user-account-membership.html'
})
export class GfUserAccountMembershipComponent implements OnDestroy {
export class GfUserAccountMembershipComponent {
public baseCurrency: string;
public coupon: number;
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';
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
@ -73,7 +73,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -118,7 +118,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ sessionUrl }) => {
window.location.href = sessionUrl;
@ -142,7 +142,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ apiKey }) => {
this.notificationService.alert({
@ -180,7 +180,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const snackBarRef = this.snackBar.open(
@ -193,14 +193,14 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
snackBarRef
.afterDismissed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
window.location.reload();
});
snackBarRef
.onAction()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
window.location.reload();
});
@ -210,9 +210,4 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
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,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormsModule,
@ -44,8 +45,8 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons';
import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -66,7 +67,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-settings.scss'],
templateUrl: './user-account-settings.html'
})
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
export class GfUserAccountSettingsComponent implements OnInit {
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public canLinkOidc = false;
@ -103,12 +104,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService,
@ -134,7 +134,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -231,11 +231,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -299,7 +299,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.userService.signOut();
@ -315,11 +315,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -331,7 +331,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete activity.id;
@ -351,11 +351,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -390,11 +390,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -403,11 +403,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deregisterDevice() {
this.webAuthnService
.deregister()
@ -417,7 +412,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.update();
@ -447,7 +442,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return error;
});
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {

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

@ -7,10 +7,11 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } 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 { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { UserDetailDialogParams } from './interfaces/interfaces';
@ -38,15 +39,14 @@ import { UserDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./user-detail-dialog.component.scss'],
templateUrl: './user-detail-dialog.html'
})
export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
export class GfUserDetailDialogComponent implements OnInit {
public user: AdminUserResponse;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent>
) {
addIcons({
@ -58,7 +58,7 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
this.adminService
.fetchUserById(this.data.userId)
.pipe(
takeUntil(this.unsubscribeSubject),
takeUntilDestroyed(this.destroyRef),
catchError(() => {
this.dialogRef.close();
@ -82,9 +82,4 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
public onClose() {
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="mb-3 row">
<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 class="col-6 mb-3">
<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 { CommonModule, NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
AbstractControl,
FormBuilder,
@ -51,17 +51,17 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html'
})
export class GfCreateOrUpdateAccountDialogComponent {
public accountForm: FormGroup;
public currencies: string[] = [];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[] = [];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccountDialogComponent>,
private formBuilder: FormBuilder
) {}
protected accountForm: FormGroup;
protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined;
protected platforms: Platform[] = [];
protected readonly data =
inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
public ngOnInit() {
const { currencies } = this.dataService.fetchInfo();
@ -93,18 +93,18 @@ export class GfCreateOrUpdateAccountDialogComponent {
this.filteredPlatforms = this.accountForm
.get('platformId')
.valueChanges.pipe(
?.valueChanges.pipe(
startWith(''),
map((value) => {
map((value: Platform | string) => {
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() {
const inputValue = this.accountForm.get('platformId').value;
protected autoCompleteCheck() {
const inputValue = this.accountForm.get('platformId')?.value;
if (typeof inputValue === 'string') {
const matchingEntry = this.platforms.find(({ name }) => {
@ -112,28 +112,28 @@ export class GfCreateOrUpdateAccountDialogComponent {
});
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 ?? '';
}
public onCancel() {
protected onCancel() {
this.dialogRef.close();
}
public async onSubmit() {
protected async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.get('balance').value,
comment: this.accountForm.get('comment').value || null,
currency: this.accountForm.get('currency').value,
id: this.accountForm.get('accountId').value,
isExcluded: this.accountForm.get('isExcluded').value,
name: this.accountForm.get('name').value,
platformId: this.accountForm.get('platformId').value?.id || null
balance: this.accountForm.get('balance')?.value,
comment: this.accountForm.get('comment')?.value || null,
currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id || null
};
try {
@ -177,7 +177,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
const filterValue = value.toLowerCase();
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',
label:
internalRoutes.adminControl.subRoutes.settings.title +
'<span class="badge badge-pill badge-secondary ml-2 text-uppercase">' +
$localize`new` +
'</span>',
label: internalRoutes.adminControl.subRoutes.settings.title,
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 { 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 { map, Observable, Subject, takeUntil } from 'rxjs';
import { map, Observable } from 'rxjs';
@Component({
host: { class: 'page' },
@ -35,9 +36,11 @@ export class GfApiPageComponent implements OnInit {
public status$: Observable<DataProviderGhostfolioStatusResponse>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {}
public constructor(
private destroyRef: DestroyRef,
private http: HttpClient
) {}
public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
@ -51,18 +54,13 @@ export class GfApiPageComponent implements OnInit {
this.status$ = this.fetchStatus();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http
.get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
.pipe(takeUntilDestroyed(this.destroyRef));
}
private fetchDividends({ symbol }: { symbol: string }) {
@ -82,7 +80,7 @@ export class GfApiPageComponent implements OnInit {
map(({ dividends }) => {
return dividends;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
);
}
@ -103,7 +101,7 @@ export class GfApiPageComponent implements OnInit {
map(({ historicalData }) => {
return historicalData;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
);
}
@ -129,7 +127,7 @@ export class GfApiPageComponent implements OnInit {
map(({ items }) => {
return items;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
);
}
@ -145,7 +143,7 @@ export class GfApiPageComponent implements OnInit {
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
);
}
@ -155,7 +153,7 @@ export class GfApiPageComponent implements OnInit {
'/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
.pipe(takeUntilDestroyed(this.destroyRef));
}
private getHeaders() {

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

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

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

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

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

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

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

@ -21,9 +21,10 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
DestroyRef,
Inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormGroup,
@ -52,7 +53,6 @@ import { cloudUploadOutline, warningOutline } from 'ionicons/icons';
import { isArray, sortBy } from 'lodash';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { ImportStep } from './enums/import-step';
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@ -81,7 +81,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
styleUrls: ['./import-activities-dialog.scss'],
templateUrl: 'import-activities-dialog.html'
})
export class GfImportActivitiesDialogComponent implements OnDestroy {
export class GfImportActivitiesDialogComponent {
public accounts: CreateAccountWithBalancesDto[] = [];
public activities: Activity[] = [];
public assetProfileForm: FormGroup;
@ -104,12 +104,11 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
public tags: CreateTagDto[] = [];
public totalItems: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private formBuilder: FormBuilder,
public dialogRef: MatDialogRef<GfImportActivitiesDialogComponent>,
@ -152,7 +151,7 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
],
range: 'max'
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase();
@ -237,7 +236,7 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
dataSource,
symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => {
this.activities = activities;
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({
file,
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) {
this.user = state.user;
this.worldMapChartFormat =
this.hasImpersonationId || this.user.settings.isRestrictedView
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
this.worldMapChartFormat = this.showValuesInPercentage()
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
this.isLoading = true;
@ -310,7 +309,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.accounts)) {
let value = 0;
if (this.hasImpersonationId) {
if (this.showValuesInPercentage()) {
value = valueInPercentage;
} else {
value = valueInBaseCurrency;
@ -328,7 +327,7 @@ export class GfAllocationsPageComponent implements OnInit {
)) {
let value = 0;
if (this.hasImpersonationId) {
if (this.showValuesInPercentage()) {
value = position.allocationInPercentage;
} else {
value = position.valueInBaseCurrency;
@ -405,17 +404,22 @@ export class GfAllocationsPageComponent implements OnInit {
}
if (position.holdings.length > 0) {
for (const holding of position.holdings) {
const { allocationInPercentage, name, valueInBaseCurrency } =
holding;
if (this.topHoldingsMap[name]?.value) {
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
for (const {
allocationInPercentage,
name,
valueInBaseCurrency
} of position.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) {
this.topHoldingsMap[normalizedAssetName].value += isNumber(
valueInBaseCurrency
)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage;
} else {
this.topHoldingsMap[name] = {
this.topHoldingsMap[normalizedAssetName] = {
name,
value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency
@ -486,7 +490,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.platforms)) {
let value = 0;
if (this.hasImpersonationId) {
if (this.showValuesInPercentage()) {
value = valueInPercentage;
} else {
value = valueInBaseCurrency;
@ -501,7 +505,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
if (this.showValuesInPercentage()) {
return {
name,
allocationInPercentage: value,
@ -518,7 +522,10 @@ export class GfAllocationsPageComponent implements OnInit {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
(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) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
@ -581,4 +596,8 @@ export class GfAllocationsPageComponent implements OnInit {
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-progress-bar
mode="determinate"
[title]="
`${(
portfolioDetails?.summary?.filteredValueInPercentage * 100
).toFixed(2)}%`
"
[title]="`${(
portfolioDetails?.summary?.filteredValueInPercentage * 100
).toFixed(2)}%`"
[value]="portfolioDetails?.summary?.filteredValueInPercentage * 100"
/>
</mat-card-content>
@ -49,7 +47,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="platforms"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['id']"
[locale]="user?.settings?.locale"
/>
@ -71,7 +69,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['currency']"
[locale]="user?.settings?.locale"
/>
@ -93,7 +91,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale"
/>
@ -114,7 +112,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="symbols"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[showLabels]="deviceType !== 'mobile'"
@ -138,7 +136,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="sectors"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
@ -161,7 +159,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="continents"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['name']"
[locale]="user?.settings?.locale"
/>
@ -183,7 +181,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
@ -206,9 +204,7 @@
<gf-world-map-chart
[countries]="countries"
[format]="worldMapChartFormat"
[isInPercent]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[locale]="user?.settings?.locale"
/>
</div>
@ -272,7 +268,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="countries"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
@ -291,7 +287,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="accounts"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['id']"
[locale]="user?.settings?.locale"
(proportionChartClicked)="onAccountChartClicked($event)"
@ -314,7 +310,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercentage]="showValuesInPercentage()"
[keys]="['etfProvider']"
[locale]="user?.settings?.locale"
/>

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

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

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

@ -12,9 +12,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
@ -27,8 +28,8 @@ import {
informationCircleOutline
} from 'ionicons/icons';
import { StringValue } from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
host: { class: 'page' },
@ -46,7 +47,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./pricing-page.scss'],
templateUrl: './pricing-page.html'
})
export class GfPricingPageComponent implements OnDestroy, OnInit {
export class GfPricingPageComponent implements OnInit {
public baseCurrency: string;
public coupon: number;
public couponId: string;
@ -92,11 +93,10 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public routerLinkRegister = publicRoutes.register.routerLink;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private userService: UserService
) {
@ -124,7 +124,7 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
this.price = subscriptionOffer?.price;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -161,15 +161,10 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ 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 { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectorRef,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -52,48 +55,50 @@ import { catchError } from 'rxjs/operators';
templateUrl: './public-page.html'
})
export class GfPublicPageComponent implements OnInit {
public continents: {
protected continents: {
[code: string]: { name: string; value: number };
};
public countries: {
protected countries: {
[code: string]: { name: string; value: number };
};
public defaultAlias = $localize`someone`;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public holdings: PublicPortfolioResponse['holdings'][string][];
public info: InfoItem;
public latestActivitiesDataSource: MatTableDataSource<
protected readonly defaultAlias = $localize`someone`;
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected hasPermissionForSubscription: boolean;
protected holdings: PublicPortfolioResponse['holdings'][string][];
protected info: InfoItem;
protected latestActivitiesDataSource: MatTableDataSource<
PublicPortfolioResponse['latestActivities'][0]
>;
public markets: {
protected markets: {
[key in Market]: { id: Market; valueInPercentage: number };
};
public pageSize = Number.MAX_SAFE_INTEGER;
public positions: {
protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
value: number;
};
};
public publicPortfolioDetails: PublicPortfolioResponse;
public sectors: {
protected publicPortfolioDetails: PublicPortfolioResponse;
protected sectors: {
[name: string]: { name: string; value: number };
};
public symbols: {
protected symbols: {
[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;
public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private router: Router
) {
public constructor() {
this.activatedRoute.params.subscribe((params) => {
this.accessId = params['id'];
});
@ -107,13 +112,11 @@ export class GfPublicPageComponent implements OnInit {
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService
.fetchPublicPortfolio(this.accessId)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => {
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.NOT_FOUND) {
console.error(error);
this.router.navigate(['/']);
@ -135,7 +138,7 @@ export class GfPublicPageComponent implements OnInit {
});
}
public initializeAnalysisData() {
private initializeAnalysisData() {
this.continents = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
@ -185,36 +188,38 @@ export class GfPublicPageComponent implements OnInit {
if (this.continents[continent]?.value) {
this.continents[continent].value +=
weight * position.valueInBaseCurrency;
weight * (position.valueInBaseCurrency ?? 0);
} else {
this.continents[continent] = {
name: continent,
value:
weight *
this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
};
}
if (this.countries[code]?.value) {
this.countries[code].value +=
weight * position.valueInBaseCurrency;
weight * (position.valueInBaseCurrency ?? 0);
} else {
this.countries[code] = {
name,
value:
weight *
this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
this.countries[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
}
if (position.sectors.length > 0) {
@ -222,20 +227,22 @@ export class GfPublicPageComponent implements OnInit {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.valueInBaseCurrency;
this.sectors[name].value +=
weight * (position.valueInBaseCurrency ?? 0);
} else {
this.sectors[name] = {
name,
value:
weight *
this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
};
}
}
} else {
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),
value: isNumber(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
class="mx-auto"
[data]="symbols"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['symbol']"
[showLabels]="deviceType !== 'mobile'"
[showLabels]="deviceType() !== 'mobile'"
/>
<gf-holdings-table
[hasPermissionToOpenDetails]="false"
@ -98,7 +98,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="positions"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['currency']"
[maxItems]="10"
/>
@ -115,7 +115,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="sectors"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[maxItems]="10"
/>
@ -134,7 +134,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="continents"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
/>
</mat-card-content>
@ -154,7 +154,7 @@
<gf-world-map-chart
format="{0}%"
[countries]="countries"
[isInPercent]="true"
[isInPercentage]="true"
/>
</div>
<div class="row">
@ -213,7 +213,7 @@
<mat-card-content>
<gf-activities-table
[dataSource]="latestActivitiesDataSource"
[deviceType]="deviceType"
[deviceType]="deviceType()"
[hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="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,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject,
OnDestroy,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@ -27,8 +28,6 @@ import {
checkmarkOutline,
copyOutline
} from 'ionicons/icons';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
@ -53,7 +52,7 @@ import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
styleUrls: ['./user-account-registration-dialog.scss'],
templateUrl: 'user-account-registration-dialog.html'
})
export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
export class GfUserAccountRegistrationDialogComponent {
@ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string;
@ -64,12 +63,11 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams,
private dataService: DataService
private dataService: DataService,
private destroyRef: DestroyRef
) {
addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline });
}
@ -77,7 +75,7 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accessToken, authToken, role }) => {
this.accessToken = accessToken;
this.authToken = authToken;
@ -96,9 +94,4 @@ export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
public onChangeDislaimerChecked() {
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