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. 20
      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. 167
      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. 295
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  45. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  46. 88
      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. 52
      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. 66
      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. 226
      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. 15
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  84. 30
      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
/.angular/cache
.claude/settings.local.json
.claude/worktrees
.cursor/rules/nx-rules.mdc
.env
.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.
## 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 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
@ -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 activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added

2
README.md

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 pageSize = DEFAULT_PAGE_SIZE;
public routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes?.users.routerLink;
internalRoutes.adminControl.subRoutes.users.routerLink;
public totalItems = 0;
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 routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes?.changelog.routerLink;
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes?.license.routerLink;
publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes?.privacyPolicy.routerLink;
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes?.termsOfService.routerLink;
publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.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"
[colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
@ -298,7 +298,7 @@
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true"
[isInPercentage]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"

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

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

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

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

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

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

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

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

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

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

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

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

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) {
console.log('Could not find access.');
return;
}

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

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

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)
},
{
path: publicRoutes.about.subRoutes?.changelog.path,
path: publicRoutes.about.subRoutes.changelog.path,
loadChildren: () =>
import('./changelog/changelog-page.routes').then((m) => m.routes)
},
{
path: publicRoutes.about.subRoutes?.license.path,
path: publicRoutes.about.subRoutes.license.path,
loadChildren: () =>
import('./license/license-page.routes').then((m) => m.routes)
},
{
path: publicRoutes.about.subRoutes?.ossFriends.path,
path: publicRoutes.about.subRoutes.ossFriends.path,
loadChildren: () =>
import('./oss-friends/oss-friends-page.routes').then((m) => m.routes)
},
{
path: publicRoutes.about.subRoutes?.privacyPolicy.path,
path: publicRoutes.about.subRoutes.privacyPolicy.path,
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.routes').then(
(m) => m.routes
)
},
{
path: publicRoutes.about.subRoutes?.termsOfService.path,
path: publicRoutes.about.subRoutes.termsOfService.path,
loadChildren: () =>
import('./terms-of-service/terms-of-service-page.routes').then(
(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],
component: GfChangelogPageComponent,
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],
component: GfLicensePageComponent,
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],
component: GfOpenSourceSoftwareFriendsPageComponent,
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],
component: GfPrivacyPolicyPageComponent,
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],
component: GfTermsOfServicePageComponent,
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 { CommonModule, NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
AbstractControl,
FormBuilder,
@ -51,17 +51,17 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html'
})
export class GfCreateOrUpdateAccountDialogComponent {
public accountForm: FormGroup;
public currencies: string[] = [];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[] = [];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccountDialogComponent>,
private formBuilder: FormBuilder
) {}
protected accountForm: FormGroup;
protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined;
protected platforms: Platform[] = [];
protected readonly data =
inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
public ngOnInit() {
const { currencies } = this.dataService.fetchInfo();
@ -93,18 +93,18 @@ export class GfCreateOrUpdateAccountDialogComponent {
this.filteredPlatforms = this.accountForm
.get('platformId')
.valueChanges.pipe(
?.valueChanges.pipe(
startWith(''),
map((value) => {
map((value: Platform | string) => {
const name = typeof value === 'string' ? value : value?.name;
return name ? this.filter(name as string) : this.platforms.slice();
return name ? this.filter(name) : this.platforms.slice();
})
);
});
}
public autoCompleteCheck() {
const inputValue = this.accountForm.get('platformId').value;
protected autoCompleteCheck() {
const inputValue = this.accountForm.get('platformId')?.value;
if (typeof inputValue === 'string') {
const matchingEntry = this.platforms.find(({ name }) => {
@ -112,28 +112,28 @@ export class GfCreateOrUpdateAccountDialogComponent {
});
if (matchingEntry) {
this.accountForm.get('platformId').setValue(matchingEntry);
this.accountForm.get('platformId')?.setValue(matchingEntry);
}
}
}
public displayFn(platform: Platform) {
protected displayFn(platform: Platform) {
return platform?.name ?? '';
}
public onCancel() {
protected onCancel() {
this.dialogRef.close();
}
public async onSubmit() {
protected async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.get('balance').value,
comment: this.accountForm.get('comment').value || null,
currency: this.accountForm.get('currency').value,
id: this.accountForm.get('accountId').value,
isExcluded: this.accountForm.get('isExcluded').value,
name: this.accountForm.get('name').value,
platformId: this.accountForm.get('platformId').value?.id || null
balance: this.accountForm.get('balance')?.value,
comment: this.accountForm.get('comment')?.value || null,
currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id || null
};
try {
@ -177,7 +177,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
const filterValue = value.toLowerCase();
return this.platforms.filter(({ name }) => {
return name.toLowerCase().startsWith(filterValue);
return name?.toLowerCase().startsWith(filterValue);
});
}
}

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

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

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

@ -20,29 +20,29 @@ export const routes: Routes = [
title: internalRoutes.adminControl.title
},
{
path: internalRoutes.adminControl.subRoutes?.jobs.path,
path: internalRoutes.adminControl.subRoutes.jobs.path,
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,
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,
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,
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,
title: internalRoutes.adminControl.subRoutes?.users.title
title: internalRoutes.adminControl.subRoutes.users.title
}
],
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 {
public routerLinkAboutOssFriends =
publicRoutes.about.subRoutes?.ossFriends.routerLink;
publicRoutes.about.subRoutes.ossFriends.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)
},
{
path: publicRoutes.faq.subRoutes?.saas.path,
path: publicRoutes.faq.subRoutes.saas.path,
loadChildren: () =>
import('./saas/saas-page.routes').then((m) => m.routes)
},
{
path: publicRoutes.faq.subRoutes?.selfHosting.path,
path: publicRoutes.faq.subRoutes.selfHosting.path,
loadChildren: () =>
import('./self-hosting/self-hosting-page.routes').then(
(m) => m.routes

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

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

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

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

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

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard],
component: GfSaasPageComponent,
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],
component: GfSelfHostingPageComponent,
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
},
{
path: internalRoutes.home.subRoutes?.holdings.path,
path: internalRoutes.home.subRoutes.holdings.path,
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,
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,
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,
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,
title: internalRoutes.home.subRoutes?.watchlist.title
title: internalRoutes.home.subRoutes.watchlist.title
}
],
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 sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public totalItems: number;
public totalItems: number | undefined;
public user: User;
public constructor(
@ -135,8 +135,11 @@ export class GfActivitiesPageComponent implements OnInit {
}
public fetchActivities() {
const dateRange = this.user?.settings?.dateRange;
// Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService
@ -200,6 +203,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
});
}
@ -214,6 +219,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
});
}
@ -289,6 +296,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
});
}
@ -316,6 +325,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
});
}
@ -365,6 +376,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe({
next: () => {
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
@ -422,6 +435,8 @@ export class GfActivitiesPageComponent implements OnInit {
.subscribe();
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],
component: GfActivitiesPageComponent,
path: '',
title: internalRoutes.portfolio.subRoutes?.activities.title
title: internalRoutes.portfolio.subRoutes.activities.title
}
];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 info: InfoItem;
public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes?.personalFinanceTools.routerLink;
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public constructor(private dataService: DataService) {
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,
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,
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,
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:
'Explore our guides to help you get started with investing and managing your finances.',
routerLink: publicRoutes.resources.subRoutes?.guides.routerLink,
title: publicRoutes.resources.subRoutes?.guides.title
routerLink: publicRoutes.resources.subRoutes.guides.routerLink,
title: publicRoutes.resources.subRoutes.guides.title
},
{
description:
'Access various market resources and tools to stay informed about financial markets.',
routerLink: publicRoutes.resources.subRoutes?.markets.routerLink,
title: publicRoutes.resources.subRoutes?.markets.title
routerLink: publicRoutes.resources.subRoutes.markets.routerLink,
title: publicRoutes.resources.subRoutes.markets.title
},
{
description:
'Learn key financial terms and concepts in our comprehensive glossary.',
routerLink: publicRoutes.resources.subRoutes?.glossary.routerLink,
title: publicRoutes.resources.subRoutes?.glossary.title
routerLink: publicRoutes.resources.subRoutes.glossary.routerLink,
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],
component: PersonalFinanceToolsPageComponent,
path: '',
title: publicRoutes.resources.subRoutes?.personalFinanceTools.title
title: publicRoutes.resources.subRoutes.personalFinanceTools.title
},
...personalFinanceTools.map(({ alias, key, name }) => {
return {
@ -23,8 +23,8 @@ export const routes: Routes = [
return GfProductPageComponent;
}
),
path: `${publicRoutes.resources.subRoutes?.personalFinanceTools.subRoutes?.product.path}-${alias ?? key}`,
title: `${publicRoutes.resources.subRoutes?.personalFinanceTools.subRoutes?.product.title} ${name}`
path: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path}-${alias ?? key}`,
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)
},
{
path: publicRoutes.resources.subRoutes?.glossary.path,
path: publicRoutes.resources.subRoutes.glossary.path,
loadChildren: () =>
import('./glossary/resources-glossary.routes').then((m) => m.routes)
},
{
path: publicRoutes.resources.subRoutes?.guides.path,
path: publicRoutes.resources.subRoutes.guides.path,
loadChildren: () =>
import('./guides/resources-guides.routes').then((m) => m.routes)
},
{
path: publicRoutes.resources.subRoutes?.markets.path,
path: publicRoutes.resources.subRoutes.markets.path,
loadChildren: () =>
import('./markets/resources-markets.routes').then((m) => m.routes)
},
{
path: publicRoutes.resources.subRoutes?.personalFinanceTools.path,
path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () =>
import('./personal-finance-tools/personal-finance-tools-page.routes').then(
(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
},
{
path: internalRoutes.account.subRoutes?.membership.path,
path: internalRoutes.account.subRoutes.membership.path,
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,
title: internalRoutes.account.subRoutes?.access.title
title: internalRoutes.account.subRoutes.access.title
}
],
component: GfUserAccountPageComponent,

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

@ -16,9 +16,9 @@ export const routes: Routes = [
component: GfHomeOverviewComponent
},
{
path: internalRoutes.zen.subRoutes?.holdings.path,
path: internalRoutes.zen.subRoutes.holdings.path,
component: GfHomeHoldingsComponent,
title: internalRoutes.home.subRoutes?.holdings.title
title: internalRoutes.home.subRoutes.holdings.title
}
],
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 { 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 { ObservableStore } from '@codewithdan/observable-store';
import { parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, Subject, of } from 'rxjs';
import { Observable, of } 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 { 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> {
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private http: HttpClient,
@ -163,7 +164,7 @@ export class UserService extends ObservableStore<UserStoreState> {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.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.
## 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.
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