Browse Source

Merge branch 'main' into next

next 3.0.0-beta.3
Thomas Kaul 18 hours ago
parent
commit
894739b06b
  1. 2
      .gitignore
  2. 55
      CHANGELOG.md
  3. 2
      README.md
  4. 4
      apps/api/src/app/activities/activities.module.ts
  5. 4
      apps/api/src/app/admin/admin.module.ts
  6. 6
      apps/api/src/app/admin/admin.service.ts
  7. 9
      apps/api/src/app/admin/queue/queue.module.ts
  8. 24
      apps/api/src/app/admin/queue/queue.service.ts
  9. 4
      apps/api/src/app/app.module.ts
  10. 4
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  11. 16
      apps/api/src/app/import/import.controller.ts
  12. 4
      apps/api/src/app/import/import.module.ts
  13. 4
      apps/api/src/app/info/info.module.ts
  14. 175
      apps/api/src/app/info/info.service.ts
  15. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  16. 25
      apps/api/src/environments/environment.prod.ts
  17. 4
      apps/api/src/events/events.module.ts
  18. 2
      apps/api/src/models/rule.ts
  19. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  20. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  21. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  22. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  23. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  24. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  25. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  26. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  27. 7
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  28. 7
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  29. 7
      apps/api/src/models/rules/liquidity/buying-power.ts
  30. 4
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  31. 4
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  32. 4
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  33. 4
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  34. 4
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  35. 6
      apps/api/src/services/cron/cron.module.ts
  36. 9
      apps/api/src/services/cron/cron.service.ts
  37. 2
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  38. 36
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  39. 212
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  40. 40
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.service.ts
  41. 101
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  42. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  43. 74
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  44. 307
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  45. 54
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  46. 92
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  47. 10
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts
  48. 13
      apps/client/src/app/components/admin-overview/admin-overview.html
  49. 2
      apps/client/src/app/components/admin-users/admin-users.component.ts
  50. 8
      apps/client/src/app/components/footer/footer.component.ts
  51. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  52. 2
      apps/client/src/app/components/home-overview/home-overview.component.ts
  53. 44
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts
  54. 8
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts
  55. 54
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  56. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  57. 14
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  58. 80
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  59. 4
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  60. 2
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  61. 19
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  62. 10
      apps/client/src/app/pages/about/about-page.routes.ts
  63. 2
      apps/client/src/app/pages/about/changelog/changelog-page.routes.ts
  64. 2
      apps/client/src/app/pages/about/license/license-page.routes.ts
  65. 2
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.routes.ts
  66. 2
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.routes.ts
  67. 2
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.routes.ts
  68. 58
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  69. 6
      apps/client/src/app/pages/admin/admin-page.component.ts
  70. 20
      apps/client/src/app/pages/admin/admin-page.routes.ts
  71. 2
      apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts
  72. 4
      apps/client/src/app/pages/faq/faq-page.routes.ts
  73. 16
      apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts
  74. 18
      apps/client/src/app/pages/faq/saas/saas-page.component.ts
  75. 2
      apps/client/src/app/pages/faq/saas/saas-page.routes.ts
  76. 2
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.routes.ts
  77. 20
      apps/client/src/app/pages/home/home-page.routes.ts
  78. 19
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  79. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts
  80. 234
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  81. 10
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  82. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts
  83. 19
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  84. 32
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  85. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  86. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.routes.ts
  87. 8
      apps/client/src/app/pages/portfolio/portfolio-page.routes.ts
  88. 87
      apps/client/src/app/pages/public/public-page.component.ts
  89. 14
      apps/client/src/app/pages/public/public-page.html
  90. 2
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts
  91. 2
      apps/client/src/app/pages/resources/glossary/resources-glossary.routes.ts
  92. 2
      apps/client/src/app/pages/resources/guides/resources-guides.routes.ts
  93. 2
      apps/client/src/app/pages/resources/markets/resources-markets.routes.ts
  94. 12
      apps/client/src/app/pages/resources/overview/resources-overview.component.ts
  95. 6
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.routes.ts
  96. 8
      apps/client/src/app/pages/resources/resources-page.routes.ts
  97. 8
      apps/client/src/app/pages/user-account/user-account-page.routes.ts
  98. 4
      apps/client/src/app/pages/zen/zen-page.routes.ts
  99. 11
      apps/client/src/app/services/user/user.service.ts
  100. 12
      apps/client/src/assets/terms-of-service.md

2
.gitignore

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

55
CHANGELOG.md

@ -16,11 +16,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used. - **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used.
## Unreleased ## 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
- Added support for filtering by activity type on the activities page (experimental) - 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 ## 2.252.0 - 2026-03-02
@ -6016,10 +6065,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema - Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription - Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022 ## 1.169.0 - 14.07.2022
### Added ### Added

2
README.md

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -89,7 +89,7 @@ export class GfAdminUsersComponent implements OnInit {
public isLoading = false; public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE; public pageSize = DEFAULT_PAGE_SIZE;
public routerLinkAdminControlUsers = public routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes?.users.routerLink; internalRoutes.adminControl.subRoutes.users.routerLink;
public totalItems = 0; public totalItems = 0;
public user: User; public user: User;

8
apps/client/src/app/components/footer/footer.component.ts

@ -33,13 +33,13 @@ export class GfFooterComponent implements OnChanges {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink; public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog = public routerLinkAboutChangelog =
publicRoutes.about.subRoutes?.changelog.routerLink; publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense = public routerLinkAboutLicense =
publicRoutes.about.subRoutes?.license.routerLink; publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy = public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes?.privacyPolicy.routerLink; publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService = public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes?.termsOfService.routerLink; publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink; public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink; public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;

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

@ -286,7 +286,7 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="sectors" [data]="sectors"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
@ -298,7 +298,7 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="countries" [data]="countries"
[isInPercent]="true" [isInPercentage]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"

2
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -56,7 +56,7 @@ export class GfHomeOverviewComponent implements OnInit {
public routerLinkAccounts = internalRoutes.accounts.routerLink; public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink; public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPortfolioActivities = public routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes?.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
public showDetails = false; public showDetails = false;
public unit: string; public unit: string;
public user: User; public user: User;

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

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

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

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

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

@ -1,5 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -14,8 +15,10 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -45,26 +48,29 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html' templateUrl: './home-watchlist.html'
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
public deviceType: string; protected hasImpersonationId: boolean;
public hasImpersonationId: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean; protected user: User;
public user: User; protected watchlist: Benchmark[];
public watchlist: Benchmark[];
protected readonly deviceType = computed(
public constructor( () => this.deviceDetectorService.deviceInfo().deviceType
private changeDetectorRef: ChangeDetectorRef, );
private dataService: DataService,
private destroyRef: DestroyRef, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private deviceService: DeviceDetectorService, private readonly dataService = inject(DataService);
private dialog: MatDialog, private readonly destroyRef = inject(DestroyRef);
private impersonationStorageService: ImpersonationStorageService, private readonly deviceDetectorService = inject(DeviceDetectorService);
private route: ActivatedRoute, private readonly dialog = inject(MatDialog);
private router: Router, private readonly impersonationStorageService = inject(
private userService: UserService ImpersonationStorageService
) { );
this.deviceType = this.deviceService.getDeviceInfo().deviceType; private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -110,7 +116,7 @@ export class GfHomeWatchlistComponent implements OnInit {
this.loadWatchlistData(); this.loadWatchlistData();
} }
public onWatchlistItemDeleted({ protected onWatchlistItemDeleted({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
@ -148,10 +154,10 @@ export class GfHomeWatchlistComponent implements OnInit {
>(GfCreateWatchlistItemDialogComponent, { >(GfCreateWatchlistItemDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType, deviceType: this.deviceType(),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale ?? defaultLocale
}, },
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

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

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

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

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

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

@ -3,12 +3,13 @@ import { validateObjectForForm } from '@ghostfolio/common/utils';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import type { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -50,18 +51,24 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class GfCreateOrUpdateAccessDialogComponent implements OnInit { export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public accessForm: FormGroup; protected accessForm: FormGroup;
public mode: 'create' | 'update'; protected mode: 'create' | 'update';
public constructor( private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, private readonly data =
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>, inject<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA);
private dataService: DataService,
private destroyRef: DestroyRef, private readonly dataService = inject(DataService);
private formBuilder: FormBuilder, private readonly destroyRef = inject(DestroyRef);
private notificationService: NotificationService
) { 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'; this.mode = this.data.access?.id ? 'update' : 'create';
} }
@ -81,22 +88,25 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
] ]
}); });
this.accessForm.get('type').valueChanges.subscribe((accessType) => { this.accessForm
const granteeUserIdControl = this.accessForm.get('granteeUserId'); .get('type')
const permissionsControl = this.accessForm.get('permissions'); ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions');
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required); granteeUserIdControl?.setValidators(Validators.required);
} else { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl.setValue(null); granteeUserIdControl?.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]); permissionsControl?.setValue(this.data.access.permissions[0]);
} }
granteeUserIdControl.updateValueAndValidity(); granteeUserIdControl?.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
public onCancel() { public onCancel() {
@ -113,9 +123,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private async createAccess() { private async createAccess() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions')?.value]
}; };
try { try {
@ -128,7 +138,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.dataService this.dataService
.postAccess(access) .postAccess(access)
.pipe( .pipe(
catchError((error) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.BAD_REQUEST) { if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not grant access.` title: $localize`Oops! Could not grant access.`
@ -149,10 +159,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private async updateAccess() { private async updateAccess() {
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: this.data.access.id, id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions')?.value]
}; };
try { try {
@ -165,8 +175,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.dataService this.dataService
.putAccess(access) .putAccess(access)
.pipe( .pipe(
catchError(({ status }) => { catchError(({ status }: HttpErrorResponse) => {
if (status.status === StatusCodes.BAD_REQUEST) { if (status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not update access.` title: $localize`Oops! Could not update access.`
}); });

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

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

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

@ -212,8 +212,6 @@ export class GfUserAccountAccessComponent implements OnInit {
}); });
if (!access) { if (!access) {
console.log('Could not find access.');
return; return;
} }

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

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

10
apps/client/src/app/pages/about/about-page.routes.ts

@ -15,29 +15,29 @@ export const routes: Routes = [
import('./overview/about-overview-page.routes').then((m) => m.routes) import('./overview/about-overview-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes?.changelog.path, path: publicRoutes.about.subRoutes.changelog.path,
loadChildren: () => loadChildren: () =>
import('./changelog/changelog-page.routes').then((m) => m.routes) import('./changelog/changelog-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes?.license.path, path: publicRoutes.about.subRoutes.license.path,
loadChildren: () => loadChildren: () =>
import('./license/license-page.routes').then((m) => m.routes) import('./license/license-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes?.ossFriends.path, path: publicRoutes.about.subRoutes.ossFriends.path,
loadChildren: () => loadChildren: () =>
import('./oss-friends/oss-friends-page.routes').then((m) => m.routes) import('./oss-friends/oss-friends-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes?.privacyPolicy.path, path: publicRoutes.about.subRoutes.privacyPolicy.path,
loadChildren: () => loadChildren: () =>
import('./privacy-policy/privacy-policy-page.routes').then( import('./privacy-policy/privacy-policy-page.routes').then(
(m) => m.routes (m) => m.routes
) )
}, },
{ {
path: publicRoutes.about.subRoutes?.termsOfService.path, path: publicRoutes.about.subRoutes.termsOfService.path,
loadChildren: () => loadChildren: () =>
import('./terms-of-service/terms-of-service-page.routes').then( import('./terms-of-service/terms-of-service-page.routes').then(
(m) => m.routes (m) => m.routes

2
apps/client/src/app/pages/about/changelog/changelog-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfChangelogPageComponent, component: GfChangelogPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes?.changelog.title title: publicRoutes.about.subRoutes.changelog.title
} }
]; ];

2
apps/client/src/app/pages/about/license/license-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfLicensePageComponent, component: GfLicensePageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes?.license.title title: publicRoutes.about.subRoutes.license.title
} }
]; ];

2
apps/client/src/app/pages/about/oss-friends/oss-friends-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfOpenSourceSoftwareFriendsPageComponent, component: GfOpenSourceSoftwareFriendsPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes?.ossFriends.title title: publicRoutes.about.subRoutes.ossFriends.title
} }
]; ];

2
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfPrivacyPolicyPageComponent, component: GfPrivacyPolicyPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes?.privacyPolicy.title title: publicRoutes.about.subRoutes.privacyPolicy.title
} }
]; ];

2
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfTermsOfServicePageComponent, component: GfTermsOfServicePageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes?.termsOfService.title title: publicRoutes.about.subRoutes.termsOfService.title
} }
]; ];

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

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

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

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

20
apps/client/src/app/pages/admin/admin-page.routes.ts

@ -20,29 +20,29 @@ export const routes: Routes = [
title: internalRoutes.adminControl.title title: internalRoutes.adminControl.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes?.jobs.path, path: internalRoutes.adminControl.subRoutes.jobs.path,
component: GfAdminJobsComponent, component: GfAdminJobsComponent,
title: internalRoutes.adminControl.subRoutes?.jobs.title title: internalRoutes.adminControl.subRoutes.jobs.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes?.marketData.path, path: internalRoutes.adminControl.subRoutes.marketData.path,
component: GfAdminMarketDataComponent, component: GfAdminMarketDataComponent,
title: internalRoutes.adminControl.subRoutes?.marketData.title title: internalRoutes.adminControl.subRoutes.marketData.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes?.settings.path, path: internalRoutes.adminControl.subRoutes.settings.path,
component: GfAdminSettingsComponent, component: GfAdminSettingsComponent,
title: internalRoutes.adminControl.subRoutes?.settings.title title: internalRoutes.adminControl.subRoutes.settings.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes?.users.path, path: internalRoutes.adminControl.subRoutes.users.path,
component: GfAdminUsersComponent, component: GfAdminUsersComponent,
title: internalRoutes.adminControl.subRoutes?.users.title title: internalRoutes.adminControl.subRoutes.users.title
}, },
{ {
path: `${internalRoutes.adminControl.subRoutes?.users.path}/:userId`, path: `${internalRoutes.adminControl.subRoutes.users.path}/:userId`,
component: GfAdminUsersComponent, component: GfAdminUsersComponent,
title: internalRoutes.adminControl.subRoutes?.users.title title: internalRoutes.adminControl.subRoutes.users.title
} }
], ],
component: AdminPageComponent, component: AdminPageComponent,

2
apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts

@ -12,6 +12,6 @@ import { RouterModule } from '@angular/router';
}) })
export class GhostfolioJoinsOssFriendsPageComponent { export class GhostfolioJoinsOssFriendsPageComponent {
public routerLinkAboutOssFriends = public routerLinkAboutOssFriends =
publicRoutes.about.subRoutes?.ossFriends.routerLink; publicRoutes.about.subRoutes.ossFriends.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink; public routerLinkBlog = publicRoutes.blog.routerLink;
} }

4
apps/client/src/app/pages/faq/faq-page.routes.ts

@ -15,12 +15,12 @@ export const routes: Routes = [
import('./overview/faq-overview-page.routes').then((m) => m.routes) import('./overview/faq-overview-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.faq.subRoutes?.saas.path, path: publicRoutes.faq.subRoutes.saas.path,
loadChildren: () => loadChildren: () =>
import('./saas/saas-page.routes').then((m) => m.routes) import('./saas/saas-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.faq.subRoutes?.selfHosting.path, path: publicRoutes.faq.subRoutes.selfHosting.path,
loadChildren: () => loadChildren: () =>
import('./self-hosting/self-hosting-page.routes').then( import('./self-hosting/self-hosting-page.routes').then(
(m) => m.routes (m) => m.routes

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

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

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

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

2
apps/client/src/app/pages/faq/saas/saas-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfSaasPageComponent, component: GfSaasPageComponent,
path: '', path: '',
title: `${publicRoutes.faq.subRoutes?.saas.title} - ${publicRoutes.faq.title}` title: `${publicRoutes.faq.subRoutes.saas.title} - ${publicRoutes.faq.title}`
} }
]; ];

2
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfSelfHostingPageComponent, component: GfSelfHostingPageComponent,
path: '', path: '',
title: `${publicRoutes.faq.subRoutes?.selfHosting.title} - ${publicRoutes.faq.title}` title: `${publicRoutes.faq.subRoutes.selfHosting.title} - ${publicRoutes.faq.title}`
} }
]; ];

20
apps/client/src/app/pages/home/home-page.routes.ts

@ -20,29 +20,29 @@ export const routes: Routes = [
component: GfHomeOverviewComponent component: GfHomeOverviewComponent
}, },
{ {
path: internalRoutes.home.subRoutes?.holdings.path, path: internalRoutes.home.subRoutes.holdings.path,
component: GfHomeHoldingsComponent, component: GfHomeHoldingsComponent,
title: internalRoutes.home.subRoutes?.holdings.title title: internalRoutes.home.subRoutes.holdings.title
}, },
{ {
path: internalRoutes.home.subRoutes?.summary.path, path: internalRoutes.home.subRoutes.summary.path,
component: GfHomeSummaryComponent, component: GfHomeSummaryComponent,
title: internalRoutes.home.subRoutes?.summary.title title: internalRoutes.home.subRoutes.summary.title
}, },
{ {
path: internalRoutes.home.subRoutes?.markets.path, path: internalRoutes.home.subRoutes.markets.path,
component: GfHomeMarketComponent, component: GfHomeMarketComponent,
title: internalRoutes.home.subRoutes?.markets.title title: internalRoutes.home.subRoutes.markets.title
}, },
{ {
path: internalRoutes.home.subRoutes?.marketsPremium.path, path: internalRoutes.home.subRoutes.marketsPremium.path,
component: GfMarketsComponent, component: GfMarketsComponent,
title: internalRoutes.home.subRoutes?.marketsPremium.title title: internalRoutes.home.subRoutes.marketsPremium.title
}, },
{ {
path: internalRoutes.home.subRoutes?.watchlist.path, path: internalRoutes.home.subRoutes.watchlist.path,
component: GfHomeWatchlistComponent, component: GfHomeWatchlistComponent,
title: internalRoutes.home.subRoutes?.watchlist.title title: internalRoutes.home.subRoutes.watchlist.title
} }
], ],
component: GfHomePageComponent, component: GfHomePageComponent,

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

@ -65,7 +65,7 @@ export class GfActivitiesPageComponent implements OnInit {
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public totalItems: number; public totalItems: number | undefined;
public user: User; public user: User;
public constructor( public constructor(
@ -135,8 +135,11 @@ export class GfActivitiesPageComponent implements OnInit {
} }
public fetchActivities() { public fetchActivities() {
const dateRange = this.user?.settings?.dateRange; // Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined; const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService this.dataService
@ -200,6 +203,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -214,6 +219,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -289,6 +296,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -316,6 +325,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
}); });
} }
@ -365,6 +376,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe({ .subscribe({
next: () => { next: () => {
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
} }
}); });
} }
@ -422,6 +435,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe(); .subscribe();
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck();
} }
}); });
} }

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

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfActivitiesPageComponent, component: GfActivitiesPageComponent,
path: '', path: '',
title: internalRoutes.portfolio.subRoutes?.activities.title title: internalRoutes.portfolio.subRoutes.activities.title
} }
]; ];

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

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
@ -89,11 +90,11 @@ export class GfCreateOrUpdateActivityDialogComponent {
public assetSubClassOptions: AssetClassSelectorOption[] = []; public assetSubClassOptions: AssetClassSelectorOption[] = [];
public currencies: string[] = []; public currencies: string[] = [];
public currencyOfAssetProfile: string; public currencyOfAssetProfile: string | undefined;
public currentMarketPrice = null; public currentMarketPrice: number | null = null;
public defaultDateFormat: string; public defaultDateFormat: string;
public defaultLookupItems: LookupItem[] = []; public defaultLookupItems: LookupItem[] = [];
public hasPermissionToCreateOwnTag: boolean; public hasPermissionToCreateOwnTag: boolean | undefined;
public isLoading = false; public isLoading = false;
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update'; public mode: 'create' | 'update';
@ -106,7 +107,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<Date, string>,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>, public dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -121,7 +122,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag); hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user.settings.locale ?? defaultLocale;
this.mode = this.data.activity?.id ? 'update' : 'create'; this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
@ -136,34 +137,25 @@ export class GfCreateOrUpdateActivityDialogComponent {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.defaultLookupItems = holdings this.defaultLookupItems = holdings
.filter(({ assetSubClass }) => { .filter(({ assetProfile }) => {
return !['CASH'].includes(assetSubClass); return !['CASH'].includes(assetProfile.assetSubClass);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.name?.localeCompare(b.name);
}) })
.map( .map(({ assetProfile }) => {
({ return {
assetClass, assetClass: assetProfile.assetClass,
assetSubClass, assetSubClass: assetProfile.assetSubClass,
currency, currency: assetProfile.currency ?? '',
dataSource, dataProviderInfo: {
name, isPremium: false
symbol },
}) => { dataSource: assetProfile.dataSource,
return { name: assetProfile.name ?? '',
assetClass, symbol: assetProfile.symbol
assetSubClass, };
currency, });
dataSource,
name,
symbol,
dataProviderInfo: {
isPremium: false
}
};
}
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -242,25 +234,25 @@ export class GfCreateOrUpdateActivityDialogComponent {
.subscribe(async () => { .subscribe(async () => {
if ( if (
['BUY', 'FEE', 'VALUABLE'].includes( ['BUY', 'FEE', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
) { ) {
this.total = this.total =
this.activityForm.get('quantity').value * this.activityForm.get('quantity')?.value *
this.activityForm.get('unitPrice').value + this.activityForm.get('unitPrice')?.value +
(this.activityForm.get('fee').value ?? 0); (this.activityForm.get('fee')?.value ?? 0);
} else { } else {
this.total = this.total =
this.activityForm.get('quantity').value * this.activityForm.get('quantity')?.value *
this.activityForm.get('unitPrice').value - this.activityForm.get('unitPrice')?.value -
(this.activityForm.get('fee').value ?? 0); (this.activityForm.get('fee')?.value ?? 0);
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('accountId').valueChanges.subscribe((accountId) => { this.activityForm.get('accountId')?.valueChanges.subscribe((accountId) => {
const type = this.activityForm.get('type').value; const type = this.activityForm.get('type')?.value;
if (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(type)) { if (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(type)) {
const currency = const currency =
@ -268,15 +260,15 @@ export class GfCreateOrUpdateActivityDialogComponent {
return id === accountId; return id === accountId;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) { if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.get('accountId').value) { if (this.activityForm.get('accountId')?.value) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
} }
} }
@ -284,7 +276,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm this.activityForm
.get('assetClass') .get('assetClass')
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => { .subscribe((assetClass) => {
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
@ -297,28 +289,28 @@ export class GfCreateOrUpdateActivityDialogComponent {
}) })
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
this.activityForm.get('assetSubClass').setValue(null); this.activityForm.get('assetSubClass')?.setValue(null);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('date').valueChanges.subscribe(() => { this.activityForm.get('date')?.valueChanges.subscribe(() => {
if (isToday(this.activityForm.get('date').value)) { if (isToday(this.activityForm.get('date')?.value)) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('searchSymbol').valueChanges.subscribe(() => { this.activityForm.get('searchSymbol')?.valueChanges.subscribe(() => {
if (this.activityForm.get('searchSymbol').invalid) { if (this.activityForm.get('searchSymbol')?.invalid) {
this.data.activity.SymbolProfile = null; this.data.activity.SymbolProfile = null;
} else if ( } else if (
['BUY', 'DIVIDEND', 'SELL'].includes( ['BUY', 'DIVIDEND', 'SELL'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
) { ) {
this.updateAssetProfile(); this.updateAssetProfile();
@ -327,7 +319,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('tags').valueChanges.subscribe((tags: Tag[]) => { this.activityForm.get('tags')?.valueChanges.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
}); });
@ -337,7 +329,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
.postTag({ ...newTag, userId: this.data.user.id }) .postTag({ ...newTag, userId: this.data.user.id })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag) => { .subscribe((tag) => {
this.activityForm.get('tags').setValue( this.activityForm.get('tags')?.setValue(
tags.map((currentTag) => { tags.map((currentTag) => {
if (currentTag.id === undefined) { if (currentTag.id === undefined) {
return tag; return tag;
@ -357,106 +349,106 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm this.activityForm
.get('type') .get('type')
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((type: ActivityType) => { .subscribe((type: ActivityType) => {
if ( if (
type === 'VALUABLE' || type === 'VALUABLE' ||
(this.activityForm.get('dataSource').value === 'MANUAL' && (this.activityForm.get('dataSource')?.value === 'MANUAL' &&
type === 'BUY') type === 'BUY')
) { ) {
const currency = const currency =
this.data.accounts.find(({ id }) => { this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value; return id === this.activityForm.get('accountId')?.value;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
this.activityForm.get('fee').setValue(0); this.activityForm.get('fee')?.setValue(0);
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name')?.setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
if (type === 'VALUABLE') { if (type === 'VALUABLE') {
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity')?.setValue(1);
} }
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
const currency = const currency =
this.data.accounts.find(({ id }) => { this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value; return id === this.activityForm.get('accountId')?.value;
})?.currency ?? this.data.user.settings.baseCurrency; })?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
if (['INTEREST', 'LIABILITY'].includes(type)) { if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('fee').setValue(0); this.activityForm.get('fee')?.setValue(0);
} }
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name')?.setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('quantity').setValue(0); this.activityForm.get('quantity')?.setValue(0);
} else if (['INTEREST', 'LIABILITY'].includes(type)) { } else if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity')?.setValue(1);
} }
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('unitPrice').setValue(0); this.activityForm.get('unitPrice')?.setValue(0);
} }
if ( if (
['FEE', 'INTEREST'].includes(type) && ['FEE', 'INTEREST'].includes(type) &&
this.activityForm.get('accountId').value this.activityForm.get('accountId')?.value
) { ) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} else { } else {
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance')?.setValue(false);
} }
} else { } else {
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
.setValidators(Validators.required); ?.setValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource')?.updateValueAndValidity();
this.activityForm.get('name').removeValidators(Validators.required); this.activityForm.get('name')?.removeValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name')?.updateValueAndValidity();
this.activityForm this.activityForm
.get('searchSymbol') .get('searchSymbol')
.setValidators(Validators.required); ?.setValidators(Validators.required);
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance')?.enable();
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.get('type').setValue(this.data.activity?.type); this.activityForm.get('type')?.setValue(this.data.activity?.type);
if (this.data.activity?.id) { if (this.data.activity?.id) {
this.activityForm.get('searchSymbol').disable(); this.activityForm.get('searchSymbol')?.disable();
this.activityForm.get('type').disable(); this.activityForm.get('type')?.disable();
} }
if (this.data.activity?.SymbolProfile?.symbol) { if (this.data.activity?.SymbolProfile?.symbol) {
@ -476,7 +468,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
public applyCurrentMarketPrice() { public applyCurrentMarketPrice() {
this.activityForm.patchValue({ this.activityForm.patchValue({
currencyOfUnitPrice: this.activityForm.get('currency').value, currencyOfUnitPrice: this.activityForm.get('currency')?.value,
unitPrice: this.currentMarketPrice unitPrice: this.currentMarketPrice
}); });
} }
@ -495,42 +487,42 @@ export class GfCreateOrUpdateActivityDialogComponent {
public async onSubmit() { public async onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.get('accountId').value, accountId: this.activityForm.get('accountId')?.value,
assetClass: this.activityForm.get('assetClass').value, assetClass: this.activityForm.get('assetClass')?.value,
assetSubClass: this.activityForm.get('assetSubClass').value, assetSubClass: this.activityForm.get('assetSubClass')?.value,
comment: this.activityForm.get('comment').value || null, comment: this.activityForm.get('comment')?.value || null,
currency: this.activityForm.get('currency').value, currency: this.activityForm.get('currency')?.value,
customCurrency: this.activityForm.get('currencyOfUnitPrice').value, customCurrency: this.activityForm.get('currencyOfUnitPrice')?.value,
dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
? 'MANUAL' ? 'MANUAL'
: this.activityForm.get('dataSource').value, : this.activityForm.get('dataSource')?.value,
date: this.activityForm.get('date').value, date: this.activityForm.get('date')?.value,
fee: this.activityForm.get('fee').value, fee: this.activityForm.get('fee')?.value,
quantity: this.activityForm.get('quantity').value, quantity: this.activityForm.get('quantity')?.value,
symbol: symbol:
(['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(
this.activityForm.get('type').value this.activityForm.get('type')?.value
) )
? undefined ? undefined
: this.activityForm.get('searchSymbol')?.value?.symbol) ?? : this.activityForm.get('searchSymbol')?.value?.symbol) ??
this.activityForm.get('name')?.value, this.activityForm.get('name')?.value,
tags: this.activityForm.get('tags').value?.map(({ id }) => { tags: this.activityForm.get('tags')?.value?.map(({ id }) => {
return id; return id;
}), }),
type: type:
this.activityForm.get('type').value === 'VALUABLE' this.activityForm.get('type')?.value === 'VALUABLE'
? 'BUY' ? 'BUY'
: this.activityForm.get('type').value, : this.activityForm.get('type')?.value,
unitPrice: this.activityForm.get('unitPrice').value unitPrice: this.activityForm.get('unitPrice')?.value
}; };
try { try {
if (this.mode === 'create') { if (this.mode === 'create') {
activity.updateAccountBalance = this.activityForm.get( activity.updateAccountBalance = this.activityForm.get(
'updateAccountBalance' 'updateAccountBalance'
).value; )?.value;
await validateObjectForForm({ await validateObjectForForm({
classDto: CreateOrderDto, classDto: CreateOrderDto,
@ -563,8 +555,8 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: this.activityForm.get('searchSymbol').value.dataSource, dataSource: this.activityForm.get('searchSymbol')?.value.dataSource,
symbol: this.activityForm.get('searchSymbol').value.symbol symbol: this.activityForm.get('searchSymbol')?.value.symbol
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
@ -580,9 +572,9 @@ export class GfCreateOrUpdateActivityDialogComponent {
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
if (this.mode === 'create') { if (this.mode === 'create') {
this.activityForm.get('currency').setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
this.activityForm.get('dataSource').setValue(dataSource); this.activityForm.get('dataSource')?.setValue(dataSource);
} }
this.currencyOfAssetProfile = currency; this.currencyOfAssetProfile = currency;

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

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

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

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

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

@ -159,10 +159,9 @@ export class GfAllocationsPageComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.worldMapChartFormat = this.worldMapChartFormat = this.showValuesInPercentage()
this.hasImpersonationId || this.user.settings.isRestrictedView ? `{0}%`
? `{0}%` : `{0} ${this.user?.settings?.baseCurrency}`;
: `{0} ${this.user?.settings?.baseCurrency}`;
this.isLoading = true; this.isLoading = true;
@ -310,7 +309,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.accounts)) { ] of Object.entries(this.portfolioDetails.accounts)) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = valueInPercentage; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
@ -328,7 +327,7 @@ export class GfAllocationsPageComponent implements OnInit {
)) { )) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = position.allocationInPercentage; value = position.allocationInPercentage;
} else { } else {
value = position.valueInBaseCurrency; value = position.valueInBaseCurrency;
@ -491,7 +490,7 @@ export class GfAllocationsPageComponent implements OnInit {
] of Object.entries(this.portfolioDetails.platforms)) { ] of Object.entries(this.portfolioDetails.platforms)) {
let value = 0; let value = 0;
if (this.hasImpersonationId) { if (this.showValuesInPercentage()) {
value = valueInPercentage; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
@ -506,7 +505,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.topHoldings = Object.values(this.topHoldingsMap) this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }) => { .map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) { if (this.showValuesInPercentage()) {
return { return {
name, name,
allocationInPercentage: value, allocationInPercentage: value,
@ -597,4 +596,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
public showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
} }

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

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

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

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

2
apps/client/src/app/pages/portfolio/analysis/analysis-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfAnalysisPageComponent, component: GfAnalysisPageComponent,
path: '', path: '',
title: internalRoutes.portfolio.subRoutes?.analysis.title title: internalRoutes.portfolio.subRoutes.analysis.title
} }
]; ];

8
apps/client/src/app/pages/portfolio/portfolio-page.routes.ts

@ -15,22 +15,22 @@ export const routes: Routes = [
import('./analysis/analysis-page.routes').then((m) => m.routes) import('./analysis/analysis-page.routes').then((m) => m.routes)
}, },
{ {
path: internalRoutes.portfolio.subRoutes?.activities.path, path: internalRoutes.portfolio.subRoutes.activities.path,
loadChildren: () => loadChildren: () =>
import('./activities/activities-page.routes').then((m) => m.routes) import('./activities/activities-page.routes').then((m) => m.routes)
}, },
{ {
path: internalRoutes.portfolio.subRoutes?.allocations.path, path: internalRoutes.portfolio.subRoutes.allocations.path,
loadChildren: () => loadChildren: () =>
import('./allocations/allocations-page.routes').then((m) => m.routes) import('./allocations/allocations-page.routes').then((m) => m.routes)
}, },
{ {
path: internalRoutes.portfolio.subRoutes?.fire.path, path: internalRoutes.portfolio.subRoutes.fire.path,
loadChildren: () => loadChildren: () =>
import('./fire/fire-page.routes').then((m) => m.routes) import('./fire/fire-page.routes').then((m) => m.routes)
}, },
{ {
path: internalRoutes.portfolio.subRoutes?.xRay.path, path: internalRoutes.portfolio.subRoutes.xRay.path,
loadChildren: () => loadChildren: () =>
import('./x-ray/x-ray-page.routes').then((m) => m.routes) import('./x-ray/x-ray-page.routes').then((m) => m.routes)
} }

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

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

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

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

2
apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts

@ -16,7 +16,7 @@ export class ResourcesGlossaryPageComponent implements OnInit {
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public info: InfoItem; public info: InfoItem;
public routerLinkResourcesPersonalFinanceTools = public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes?.personalFinanceTools.routerLink; publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public constructor(private dataService: DataService) { public constructor(private dataService: DataService) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();

2
apps/client/src/app/pages/resources/glossary/resources-glossary.routes.ts

@ -8,6 +8,6 @@ export const routes: Routes = [
{ {
component: ResourcesGlossaryPageComponent, component: ResourcesGlossaryPageComponent,
path: '', path: '',
title: publicRoutes.resources.subRoutes?.glossary.title title: publicRoutes.resources.subRoutes.glossary.title
} }
]; ];

2
apps/client/src/app/pages/resources/guides/resources-guides.routes.ts

@ -8,6 +8,6 @@ export const routes: Routes = [
{ {
component: ResourcesGuidesComponent, component: ResourcesGuidesComponent,
path: '', path: '',
title: publicRoutes.resources.subRoutes?.guides.title title: publicRoutes.resources.subRoutes.guides.title
} }
]; ];

2
apps/client/src/app/pages/resources/markets/resources-markets.routes.ts

@ -8,6 +8,6 @@ export const routes: Routes = [
{ {
component: ResourcesMarketsComponent, component: ResourcesMarketsComponent,
path: '', path: '',
title: publicRoutes.resources.subRoutes?.markets.title title: publicRoutes.resources.subRoutes.markets.title
} }
]; ];

12
apps/client/src/app/pages/resources/overview/resources-overview.component.ts

@ -20,20 +20,20 @@ export class ResourcesOverviewComponent {
{ {
description: description:
'Explore our guides to help you get started with investing and managing your finances.', 'Explore our guides to help you get started with investing and managing your finances.',
routerLink: publicRoutes.resources.subRoutes?.guides.routerLink, routerLink: publicRoutes.resources.subRoutes.guides.routerLink,
title: publicRoutes.resources.subRoutes?.guides.title title: publicRoutes.resources.subRoutes.guides.title
}, },
{ {
description: description:
'Access various market resources and tools to stay informed about financial markets.', 'Access various market resources and tools to stay informed about financial markets.',
routerLink: publicRoutes.resources.subRoutes?.markets.routerLink, routerLink: publicRoutes.resources.subRoutes.markets.routerLink,
title: publicRoutes.resources.subRoutes?.markets.title title: publicRoutes.resources.subRoutes.markets.title
}, },
{ {
description: description:
'Learn key financial terms and concepts in our comprehensive glossary.', 'Learn key financial terms and concepts in our comprehensive glossary.',
routerLink: publicRoutes.resources.subRoutes?.glossary.routerLink, routerLink: publicRoutes.resources.subRoutes.glossary.routerLink,
title: publicRoutes.resources.subRoutes?.glossary.title title: publicRoutes.resources.subRoutes.glossary.title
} }
]; ];
} }

6
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.routes.ts

@ -11,7 +11,7 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: PersonalFinanceToolsPageComponent, component: PersonalFinanceToolsPageComponent,
path: '', path: '',
title: publicRoutes.resources.subRoutes?.personalFinanceTools.title title: publicRoutes.resources.subRoutes.personalFinanceTools.title
}, },
...personalFinanceTools.map(({ alias, key, name }) => { ...personalFinanceTools.map(({ alias, key, name }) => {
return { return {
@ -23,8 +23,8 @@ export const routes: Routes = [
return GfProductPageComponent; return GfProductPageComponent;
} }
), ),
path: `${publicRoutes.resources.subRoutes?.personalFinanceTools.subRoutes?.product.path}-${alias ?? key}`, path: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path}-${alias ?? key}`,
title: `${publicRoutes.resources.subRoutes?.personalFinanceTools.subRoutes?.product.title} ${name}` title: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.title} ${name}`
}; };
}) })
]; ];

8
apps/client/src/app/pages/resources/resources-page.routes.ts

@ -16,22 +16,22 @@ export const routes: Routes = [
import('./overview/resources-overview.routes').then((m) => m.routes) import('./overview/resources-overview.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.resources.subRoutes?.glossary.path, path: publicRoutes.resources.subRoutes.glossary.path,
loadChildren: () => loadChildren: () =>
import('./glossary/resources-glossary.routes').then((m) => m.routes) import('./glossary/resources-glossary.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.resources.subRoutes?.guides.path, path: publicRoutes.resources.subRoutes.guides.path,
loadChildren: () => loadChildren: () =>
import('./guides/resources-guides.routes').then((m) => m.routes) import('./guides/resources-guides.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.resources.subRoutes?.markets.path, path: publicRoutes.resources.subRoutes.markets.path,
loadChildren: () => loadChildren: () =>
import('./markets/resources-markets.routes').then((m) => m.routes) import('./markets/resources-markets.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.resources.subRoutes?.personalFinanceTools.path, path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () => loadChildren: () =>
import('./personal-finance-tools/personal-finance-tools-page.routes').then( import('./personal-finance-tools/personal-finance-tools-page.routes').then(
(m) => m.routes (m) => m.routes

8
apps/client/src/app/pages/user-account/user-account-page.routes.ts

@ -18,14 +18,14 @@ export const routes: Routes = [
title: internalRoutes.account.title title: internalRoutes.account.title
}, },
{ {
path: internalRoutes.account.subRoutes?.membership.path, path: internalRoutes.account.subRoutes.membership.path,
component: GfUserAccountMembershipComponent, component: GfUserAccountMembershipComponent,
title: internalRoutes.account.subRoutes?.membership.title title: internalRoutes.account.subRoutes.membership.title
}, },
{ {
path: internalRoutes.account.subRoutes?.access.path, path: internalRoutes.account.subRoutes.access.path,
component: GfUserAccountAccessComponent, component: GfUserAccountAccessComponent,
title: internalRoutes.account.subRoutes?.access.title title: internalRoutes.account.subRoutes.access.title
} }
], ],
component: GfUserAccountPageComponent, component: GfUserAccountPageComponent,

4
apps/client/src/app/pages/zen/zen-page.routes.ts

@ -16,9 +16,9 @@ export const routes: Routes = [
component: GfHomeOverviewComponent component: GfHomeOverviewComponent
}, },
{ {
path: internalRoutes.zen.subRoutes?.holdings.path, path: internalRoutes.zen.subRoutes.holdings.path,
component: GfHomeHoldingsComponent, component: GfHomeHoldingsComponent,
title: internalRoutes.home.subRoutes?.holdings.title title: internalRoutes.home.subRoutes.holdings.title
} }
], ],
component: GfZenPageComponent, component: GfZenPageComponent,

11
apps/client/src/app/services/user/user.service.ts

@ -3,14 +3,15 @@ import { Filter, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { DestroyRef, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ObservableStore } from '@codewithdan/observable-store'; import { ObservableStore } from '@codewithdan/observable-store';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, Subject, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { throwError } from 'rxjs'; import { throwError } from 'rxjs';
import { catchError, map, takeUntil } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces'; import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces';
import { GfSubscriptionInterstitialDialogComponent } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component'; import { GfSubscriptionInterstitialDialogComponent } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
@ -22,9 +23,9 @@ import { UserStoreState } from './user-store.state';
}) })
export class UserService extends ObservableStore<UserStoreState> { export class UserService extends ObservableStore<UserStoreState> {
private deviceType: string; private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private http: HttpClient, private http: HttpClient,
@ -163,7 +164,7 @@ export class UserService extends ObservableStore<UserStoreState> {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }

12
apps/client/src/assets/terms-of-service.md

@ -51,8 +51,18 @@ This Agreement constitutes the entire agreement between the User and LICENSEE re
LICENSEE reserves the right to modify this Agreement at any time. Users are encouraged to review this Agreement periodically for updates. Continued use of the Service after changes to this Agreement constitutes acceptance of the modified terms. LICENSEE reserves the right to modify this Agreement at any time. Users are encouraged to review this Agreement periodically for updates. Continued use of the Service after changes to this Agreement constitutes acceptance of the modified terms.
## Paid Plans
LICENSEE offers paid plans (such as [Ghostfolio Premium](https://ghostfol.io/en/pricing)) for a fixed term as specified at the time of purchase. Paid plans do not renew automatically.
LICENSEE may change the features or pricing of paid plans. For any existing paid plan, if a material feature is removed, LICENSEE may, acting in good faith, offer a pro-rata refund for the unused portion of the term upon the User’s request.
## Refund Policy
LICENSEE may offer a free trial of paid plans. Users are encouraged to use any available free trial to evaluate the Service before purchasing. While all purchases are final, a refund may be requested within fourteen (14) days of the purchase date by contacting LICENSEE. Requests after this period will not be honored.
By accessing or using the Service, or downloading data provided by the Service, the User acknowledges that they have read, understood, and agreed to be bound by this Terms of Service Agreement. By accessing or using the Service, or downloading data provided by the Service, the User acknowledges that they have read, understood, and agreed to be bound by this Terms of Service Agreement.
For any questions or concerns regarding this Agreement, please contact us [here](https://ghostfol.io/en/about). For any questions or concerns regarding this Agreement, please contact us [here](https://ghostfol.io/en/about).
Date of Last Revision: March 29, 2025 Date of Last Revision: April 6, 2026

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

Loading…
Cancel
Save