Browse Source

Merge branch 'main' into task/improve-asset-profile-dialog-routing

pull/6309/merge^2
Thomas Kaul 2 months ago
committed by GitHub
parent
commit
67751614d4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 50
      CHANGELOG.md
  2. 4
      README.md
  3. 6
      apps/api/src/app/account/account.controller.ts
  4. 6
      apps/api/src/app/account/account.service.ts
  5. 1
      apps/api/src/app/admin/admin.service.ts
  6. 7
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  7. 6
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  8. 14
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  9. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  10. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  11. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  12. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  13. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  14. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  15. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  16. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  17. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  18. 190
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  19. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  20. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  21. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  22. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  23. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  24. 11
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  25. 3
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  26. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  27. 8
      apps/api/src/app/portfolio/portfolio.service.ts
  28. 3
      apps/api/src/app/subscription/subscription.service.ts
  29. 23
      apps/api/src/app/user/user.controller.ts
  30. 4
      apps/api/src/app/user/user.module.ts
  31. 51
      apps/api/src/app/user/user.service.ts
  32. 155
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  33. 139
      apps/api/src/helper/object.helper.spec.ts
  34. 69
      apps/api/src/helper/object.helper.ts
  35. 42
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  36. 19
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  37. 34
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  38. 16
      apps/api/src/services/tag/tag.service.ts
  39. 6
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  40. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  41. 58
      apps/client/src/app/components/admin-users/admin-users.component.ts
  42. 20
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  43. 3
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  44. 2
      apps/client/src/app/components/home-holdings/home-holdings.html
  45. 50
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  46. 1
      apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts
  47. 25
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  48. 40
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  49. 8
      apps/client/src/app/pages/about/overview/about-overview-page.html
  50. 10
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  51. 3
      apps/client/src/app/pages/accounts/accounts-page.html
  52. 1
      apps/client/src/app/pages/public/public-page.html
  53. 4
      libs/common/src/lib/calculation-helper.ts
  54. 55
      libs/common/src/lib/chart-helper.ts
  55. 52
      libs/common/src/lib/config.ts
  56. 2
      libs/common/src/lib/interfaces/activities.interface.ts
  57. 4
      libs/common/src/lib/interfaces/admin-data.interface.ts
  58. 4
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  59. 3
      libs/common/src/lib/interfaces/responses/accounts-response.interface.ts
  60. 3
      libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts
  61. 3
      libs/common/src/lib/models/timeline-position.ts
  62. 4
      libs/common/src/lib/types/account-with-value.type.ts
  63. 46
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  64. 23
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  65. 165
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  66. 36
      libs/ui/src/lib/activities-table/activities-table.component.html
  67. 26
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  68. 138
      libs/ui/src/lib/activities-table/activities-table.component.ts
  69. 29
      libs/ui/src/lib/chart/chart.registry.ts
  70. 1
      libs/ui/src/lib/chart/index.ts
  71. 18
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  72. 38
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  73. 4
      libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts
  74. 103
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  75. 58
      libs/ui/src/lib/line-chart/line-chart.component.ts
  76. 51
      libs/ui/src/lib/mocks/holdings.ts
  77. 15
      libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts
  78. 3
      libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts
  79. 27
      libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts
  80. 4
      libs/ui/src/lib/notifications/confirmation-dialog/interfaces/interfaces.ts
  81. 30
      libs/ui/src/lib/notifications/notification.service.ts
  82. 7
      libs/ui/src/lib/notifications/prompt-dialog/interfaces/interfaces.ts
  83. 37
      libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts
  84. 56
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  85. 16
      libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts
  86. 49
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  87. 59
      libs/ui/src/lib/value/value.component.ts
  88. 634
      package-lock.json
  89. 34
      package.json
  90. 58
      test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json

50
CHANGELOG.md

@ -7,15 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Improved the routing of the asset profile dialog in the market data section of the admin control panel
## 2.238.0 - 2026-02-12
### Changed
- Upgraded `ngx-skeleton-loader` from version `11.3.0` to `12.0.0`
- Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0`
### Fixed
- Fixed a performance calculation issue by resetting tracking variables when a holding is fully closed
- Fixed an issue in the annualized performance calculation
- Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day)
## 2.237.0 - 2026-02-08
### Changed
- Removed the deprecated `transactionCount` in the portfolio calculator and service
- Refreshed the cryptocurrencies list
- Upgraded `Nx` from version `22.4.1` to `22.4.5`
### Fixed
- Fixed the accounts of the assistant for the impersonation mode
- Fixed the tags of the assistant for the impersonation mode
## 2.236.0 - 2026-02-05
### Changed
- Removed the deprecated `transactionCount` in the endpoint `GET api/v1/admin`
- Upgraded `stripe` from version `20.1.0` to `20.3.0`
### Fixed
- Fixed an exception when fetching the top holdings for ETF and mutual fund assets from _Yahoo Finance_
## 2.235.0 - 2026-02-03
### Added ### Added
- Added the ability to fetch top holdings for ETF and mutual fund assets from _Yahoo Finance_ - Added the ability to fetch top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Added support for the impersonation mode in the endpoint `GET api/v1/account/:id/balances`
- Added an action menu to the user detail dialog in the users section of the admin control panel
### Changed ### Changed
- Optimized the value redaction interceptor for the impersonation mode by introducing `fast-redact`
- Refactored `showTransactions` in favor of `showActivitiesCount` in the accounts table component
- Refactored `transactionCount` in favor of `activitiesCount` in the accounts table component
- Deprecated `transactionCount` in favor of `activitiesCount` in the endpoint `GET api/v1/admin` - Deprecated `transactionCount` in favor of `activitiesCount` in the endpoint `GET api/v1/admin`
- Removed the deprecated `firstBuyDate` in the portfolio calculator - Removed the deprecated `firstBuyDate` in the portfolio calculator
- Improved the routing of the asset profile dialog in the market data section of the admin control panel - Upgraded `yahoo-finance2` from version `3.11.2` to `3.13.0`
## 2.234.0 - 2026-01-30 ## 2.234.0 - 2026-01-30

4
README.md

@ -318,8 +318,8 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s
## Sponsors ## Sponsors
<div align="center"> <div align="center">
<a href="https://www.testmu.ai?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool"> <a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
<img alt="TestMu AI Logo" height="45" src="https://assets.testmu.ai/resources/images/logos/logo.svg" /> <img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />
</a> </a>
</div> </div>

6
apps/api/src/app/account/account.controller.ts

@ -132,12 +132,16 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: impersonationUserId || this.request.user.id
}); });
} }

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

@ -150,15 +150,15 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let activitiesCount = 0;
for (const { isDraft } of account.activities) { for (const { isDraft } of account.activities) {
if (!isDraft) { if (!isDraft) {
transactionCount += 1; activitiesCount += 1;
} }
} }
const result = { ...account, transactionCount }; const result = { ...account, activitiesCount };
delete result.activities; delete result.activities;

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

@ -186,7 +186,6 @@ export class AdminService {
dataProviders, dataProviders,
settings, settings,
userCount, userCount,
transactionCount: activitiesCount,
version: environment.version version: environment.version
}; };
} }

7
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -2,6 +2,8 @@ import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { 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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -28,7 +30,8 @@ import {
Param, Param,
Post, Post,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -86,6 +89,8 @@ export class MarketDataController {
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string

6
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,5 +1,7 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -13,7 +15,9 @@ import { MarketDataController } from './market-data.controller';
AdminModule, AdminModule,
MarketDataServiceModule, MarketDataServiceModule,
SymbolModule, SymbolModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
] ]
}) })
export class MarketDataModule {} export class MarketDataModule {}

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

@ -53,6 +53,7 @@ import {
isBefore, isBefore,
isWithinInterval, isWithinInterval,
min, min,
startOfDay,
startOfYear, startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
@ -162,8 +163,8 @@ export abstract class PortfolioCalculator {
subDays(dateOfFirstActivity, 1) subDays(dateOfFirstActivity, 1)
); );
this.endDate = endDate; this.endDate = endOfDay(endDate);
this.startDate = startDate; this.startDate = startOfDay(startDate);
this.computeTransactionPoints(); this.computeTransactionPoints();
@ -236,7 +237,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: this.endDate,
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
}); });
@ -445,7 +446,6 @@ export abstract class PortfolioCalculator {
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags, tags: item.tags,
transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity item.quantity
) )
@ -1005,8 +1005,7 @@ export abstract class PortfolioCalculator {
oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency), oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency),
includeInHoldings: oldAccumulatedSymbol.includeInHoldings, includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity, quantity: newQuantity,
tags: oldAccumulatedSymbol.tags.concat(tags), tags: oldAccumulatedSymbol.tags.concat(tags)
transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
@ -1024,8 +1023,7 @@ export abstract class PortfolioCalculator {
dividend: new Big(0), dividend: new Big(0),
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type), includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor), investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor), quantity: quantity.mul(factor)
transactionCount: 1
}; };
} }

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -178,7 +178,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154' '474.93846153846153846154'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6') valueInBaseCurrency: new Big('595.6')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -192,7 +192,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627' '285.80000000000000396627'
), ),
transactionCount: 3,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -176,7 +176,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('285.8'), timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -172,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('273.2'), timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1,
valueInBaseCurrency: new Big('297.8') valueInBaseCurrency: new Big('297.8')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -227,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7') valueInBaseCurrency: new Big('43099.7')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -194,7 +194,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79389574611155533947' '636.79389574611155533947'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356') valueInBaseCurrency: new Big('13298.425356')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -227,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7') valueInBaseCurrency: new Big('43099.7')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -276,7 +276,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'852.45231607629427792916' '852.45231607629427792916'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big(1820) valueInBaseCurrency: new Big(1820)
}); });

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -172,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854), timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],

190
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -0,0 +1,190 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with JNUG buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 4,
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2025-12-11',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4'),
feeInBaseCurrency: new Big('4'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
netPerformanceWithCurrencyEffectMap: {
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
},
marketPrice: 237.8000030517578,
marketPriceInBaseCurrency: 237.8000030517578,
quantity: new Big('0'),
symbol: 'JNUG',
tags: [],
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('4'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2025-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2025-01-01', investment: 0 }
]);
});
});
});

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -162,8 +162,7 @@ describe('PortfolioCalculator', () => {
}, },
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: []
transactionCount: 2
} }
], ],
totalFeesWithCurrencyEffect: new Big('19'), totalFeesWithCurrencyEffect: new Big('19'),

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

@ -174,7 +174,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714' '145.10285714285714285714'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8') valueInBaseCurrency: new Big('87.8')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -225,7 +225,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -146,7 +146,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('500000'), timeWeightedInvestment: new Big('500000'),
timeWeightedInvestmentWithCurrencyEffect: new Big('500000'), timeWeightedInvestmentWithCurrencyEffect: new Big('500000'),
transactionCount: 1,
valueInBaseCurrency: new Big('500000') valueInBaseCurrency: new Big('500000')
} }
], ],

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -626,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalQuantityFromBuyTransactions totalQuantityFromBuyTransactions
); );
if (totalUnits.eq(0)) {
// Reset tracking variables when position is fully closed
totalInvestmentFromBuyTransactions = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
totalQuantityFromBuyTransactions = new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
'grossPerformanceFromSells', 'grossPerformanceFromSells',

11
apps/api/src/app/portfolio/current-rate.service.mock.ts

@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'JNUG':
if (isSameDay(parseDate('2025-12-10'), date)) {
return { marketPrice: 204.5599975585938 };
} else if (isSameDay(parseDate('2025-12-17'), date)) {
return { marketPrice: 203.9700012207031 };
} else if (isSameDay(parseDate('2025-12-28'), date)) {
return { marketPrice: 237.8000030517578 };
}
return { marketPrice: 0 };
case 'MSFT': case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) { if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 }; return { marketPrice: 89.12 };

3
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts

@ -17,7 +17,4 @@ export interface TransactionPointSymbol {
skipErrors: boolean; skipErrors: boolean;
symbol: string; symbol: string;
tags?: Tag[]; tags?: Tag[];
/** @deprecated use activitiesCount instead */
transactionCount: number;
} }

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

@ -195,11 +195,13 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency', 'interestInBaseCurrency',
'items', 'items',
'liabilities', 'liabilities',
'liabilitiesInBaseCurrency',
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect', 'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',

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

@ -233,7 +233,6 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
transactionCount: activitiesCount,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency, valueInBaseCurrency,
userCurrency, userCurrency,
@ -284,7 +283,6 @@ export class PortfolioService {
let totalDividendInBaseCurrency = new Big(0); let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0); let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) { for (const account of accounts) {
activitiesCount += account.activitiesCount; activitiesCount += account.activitiesCount;
@ -301,8 +299,6 @@ export class PortfolioService {
totalValueInBaseCurrency = totalValueInBaseCurrency.plus( totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency account.valueInBaseCurrency
); );
transactionCount += account.transactionCount;
} }
for (const account of accounts) { for (const account of accounts) {
@ -317,7 +313,6 @@ export class PortfolioService {
return { return {
accounts, accounts,
activitiesCount, activitiesCount,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(), totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(), totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
@ -591,7 +586,6 @@ export class PortfolioService {
quantity, quantity,
symbol, symbol,
tags, tags,
transactionCount,
valueInBaseCurrency valueInBaseCurrency
} of positions) { } of positions) {
if (isFilteredByClosedHoldings === true) { if (isFilteredByClosedHoldings === true) {
@ -625,7 +619,6 @@ export class PortfolioService {
marketPrice, marketPrice,
symbol, symbol,
tags, tags,
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
@ -1696,7 +1689,6 @@ export class PortfolioService {
sectors: [], sectors: [],
symbol: currency, symbol: currency,
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: balance valueInBaseCurrency: balance
}; };
} }

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2025-12-15.clover' apiVersion: '2026-01-28.clover'
} }
); );
} }
@ -100,7 +100,6 @@ export class SubscriptionService {
); );
return { return {
sessionId: session.id,
sessionUrl: session.url sessionUrl: session.url
}; };
} }

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

@ -1,8 +1,11 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
DeleteOwnUserDto, DeleteOwnUserDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
@ -28,7 +31,8 @@ import {
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -43,6 +47,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -107,13 +112,19 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<User> { ): Promise<User> {
return this.userService.getUser( const impersonationUserId =
this.request.user, await this.impersonationService.validateImpersonationId(impersonationId);
acceptLanguage?.split(',')?.[0]
); return this.userService.getUser({
impersonationUserId,
locale: acceptLanguage?.split(',')?.[0],
user: this.request.user
});
} }
@Post() @Post()

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

@ -1,7 +1,9 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -18,6 +20,7 @@ import { UserService } from './user.service';
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
I18nModule, I18nModule,
ImpersonationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
@ -25,6 +28,7 @@ import { UserService } from './user.service';
OrderModule, OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedactValuesInResponseModule,
SubscriptionModule, SubscriptionModule,
TagModule TagModule
], ],

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

@ -30,7 +30,7 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale locale as defaultLocale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
User as IUser, User as IUser,
@ -49,7 +49,7 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
@ -96,10 +96,17 @@ export class UserService {
return { accessToken, hashedAccessToken }; return { accessToken, hashedAccessToken };
} }
public async getUser( public async getUser({
{ accounts, id, permissions, settings, subscription }: UserWithSettings, impersonationUserId,
aLocale = locale locale = defaultLocale,
): Promise<IUser> { user
}: {
impersonationUserId: string;
locale?: string;
user: UserWithSettings;
}): Promise<IUser> {
const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([ const userData = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
@ -108,22 +115,31 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { granteeUserId: id } where: { granteeUserId: id }
}), }),
this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
where: {
userId: impersonationUserId || user.id
}
}),
this.prismaService.order.count({ this.prismaService.order.count({
where: { userId: id } where: { userId: impersonationUserId || user.id }
}), }),
this.prismaService.order.findFirst({ this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
where: { userId: id } where: { userId: impersonationUserId || user.id }
}), }),
this.tagService.getTagsForUser(id) this.tagService.getTagsForUser(impersonationUserId || user.id)
]); ]);
const access = userData[0]; const access = userData[0];
const activitiesCount = userData[1]; const accounts = userData[1];
const firstActivity = userData[2]; const activitiesCount = userData[2];
let tags = userData[3].filter((tag) => { const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
}); });
@ -146,7 +162,6 @@ export class UserService {
} }
return { return {
accounts,
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
@ -160,10 +175,13 @@ export class UserService {
permissions: accessItem.permissions permissions: accessItem.permissions
}; };
}), }),
accounts: accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...(settings.settings as UserSettings),
locale: (settings.settings as UserSettings)?.locale ?? aLocale locale: (settings.settings as UserSettings)?.locale ?? locale
} }
}; };
} }
@ -516,9 +534,10 @@ export class UserService {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
user.accounts = sortBy(user.accounts, ({ name }) => { user.accounts = user.accounts.sort((a, b) => {
return name.toLowerCase(); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();
return user; return user;

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

@ -4,6 +4,7 @@
"4": "4", "4": "4",
"7": "Lucky7", "7": "Lucky7",
"8": "8", "8": "8",
"21": "2131KOBUSHIDE",
"32": "Project 32", "32": "Project 32",
"42": "Semantic Layer", "42": "Semantic Layer",
"47": "President Trump", "47": "President Trump",
@ -20,6 +21,7 @@
"1337": "EliteCoin", "1337": "EliteCoin",
"1717": "1717 Masonic Commemorative Token", "1717": "1717 Masonic Commemorative Token",
"2015": "2015 coin", "2015": "2015 coin",
"2016": "2016 coin",
"2024": "2024", "2024": "2024",
"2025": "2025 TOKEN", "2025": "2025 TOKEN",
"2026": "2026", "2026": "2026",
@ -116,6 +118,7 @@
"3DES": "3DES", "3DES": "3DES",
"3DVANCE": "3D Vance", "3DVANCE": "3D Vance",
"3FT": "ThreeFold Token", "3FT": "ThreeFold Token",
"3KDS": "3KDS",
"3KM": "3 Kingdoms Multiverse", "3KM": "3 Kingdoms Multiverse",
"3P": "Web3Camp", "3P": "Web3Camp",
"3RDEYE": "3rd Eye", "3RDEYE": "3rd Eye",
@ -149,6 +152,7 @@
"7E": "7ELEVEN", "7E": "7ELEVEN",
"88MPH": "88mph", "88MPH": "88mph",
"8BIT": "8BIT Coin", "8BIT": "8BIT Coin",
"8BITCOIN": "8-Bit COIN",
"8BT": "8 Circuit Studios", "8BT": "8 Circuit Studios",
"8LNDS": "8Lends", "8LNDS": "8Lends",
"8PAY": "8Pay", "8PAY": "8Pay",
@ -179,6 +183,7 @@
"AAC": "Double-A Chain", "AAC": "Double-A Chain",
"AAG": "AAG Ventures", "AAG": "AAG Ventures",
"AAI": "AutoAir AI", "AAI": "AutoAir AI",
"AALON": "American Airlines Group (Ondo Tokenized)",
"AAPLON": "Apple (Ondo Tokenized)", "AAPLON": "Apple (Ondo Tokenized)",
"AAPLX": "Apple xStock", "AAPLX": "Apple xStock",
"AAPX": "AMPnet", "AAPX": "AMPnet",
@ -713,6 +718,7 @@
"AMBRX": "Amber xStock", "AMBRX": "Amber xStock",
"AMBT": "AMBT Token", "AMBT": "AMBT Token",
"AMC": "AI Meta Coin", "AMC": "AI Meta Coin",
"AMCON": "AMC Entertainment (Ondo Tokenized)",
"AMDC": "Allmedi Coin", "AMDC": "Allmedi Coin",
"AMDG": "AMDG", "AMDG": "AMDG",
"AMDX": "AMD xStock", "AMDX": "AMD xStock",
@ -933,6 +939,7 @@
"ARBI": "Arbipad", "ARBI": "Arbipad",
"ARBINU": "ArbInu", "ARBINU": "ArbInu",
"ARBIT": "Arbit Coin", "ARBIT": "Arbit Coin",
"ARBITROVE": "Arbitrove Governance Token",
"ARBP": "ARB Protocol", "ARBP": "ARB Protocol",
"ARBS": "Arbswap", "ARBS": "Arbswap",
"ARBT": "ARBITRAGE", "ARBT": "ARBITRAGE",
@ -1101,6 +1108,7 @@
"ASTA": "ASTA", "ASTA": "ASTA",
"ASTER": "Aster", "ASTER": "Aster",
"ASTERINU": "Aster INU", "ASTERINU": "Aster INU",
"ASTHERUSUSDF": "Astherus USDF",
"ASTO": "Altered State Token", "ASTO": "Altered State Token",
"ASTON": "Aston", "ASTON": "Aston",
"ASTONV": "Aston Villa Fan Token", "ASTONV": "Aston Villa Fan Token",
@ -1171,6 +1179,7 @@
"ATON": "Further Network", "ATON": "Further Network",
"ATOPLUS": "ATO+", "ATOPLUS": "ATO+",
"ATOR": "ATOR Protocol", "ATOR": "ATOR Protocol",
"ATOS": "Atoshi",
"ATOZ": "Race Kingdom", "ATOZ": "Race Kingdom",
"ATP": "Atlas Protocol", "ATP": "Atlas Protocol",
"ATPAY": "AtPay", "ATPAY": "AtPay",
@ -1364,7 +1373,6 @@
"BABI": "Babylons", "BABI": "Babylons",
"BABL": "Babylon Finance", "BABL": "Babylon Finance",
"BABY": "Babylon", "BABY": "Babylon",
"BABY4": "Baby 4",
"BABYANDY": "Baby Andy", "BABYANDY": "Baby Andy",
"BABYASTER": "Baby Aster", "BABYASTER": "Baby Aster",
"BABYB": "Baby Bali", "BABYB": "Baby Bali",
@ -2007,7 +2015,8 @@
"BIPC": "BipCoin", "BIPC": "BipCoin",
"BIPX": "Bispex", "BIPX": "Bispex",
"BIR": "Birake", "BIR": "Birake",
"BIRB": "Birb", "BIRB": "Moonbirds",
"BIRBV1": "Birb",
"BIRD": "BIRD", "BIRD": "BIRD",
"BIRDCHAIN": "Birdchain", "BIRDCHAIN": "Birdchain",
"BIRDD": "BIRD DOG", "BIRDD": "BIRD DOG",
@ -2048,6 +2057,7 @@
"BITCOINCONFI": "Bitcoin Confidential", "BITCOINCONFI": "Bitcoin Confidential",
"BITCOINOTE": "BitcoiNote", "BITCOINOTE": "BitcoiNote",
"BITCOINP": "Bitcoin Private", "BITCOINP": "Bitcoin Private",
"BITCOINSCRYPT": "Bitcoin Scrypt",
"BITCOINV": "BitcoinV", "BITCOINV": "BitcoinV",
"BITCONNECT": "BitConnect Coin", "BITCONNECT": "BitConnect Coin",
"BITCORE": "BitCore", "BITCORE": "BitCore",
@ -2181,6 +2191,7 @@
"BLOCKASSET": "Blockasset", "BLOCKASSET": "Blockasset",
"BLOCKB": "Block Browser", "BLOCKB": "Block Browser",
"BLOCKBID": "Blockbid", "BLOCKBID": "Blockbid",
"BLOCKCHAINTRADED": "Blockchain Traded Fund",
"BLOCKF": "Block Farm Club", "BLOCKF": "Block Farm Club",
"BLOCKG": "BlockGames", "BLOCKG": "BlockGames",
"BLOCKIFY": "Blockify.Games", "BLOCKIFY": "Blockify.Games",
@ -2808,7 +2819,7 @@
"BTCR": "BitCurrency", "BTCR": "BitCurrency",
"BTCRED": "Bitcoin Red", "BTCRED": "Bitcoin Red",
"BTCRY": "BitCrystal", "BTCRY": "BitCrystal",
"BTCS": "Bitcoin Scrypt", "BTCS": "BTCs",
"BTCSR": "BTC Strategic Reserve", "BTCSR": "BTC Strategic Reserve",
"BTCST": "BTC Standard Hashrate Token", "BTCST": "BTC Standard Hashrate Token",
"BTCTOKEN": "Bitcoin Token", "BTCTOKEN": "Bitcoin Token",
@ -2823,9 +2834,10 @@
"BTELEGRAM": "BetterTelegram Token", "BTELEGRAM": "BetterTelegram Token",
"BTEV1": "Betero v1", "BTEV1": "Betero v1",
"BTEX": "BTEX", "BTEX": "BTEX",
"BTF": "Blockchain Traded Fund", "BTF": "Bitfinity Network",
"BTFA": "Banana Task Force Ape", "BTFA": "Banana Task Force Ape",
"BTG": "Bitcoin Gold", "BTG": "Bitcoin Gold",
"BTGON": "B2Gold (Ondo Tokenized)",
"BTH": "Bithereum", "BTH": "Bithereum",
"BTK": "Bostoken", "BTK": "Bostoken",
"BTL": "Bitlocus", "BTL": "Bitlocus",
@ -2973,9 +2985,11 @@
"BUSY": "Busy DAO", "BUSY": "Busy DAO",
"BUT": "Bucket Token", "BUT": "Bucket Token",
"BUTT": "Buttercat", "BUTT": "Buttercat",
"BUTTC": "Buttcoin",
"BUTTCOIN": "The Next Bitcoin", "BUTTCOIN": "The Next Bitcoin",
"BUTTHOLE": "Butthole Coin", "BUTTHOLE": "Butthole Coin",
"BUTTPLUG": "fartcoin killer", "BUTTPLUG": "fartcoin killer",
"BUTWHY": "ButWhy",
"BUX": "BUX", "BUX": "BUX",
"BUXCOIN": "Buxcoin", "BUXCOIN": "Buxcoin",
"BUY": "Burency", "BUY": "Burency",
@ -3007,6 +3021,7 @@
"BXA": "Blockchain Exchange Alliance", "BXA": "Blockchain Exchange Alliance",
"BXBT": "BoxBet", "BXBT": "BoxBet",
"BXC": "BonusCloud", "BXC": "BonusCloud",
"BXE": "Banxchange",
"BXF": "BlackFort Token", "BXF": "BlackFort Token",
"BXH": "BXH", "BXH": "BXH",
"BXK": "Bitbook Gambling", "BXK": "Bitbook Gambling",
@ -3230,6 +3245,7 @@
"CATW": "Cat wif Hands", "CATW": "Cat wif Hands",
"CATWARRIOR": "Cat warrior", "CATWARRIOR": "Cat warrior",
"CATWIF": "CatWifHat", "CATWIF": "CatWifHat",
"CATWIFM": "catwifmask",
"CATX": "CAT.trade Protocol", "CATX": "CAT.trade Protocol",
"CATZ": "CatzCoin", "CATZ": "CatzCoin",
"CAU": "Canxium", "CAU": "Canxium",
@ -3613,6 +3629,8 @@
"CLASH": "GeorgePlaysClashRoyale", "CLASH": "GeorgePlaysClashRoyale",
"CLASHUB": "Clashub", "CLASHUB": "Clashub",
"CLASS": "Class Coin", "CLASS": "Class Coin",
"CLAWD": "clawd.atg.eth",
"CLAWNCH": "CLAWNCH",
"CLAY": "Clayton", "CLAY": "Clayton",
"CLAYN": "Clay Nation", "CLAYN": "Clay Nation",
"CLB": "Cloudbric", "CLB": "Cloudbric",
@ -3637,7 +3655,8 @@
"CLIN": "Clinicoin", "CLIN": "Clinicoin",
"CLINK": "cLINK", "CLINK": "cLINK",
"CLINT": "Clinton", "CLINT": "Clinton",
"CLIPPY": "CLIPPY", "CLIPPY": "Clippy",
"CLIPPYETH": "CLIPPY",
"CLIPS": "Clips", "CLIPS": "Clips",
"CLIQ": "DefiCliq", "CLIQ": "DefiCliq",
"CLIST": "Chainlist", "CLIST": "Chainlist",
@ -3867,6 +3886,7 @@
"COPIUM": "Copium", "COPIUM": "Copium",
"COPPER": "COPPER", "COPPER": "COPPER",
"COPS": "Cops Finance", "COPS": "Cops Finance",
"COPXON": "Global X Copper Miners ETF (Ondo Tokenized)",
"COPYCAT": "Copycat Finance", "COPYCAT": "Copycat Finance",
"COQ": "Coq Inu", "COQ": "Coq Inu",
"COR": "Coreto", "COR": "Coreto",
@ -3944,6 +3964,7 @@
"CPLO": "Cpollo", "CPLO": "Cpollo",
"CPM": "Crypto Pump Meme", "CPM": "Crypto Pump Meme",
"CPN": "CompuCoin", "CPN": "CompuCoin",
"CPNGON": "Coupang (Ondo Tokenized)",
"CPO": "Cryptopolis", "CPO": "Cryptopolis",
"CPOO": "Cockapoo", "CPOO": "Cockapoo",
"CPOOL": "Clearpool", "CPOOL": "Clearpool",
@ -3976,6 +3997,7 @@
"CRAPPY": "CrappyBird", "CRAPPY": "CrappyBird",
"CRASH": "Solana Crash", "CRASH": "Solana Crash",
"CRASHBOYS": "CRASHBOYS", "CRASHBOYS": "CRASHBOYS",
"CRAT": "CratD2C",
"CRAVE": "CraveCoin", "CRAVE": "CraveCoin",
"CRAYRABBIT": "CrazyRabbit", "CRAYRABBIT": "CrazyRabbit",
"CRAZ": "CRAZY FLOKI", "CRAZ": "CRAZY FLOKI",
@ -4411,6 +4433,7 @@
"DANJ": "Danjuan Cat", "DANJ": "Danjuan Cat",
"DANK": "DarkKush", "DANK": "DarkKush",
"DANKDOGE": "Dank Doge", "DANKDOGE": "Dank Doge",
"DANKDOGEAI": "DankDogeAI",
"DANNY": "Degen Danny", "DANNY": "Degen Danny",
"DAO": "DAO Maker", "DAO": "DAO Maker",
"DAO1": "DAO1", "DAO1": "DAO1",
@ -4897,6 +4920,7 @@
"DLXV": "Delta-X", "DLXV": "Delta-X",
"DLY": "Daily Finance", "DLY": "Daily Finance",
"DLYCOP": "Daily COP", "DLYCOP": "Daily COP",
"DM": "Dumb Money",
"DMA": "Dragoma", "DMA": "Dragoma",
"DMAGA": "Dark MAGA", "DMAGA": "Dark MAGA",
"DMAIL": "DMAIL Network", "DMAIL": "DMAIL Network",
@ -5096,7 +5120,8 @@
"DONKEY": "donkey", "DONKEY": "donkey",
"DONNIEFIN": "Donnie Finance", "DONNIEFIN": "Donnie Finance",
"DONS": "The Dons", "DONS": "The Dons",
"DONT": "Donald Trump (dont.cash)", "DONT": "DisclaimerCoin",
"DONTCASH": "DONT",
"DONU": "Donu", "DONU": "Donu",
"DONUT": "Donut", "DONUT": "Donut",
"DONUTS": "The Simpsons", "DONUTS": "The Simpsons",
@ -5451,6 +5476,7 @@
"ECET": "Evercraft Ecotechnologies", "ECET": "Evercraft Ecotechnologies",
"ECG": "EcoSmart", "ECG": "EcoSmart",
"ECH": "EthereCash", "ECH": "EthereCash",
"ECHELON": "Echelon Token",
"ECHO": "Echo", "ECHO": "Echo",
"ECHOBOT": "ECHO BOT", "ECHOBOT": "ECHO BOT",
"ECHOD": "EchoDEX", "ECHOD": "EchoDEX",
@ -5569,6 +5595,7 @@
"EHASH": "EHash", "EHASH": "EHash",
"EHIVE": "eHive", "EHIVE": "eHive",
"EHRT": "Eight Hours Token", "EHRT": "Eight Hours Token",
"EICOIN": "EICOIN",
"EIFI": "EIFI FINANCE", "EIFI": "EIFI FINANCE",
"EIGEN": "EigenLayer", "EIGEN": "EigenLayer",
"EIGENP": "Eigenpie", "EIGENP": "Eigenpie",
@ -5899,6 +5926,7 @@
"ETHEREM": "Etherempires", "ETHEREM": "Etherempires",
"ETHEREUMMEME": "Solana Ethereum Meme", "ETHEREUMMEME": "Solana Ethereum Meme",
"ETHEREUMP": "ETHEREUMPLUS", "ETHEREUMP": "ETHEREUMPLUS",
"ETHEREUMSCRYPT": "EthereumScrypt",
"ETHERINC": "EtherInc", "ETHERINC": "EtherInc",
"ETHERKING": "Ether Kingdoms Token", "ETHERKING": "Ether Kingdoms Token",
"ETHERNITY": "Ethernity Chain", "ETHERNITY": "Ethernity Chain",
@ -5921,7 +5949,7 @@
"ETHPR": "Ethereum Premium", "ETHPR": "Ethereum Premium",
"ETHPY": "Etherpay", "ETHPY": "Etherpay",
"ETHR": "Ethereal", "ETHR": "Ethereal",
"ETHS": "EthereumScrypt", "ETHS": "Ethscriptions",
"ETHSHIB": "Eth Shiba", "ETHSHIB": "Eth Shiba",
"ETHV": "Ethverse", "ETHV": "Ethverse",
"ETHW": "Ethereum PoW", "ETHW": "Ethereum PoW",
@ -6096,6 +6124,7 @@
"F2C": "Ftribe Fighters", "F2C": "Ftribe Fighters",
"F2K": "Farm2Kitchen", "F2K": "Farm2Kitchen",
"F3": "Friend3", "F3": "Friend3",
"F5": "F5-promoT5",
"F7": "Five7", "F7": "Five7",
"F9": "Falcon Nine", "F9": "Falcon Nine",
"FAB": "FABRK Token", "FAB": "FABRK Token",
@ -6283,6 +6312,7 @@
"FIC": "Filecash", "FIC": "Filecash",
"FID": "Fidira", "FID": "Fidira",
"FIDA": "Bonfida", "FIDA": "Bonfida",
"FIDD": "Fidelity Digital Dollar",
"FIDLE": "Fidlecoin", "FIDLE": "Fidlecoin",
"FIDO": "FIDO", "FIDO": "FIDO",
"FIDU": "Fidu", "FIDU": "Fidu",
@ -6672,6 +6702,7 @@
"FRR": "Frontrow", "FRR": "Frontrow",
"FRSP": "Forkspot", "FRSP": "Forkspot",
"FRST": "FirstCoin", "FRST": "FirstCoin",
"FRT": "FORT Token",
"FRTC": "FART COIN", "FRTC": "FART COIN",
"FRTN": "EbisusBay Fortune", "FRTN": "EbisusBay Fortune",
"FRTS": "Fruits", "FRTS": "Fruits",
@ -7211,6 +7242,7 @@
"GMDP": "GMD Protocol", "GMDP": "GMD Protocol",
"GME": "GameStop", "GME": "GameStop",
"GMEE": "GAMEE", "GMEE": "GAMEE",
"GMEON": "GameStop (Ondo Tokenized)",
"GMEPEPE": "GAMESTOP PEPE", "GMEPEPE": "GAMESTOP PEPE",
"GMETHERFRENS": "GM", "GMETHERFRENS": "GM",
"GMETRUMP": "GME TRUMP", "GMETRUMP": "GME TRUMP",
@ -7289,6 +7321,7 @@
"GOFINDXR": "Gofind XR", "GOFINDXR": "Gofind XR",
"GOFX": "GooseFX", "GOFX": "GooseFX",
"GOG": "Guild of Guardians", "GOG": "Guild of Guardians",
"GOGE": "GOLD DOGE",
"GOGLZ": "GOGGLES", "GOGLZ": "GOGGLES",
"GOGLZV1": "GOGGLES v1", "GOGLZV1": "GOGGLES v1",
"GOGO": "GOGO Finance", "GOGO": "GOGO Finance",
@ -7399,6 +7432,7 @@
"GPU": "Node AI", "GPU": "Node AI",
"GPUCOIN": "GPU Coin", "GPUCOIN": "GPU Coin",
"GPUINU": "GPU Inu", "GPUINU": "GPU Inu",
"GPUNET": "GPUnet",
"GPX": "GPEX", "GPX": "GPEX",
"GQ": "Galactic Quadrant", "GQ": "Galactic Quadrant",
"GR": "GROM", "GR": "GROM",
@ -7537,6 +7571,7 @@
"GTAN": "Giant Token", "GTAN": "Giant Token",
"GTAVI": "GTAVI", "GTAVI": "GTAVI",
"GTBOT": "Gaming-T-Bot", "GTBOT": "Gaming-T-Bot",
"GTBTC": "Gate Wrapped BTC",
"GTC": "Gitcoin", "GTC": "Gitcoin",
"GTCC": "GTC COIN", "GTCC": "GTC COIN",
"GTCOIN": "Game Tree", "GTCOIN": "Game Tree",
@ -7599,6 +7634,7 @@
"GVT": "Genesis Vision", "GVT": "Genesis Vision",
"GW": "Gyrowin", "GW": "Gyrowin",
"GWD": "GreenWorld", "GWD": "GreenWorld",
"GWEI": "ETHGas",
"GWGW": "GoWrap", "GWGW": "GoWrap",
"GWT": "Galaxy War", "GWT": "Galaxy War",
"GX": "GameX", "GX": "GameX",
@ -7915,7 +7951,7 @@
"HLG": "Holograph", "HLG": "Holograph",
"HLINK": "Chainlink (Harmony One Bridge)", "HLINK": "Chainlink (Harmony One Bridge)",
"HLM": "Helium", "HLM": "Helium",
"HLN": "Holonus", "HLN": "Ēnosys",
"HLO": "Halo", "HLO": "Halo",
"HLOV1": "Halo v1", "HLOV1": "Halo v1",
"HLP": "Purpose Coin", "HLP": "Purpose Coin",
@ -7980,6 +8016,7 @@
"HOLDON4": "HoldOn4DearLife", "HOLDON4": "HoldOn4DearLife",
"HOLDS": "Holdstation", "HOLDS": "Holdstation",
"HOLO": "Holoworld", "HOLO": "Holoworld",
"HOLON": "Holonus",
"HOLY": "Holy Trinity", "HOLY": "Holy Trinity",
"HOM": "Homeety", "HOM": "Homeety",
"HOME": "Home", "HOME": "Home",
@ -8199,6 +8236,7 @@
"IAI": "inheritance Art", "IAI": "inheritance Art",
"IAM": "IAME Identity", "IAM": "IAME Identity",
"IAOMIN": "Yao Ming", "IAOMIN": "Yao Ming",
"IAUON": "iShares Gold Trust (Ondo Tokenized)",
"IB": "Iron Bank", "IB": "Iron Bank",
"IBANK": "iBankCoin", "IBANK": "iBankCoin",
"IBAT": "Battle Infinity", "IBAT": "Battle Infinity",
@ -8357,7 +8395,7 @@
"IMS": "Independent Money System", "IMS": "Independent Money System",
"IMST": "Imsmart", "IMST": "Imsmart",
"IMT": "Immortal Token", "IMT": "Immortal Token",
"IMU": "imusify", "IMUSIFY": "imusify",
"IMVR": "ImmVRse", "IMVR": "ImmVRse",
"IMX": "Immutable X", "IMX": "Immutable X",
"IN": "INFINIT", "IN": "INFINIT",
@ -8522,6 +8560,7 @@
"IRA": "Diligence", "IRA": "Diligence",
"IRC": "IRIS", "IRC": "IRIS",
"IRENA": "Irena Coin Apps", "IRENA": "Irena Coin Apps",
"IRENON": "IREN (Ondo Tokenized)",
"IRIS": "IRIS Network", "IRIS": "IRIS Network",
"IRISTOKEN": "Iris Ecosystem", "IRISTOKEN": "Iris Ecosystem",
"IRL": "IrishCoin", "IRL": "IrishCoin",
@ -8595,6 +8634,7 @@
"IVZ": "InvisibleCoin", "IVZ": "InvisibleCoin",
"IW": "iWallet", "IW": "iWallet",
"IWFT": "İstanbul Wild Cats", "IWFT": "İstanbul Wild Cats",
"IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)",
"IWT": "IwToken", "IWT": "IwToken",
"IX": "X-Block", "IX": "X-Block",
"IXC": "IXcoin", "IXC": "IXcoin",
@ -8698,6 +8738,7 @@
"JETCOIN": "Jetcoin", "JETCOIN": "Jetcoin",
"JETFUEL": "Jetfuel Finance", "JETFUEL": "Jetfuel Finance",
"JETTON": "JetTon Game", "JETTON": "JetTon Game",
"JETUSD": "JETUSD",
"JEUR": "Jarvis Synthetic Euro", "JEUR": "Jarvis Synthetic Euro",
"JEW": "Shekel", "JEW": "Shekel",
"JEWEL": "DeFi Kingdoms", "JEWEL": "DeFi Kingdoms",
@ -8847,6 +8888,7 @@
"JWBTC": "Wrapped Bitcoin (TON Bridge)", "JWBTC": "Wrapped Bitcoin (TON Bridge)",
"JWIF": "Jerrywifhat", "JWIF": "Jerrywifhat",
"JWL": "Jewels", "JWL": "Jewels",
"JWT": "JW Token",
"JYAI": "Jerry The Turtle By Matt Furie", "JYAI": "Jerry The Turtle By Matt Furie",
"JYC": "Joe-Yo Coin", "JYC": "Joe-Yo Coin",
"K": "Sidekick", "K": "Sidekick",
@ -8855,7 +8897,13 @@
"KAAI": "KanzzAI", "KAAI": "KanzzAI",
"KAAS": "KAASY.AI", "KAAS": "KAASY.AI",
"KAB": "KABOSU", "KAB": "KABOSU",
"KABOSU": "Kabosu Family", "KABOSU": "X Meme Dog",
"KABOSUCOIN": "Kabosu",
"KABOSUCOM": "Kabosu",
"KABOSUFAMILY": "Kabosu Family",
"KABOSUTOKEN": "Kabosu",
"KABOSUTOKENETH": "KABOSU",
"KABUTO": "Kabuto",
"KABY": "Kaby Arena", "KABY": "Kaby Arena",
"KAC": "KACO Finance", "KAC": "KACO Finance",
"KACY": "markkacy", "KACY": "markkacy",
@ -9155,6 +9203,7 @@
"KNDC": "KanadeCoin", "KNDC": "KanadeCoin",
"KNDM": "Kingdom", "KNDM": "Kingdom",
"KNDX": "Kondux", "KNDX": "Kondux",
"KNEKTED": "Knekted",
"KNFT": "KStarNFT", "KNFT": "KStarNFT",
"KNG": "BetKings", "KNG": "BetKings",
"KNGN": "KingN Coin", "KNGN": "KingN Coin",
@ -9167,7 +9216,7 @@
"KNOW": "KNOW", "KNOW": "KNOW",
"KNOX": "KnoxDAO", "KNOX": "KnoxDAO",
"KNS": "Kenshi", "KNS": "Kenshi",
"KNT": "Knekted", "KNT": "KayakNet",
"KNTO": "Kento", "KNTO": "Kento",
"KNTQ": "Kinetiq Governance Token", "KNTQ": "Kinetiq Governance Token",
"KNU": "Keanu", "KNU": "Keanu",
@ -9670,7 +9719,7 @@
"LITTLEGUY": "just a little guy", "LITTLEGUY": "just a little guy",
"LITTLEMANYU": "Little Manyu", "LITTLEMANYU": "Little Manyu",
"LIV": "LiviaCoin", "LIV": "LiviaCoin",
"LIVE": "TRONbetLive", "LIVE": "SecondLive",
"LIVENCOIN": "LivenPay", "LIVENCOIN": "LivenPay",
"LIVESEY": "Dr. Livesey", "LIVESEY": "Dr. Livesey",
"LIVESTARS": "Live Stars", "LIVESTARS": "Live Stars",
@ -10008,6 +10057,7 @@
"M0": "M by M^0", "M0": "M by M^0",
"M1": "SupplyShock", "M1": "SupplyShock",
"M2O": "M2O Token", "M2O": "M2O Token",
"M3H": "MehVerseCoin",
"M3M3": "M3M3", "M3M3": "M3M3",
"M87": "MESSIER", "M87": "MESSIER",
"MA": "Mind-AI", "MA": "Mind-AI",
@ -10026,6 +10076,8 @@
"MADOG": "MarvelDoge", "MADOG": "MarvelDoge",
"MADP": "Mad Penguin", "MADP": "Mad Penguin",
"MADPEPE": "Mad Pepe", "MADPEPE": "Mad Pepe",
"MADU": "Nicolas Maduro",
"MADURO": "MADURO",
"MAECENAS": "Maecenas", "MAECENAS": "Maecenas",
"MAEP": "Maester Protocol", "MAEP": "Maester Protocol",
"MAF": "MetaMAFIA", "MAF": "MetaMAFIA",
@ -10115,7 +10167,8 @@
"MANUSAI": "Manus AI Agent", "MANUSAI": "Manus AI Agent",
"MANYU": "Manyu", "MANYU": "Manyu",
"MANYUDOG": "MANYU", "MANYUDOG": "MANYU",
"MAO": "Mao", "MAO": "MAO",
"MAOMEME": "Mao",
"MAOW": "MAOW", "MAOW": "MAOW",
"MAP": "MAP Protocol", "MAP": "MAP Protocol",
"MAPC": "MapCoin", "MAPC": "MapCoin",
@ -10125,6 +10178,7 @@
"MAPS": "MAPS", "MAPS": "MAPS",
"MAPU": "MatchAwards Platform Utility Token", "MAPU": "MatchAwards Platform Utility Token",
"MAR3": "Mar3 AI", "MAR3": "Mar3 AI",
"MARAON": "MARA Holdings (Ondo Tokenized)",
"MARCO": "MELEGA", "MARCO": "MELEGA",
"MARCUS": "Marcus Cesar Inu", "MARCUS": "Marcus Cesar Inu",
"MARE": "Mare Finance", "MARE": "Mare Finance",
@ -10179,7 +10233,7 @@
"MASTERMIX": "Master MIX Token", "MASTERMIX": "Master MIX Token",
"MASTERTRADER": "MasterTraderCoin", "MASTERTRADER": "MasterTraderCoin",
"MASYA": "MASYA", "MASYA": "MASYA",
"MAT": "My Master Wa", "MAT": "Matchain",
"MATA": "Ninneko", "MATA": "Ninneko",
"MATAR": "MATAR AI", "MATAR": "MATAR AI",
"MATCH": "Matching Game", "MATCH": "Matching Game",
@ -10397,6 +10451,7 @@
"MEMEAI": "Meme Ai", "MEMEAI": "Meme Ai",
"MEMEBRC": "MEME", "MEMEBRC": "MEME",
"MEMECOIN": "just memecoin", "MEMECOIN": "just memecoin",
"MEMECOINDAOAI": "MemeCoinDAO",
"MEMECUP": "Meme Cup", "MEMECUP": "Meme Cup",
"MEMEETF": "Meme ETF", "MEMEETF": "Meme ETF",
"MEMEFI": "MemeFi", "MEMEFI": "MemeFi",
@ -10408,7 +10463,7 @@
"MEMEMUSK": "MEME MUSK", "MEMEMUSK": "MEME MUSK",
"MEMENTO": "MEMENTO•MORI (Runes)", "MEMENTO": "MEMENTO•MORI (Runes)",
"MEMERUNE": "MEME•ECONOMICS", "MEMERUNE": "MEME•ECONOMICS",
"MEMES": "MemeCoinDAO", "MEMES": "memes will continue",
"MEMESAI": "Memes AI", "MEMESAI": "Memes AI",
"MEMESQUAD": "Meme Squad", "MEMESQUAD": "Meme Squad",
"MEMET": "MEMETOON", "MEMET": "MEMETOON",
@ -10596,7 +10651,7 @@
"MIININGNFT": "MiningNFT", "MIININGNFT": "MiningNFT",
"MIKE": "Mike", "MIKE": "Mike",
"MIKS": "MIKS COIN", "MIKS": "MIKS COIN",
"MIL": "Milllionaire Coin", "MIL": "Mil",
"MILA": "MILADY MEME TOKEN", "MILA": "MILADY MEME TOKEN",
"MILC": "Micro Licensing Coin", "MILC": "Micro Licensing Coin",
"MILE": "milestoneBased", "MILE": "milestoneBased",
@ -10609,6 +10664,7 @@
"MILLI": "Million", "MILLI": "Million",
"MILLIM": "Millimeter", "MILLIM": "Millimeter",
"MILLIMV1": "Millimeter v1", "MILLIMV1": "Millimeter v1",
"MILLLIONAIRECOIN": "Milllionaire Coin",
"MILLY": "milly", "MILLY": "milly",
"MILO": "Milo Inu", "MILO": "Milo Inu",
"MILOCEO": "Milo CEO", "MILOCEO": "Milo CEO",
@ -10879,6 +10935,7 @@
"MOLK": "Mobilink Token", "MOLK": "Mobilink Token",
"MOLLARS": "MollarsToken", "MOLLARS": "MollarsToken",
"MOLLY": "Molly", "MOLLY": "Molly",
"MOLT": "Moltbook",
"MOM": "Mother of Memes", "MOM": "Mother of Memes",
"MOMA": "Mochi Market", "MOMA": "Mochi Market",
"MOMIJI": "MAGA Momiji", "MOMIJI": "MAGA Momiji",
@ -10894,6 +10951,7 @@
"MONAV": "Monavale", "MONAV": "Monavale",
"MONB": "MonbaseCoin", "MONB": "MonbaseCoin",
"MONDO": "mondo", "MONDO": "mondo",
"MONEROAI": "Monero AI",
"MONEROCHAN": "Monerochan", "MONEROCHAN": "Monerochan",
"MONET": "Claude Monet Memeory Coin", "MONET": "Claude Monet Memeory Coin",
"MONETA": "Moneta", "MONETA": "Moneta",
@ -11084,6 +11142,7 @@
"MSCT": "MUSE ENT NFT", "MSCT": "MUSE ENT NFT",
"MSD": "MSD", "MSD": "MSD",
"MSFT": "Microsoft 6900", "MSFT": "Microsoft 6900",
"MSFTON": "Microsoft (Ondo Tokenized)",
"MSFTX": "Microsoft xStock", "MSFTX": "Microsoft xStock",
"MSG": "MsgSender", "MSG": "MsgSender",
"MSGO": "MetaSetGO", "MSGO": "MetaSetGO",
@ -11107,6 +11166,7 @@
"MSTRX": "MicroStrategy xStock", "MSTRX": "MicroStrategy xStock",
"MSU": "MetaSoccer", "MSU": "MetaSoccer",
"MSUSHI": "Sushi (Multichain)", "MSUSHI": "Sushi (Multichain)",
"MSVP": "MetaSoilVerseProtocol",
"MSWAP": "MoneySwap", "MSWAP": "MoneySwap",
"MT": "Mint Token", "MT": "Mint Token",
"MTA": "Meta", "MTA": "Meta",
@ -11262,10 +11322,12 @@
"MYL": "MyLottoCoin", "MYL": "MyLottoCoin",
"MYLINX": "Linx", "MYLINX": "Linx",
"MYLO": "MYLOCAT", "MYLO": "MYLOCAT",
"MYMASTERWAR": "My Master Wa",
"MYNE": "ITSMYNE", "MYNE": "ITSMYNE",
"MYO": "Mycro", "MYO": "Mycro",
"MYOBU": "Myōbu", "MYOBU": "Myōbu",
"MYRA": "Mytheria", "MYRA": "Mytheria",
"MYRC": "MYRC",
"MYRE": "Myre", "MYRE": "Myre",
"MYRIA": "Myria", "MYRIA": "Myria",
"MYRO": "Myro", "MYRO": "Myro",
@ -11586,6 +11648,7 @@
"NIC": "NewInvestCoin", "NIC": "NewInvestCoin",
"NICE": "Nice", "NICE": "Nice",
"NICEC": "NiceCoin", "NICEC": "NiceCoin",
"NIETZSCHEAN": "Nietzschean Penguin",
"NIF": "Unifty", "NIF": "Unifty",
"NIFT": "Niftify", "NIFT": "Niftify",
"NIFTSY": "Envelop", "NIFTSY": "Envelop",
@ -11617,6 +11680,7 @@
"NINU": "Nvidia Inu", "NINU": "Nvidia Inu",
"NIOB": "Niob Finance", "NIOB": "Niob Finance",
"NIOCTIB": "nioctiB", "NIOCTIB": "nioctiB",
"NIOON": "NIO (Ondo Tokenized)",
"NIOX": "Autonio", "NIOX": "Autonio",
"NIOXV1": "Autonio v1", "NIOXV1": "Autonio v1",
"NIOXV2": "Autonio v2", "NIOXV2": "Autonio v2",
@ -11750,6 +11814,7 @@
"NRCH": "EnreachDAO", "NRCH": "EnreachDAO",
"NRFB": "NuriFootBall", "NRFB": "NuriFootBall",
"NRG": "Energi", "NRG": "Energi",
"NRGE": "New Resources Generation Energy",
"NRGY": "NRGY Defi", "NRGY": "NRGY Defi",
"NRK": "Nordek", "NRK": "Nordek",
"NRM": "Neuromachine", "NRM": "Neuromachine",
@ -12308,6 +12373,7 @@
"OWB": "OWB", "OWB": "OWB",
"OWC": "Oduwa", "OWC": "Oduwa",
"OWD": "Owlstand", "OWD": "Owlstand",
"OWL": "Owlto",
"OWLTOKEN": "OWL Token", "OWLTOKEN": "OWL Token",
"OWN": "OTHERWORLD", "OWN": "OTHERWORLD",
"OWNDATA": "OWNDATA", "OWNDATA": "OWNDATA",
@ -12342,6 +12408,7 @@
"P202": "Project 202", "P202": "Project 202",
"P2P": "Sentinel", "P2P": "Sentinel",
"P2PS": "P2P Solutions Foundation", "P2PS": "P2P Solutions Foundation",
"P2PV1": "Sentinel",
"P33L": "THE P33L", "P33L": "THE P33L",
"P3D": "3DPass", "P3D": "3DPass",
"P404": "Potion 404", "P404": "Potion 404",
@ -12593,6 +12660,7 @@
"PENDY": "Pendy", "PENDY": "Pendy",
"PENG": "Peng", "PENG": "Peng",
"PENGCOIN": "PENG", "PENGCOIN": "PENG",
"PENGO": "Petro Penguins",
"PENGU": "Pudgy Penguins", "PENGU": "Pudgy Penguins",
"PENGUAI": "PENGU AI", "PENGUAI": "PENGU AI",
"PENGUI": "Penguiana", "PENGUI": "Penguiana",
@ -12718,6 +12786,7 @@
"PEW": "pepe in a memes world", "PEW": "pepe in a memes world",
"PEX": "Pexcoin", "PEX": "Pexcoin",
"PF": "Purple Frog", "PF": "Purple Frog",
"PFEON": "Pfizer (Ondo Tokenized)",
"PFEX": "Pfizer xStock", "PFEX": "Pfizer xStock",
"PFF": "PumpFunFloki", "PFF": "PumpFunFloki",
"PFI": "PrimeFinance", "PFI": "PrimeFinance",
@ -12815,6 +12884,7 @@
"PIKAM": "Pikamoon", "PIKAM": "Pikamoon",
"PIKE": "Pike Token", "PIKE": "Pike Token",
"PIKO": "Pinnako", "PIKO": "Pinnako",
"PIKZ": "PIKZ",
"PILLAR": "PillarFi", "PILLAR": "PillarFi",
"PILOT": "Unipilot", "PILOT": "Unipilot",
"PIM": "PIM", "PIM": "PIM",
@ -12952,6 +13022,7 @@
"PLSX": "PulseX", "PLSX": "PulseX",
"PLT": "Poollotto.finance", "PLT": "Poollotto.finance",
"PLTC": "PlatonCoin", "PLTC": "PlatonCoin",
"PLTRON": "Palantir Technologies (Ondo Tokenized)",
"PLTRX": "Palantir xStock", "PLTRX": "Palantir xStock",
"PLTX": "PlutusX", "PLTX": "PlutusX",
"PLTXYZ": "Add.xyz", "PLTXYZ": "Add.xyz",
@ -13199,6 +13270,7 @@
"PRESI": "Turbo Trump", "PRESI": "Turbo Trump",
"PRESID": "President Ron DeSantis", "PRESID": "President Ron DeSantis",
"PRESIDEN": "President Elon", "PRESIDEN": "President Elon",
"PRESSX": "PressX",
"PRFT": "Proof Suite Token", "PRFT": "Proof Suite Token",
"PRG": "Paragon", "PRG": "Paragon",
"PRI": "PRIVATEUM INITIATIVE", "PRI": "PRIVATEUM INITIATIVE",
@ -13431,6 +13503,7 @@
"PYME": "PymeDAO", "PYME": "PymeDAO",
"PYN": "Paynetic", "PYN": "Paynetic",
"PYP": "PayPro", "PYP": "PayPro",
"PYPLON": "PayPal (Ondo Tokenized)",
"PYQ": "PolyQuity", "PYQ": "PolyQuity",
"PYR": "Vulcan Forged", "PYR": "Vulcan Forged",
"PYRAM": "Pyram Token", "PYRAM": "Pyram Token",
@ -13467,6 +13540,7 @@
"QBX": "qiibee foundation", "QBX": "qiibee foundation",
"QBZ": "QUEENBEE", "QBZ": "QUEENBEE",
"QC": "Qcash", "QC": "Qcash",
"QCAD": "QCAD",
"QCH": "QChi", "QCH": "QChi",
"QCN": "Quazar Coin", "QCN": "Quazar Coin",
"QCO": "Qravity", "QCO": "Qravity",
@ -13538,6 +13612,7 @@
"QUA": "Quantum Tech", "QUA": "Quantum Tech",
"QUAC": "QUACK", "QUAC": "QUACK",
"QUACK": "Rich Quack", "QUACK": "Rich Quack",
"QUADRANS": "QuadransToken",
"QUAI": "Quai Network", "QUAI": "Quai Network",
"QUAIN": "QUAIN", "QUAIN": "QUAIN",
"QUAM": "Quam Network", "QUAM": "Quam Network",
@ -13614,6 +13689,7 @@
"RAIF": "RAI Finance", "RAIF": "RAI Finance",
"RAIIN": "Raiin", "RAIIN": "Raiin",
"RAIL": "Railgun", "RAIL": "Railgun",
"RAILS": "Rails Token",
"RAIN": "Rain", "RAIN": "Rain",
"RAINBOW": "Rainbow Token", "RAINBOW": "Rainbow Token",
"RAINC": "RainCheck", "RAINC": "RainCheck",
@ -13744,6 +13820,7 @@
"REALYN": "Real", "REALYN": "Real",
"REAP": "ReapChain", "REAP": "ReapChain",
"REAPER": "Grim Finance", "REAPER": "Grim Finance",
"REAT": "REAT",
"REAU": "Vira-lata Finance", "REAU": "Vira-lata Finance",
"REBD": "REBORN", "REBD": "REBORN",
"REBL": "REBL", "REBL": "REBL",
@ -14557,7 +14634,7 @@
"SENSOR": "Sensor Protocol", "SENSOR": "Sensor Protocol",
"SENSOV1": "SENSO v1", "SENSOV1": "SENSO v1",
"SENSUS": "Sensus", "SENSUS": "Sensus",
"SENT": "Sentinel", "SENT": "Sentient",
"SENTAI": "SentAI", "SENTAI": "SentAI",
"SENTI": "Sentinel Bot Ai", "SENTI": "Sentinel Bot Ai",
"SENTIS": "Sentism AI Token", "SENTIS": "Sentism AI Token",
@ -14626,6 +14703,7 @@
"SGB": "Songbird", "SGB": "Songbird",
"SGDX": "eToro Singapore Dollar", "SGDX": "eToro Singapore Dollar",
"SGE": "Society of Galactic Exploration", "SGE": "Society of Galactic Exploration",
"SGI": "SmartGolfToken",
"SGLY": "Singularity", "SGLY": "Singularity",
"SGN": "Signals Network", "SGN": "Signals Network",
"SGO": "SafuuGO", "SGO": "SafuuGO",
@ -15151,6 +15229,7 @@
"SOETH": "Wrapped Ethereum (Sollet)", "SOETH": "Wrapped Ethereum (Sollet)",
"SOFAC": "SofaCat", "SOFAC": "SofaCat",
"SOFI": "RAI Finance", "SOFI": "RAI Finance",
"SOFION": "SoFi Technologies (Ondo Tokenized)",
"SOFTCO": "SOFT COQ INU", "SOFTCO": "SOFT COQ INU",
"SOFTT": "Wrapped FTT (Sollet)", "SOFTT": "Wrapped FTT (Sollet)",
"SOGNI": "Sogni AI", "SOGNI": "Sogni AI",
@ -15208,6 +15287,7 @@
"SOLIDSEX": "SOLIDsex: Tokenized veSOLID", "SOLIDSEX": "SOLIDsex: Tokenized veSOLID",
"SOLINK": "Wrapped Chainlink (Sollet)", "SOLINK": "Wrapped Chainlink (Sollet)",
"SOLITO": "SOLITO", "SOLITO": "SOLITO",
"SOLKABOSU": "Kabosu",
"SOLKIT": "Solana Kit", "SOLKIT": "Solana Kit",
"SOLLY": "Solly", "SOLLY": "Solly",
"SOLM": "SolMix", "SOLM": "SolMix",
@ -15432,6 +15512,7 @@
"SQG": "Squid Token", "SQG": "Squid Token",
"SQGROW": "SquidGrow", "SQGROW": "SquidGrow",
"SQL": "Squall Coin", "SQL": "Squall Coin",
"SQQQON": "ProShares UltraPro Short QQQ (Ondo Tokenized)",
"SQR": "Magic Square", "SQR": "Magic Square",
"SQRL": "Squirrel Swap", "SQRL": "Squirrel Swap",
"SQT": "SubQuery Network", "SQT": "SubQuery Network",
@ -15519,6 +15600,7 @@
"STAK": "Jigstack", "STAK": "Jigstack",
"STAKE": "xDai Chain", "STAKE": "xDai Chain",
"STAKEDETH": "StakeHound Staked Ether", "STAKEDETH": "StakeHound Staked Ether",
"STAKERDAOWXTZ": "Wrapped Tezos",
"STALIN": "StalinCoin", "STALIN": "StalinCoin",
"STAMP": "SafePost", "STAMP": "SafePost",
"STAN": "Stank Memes", "STAN": "Stank Memes",
@ -16561,7 +16643,7 @@
"TRGI": "The Real Golden Inu", "TRGI": "The Real Golden Inu",
"TRHUB": "Tradehub", "TRHUB": "Tradehub",
"TRI": "Triangles Coin", "TRI": "Triangles Coin",
"TRIA": "Triaconta", "TRIA": "TRIA",
"TRIAS": "Trias", "TRIAS": "Trias",
"TRIBE": "Tribe", "TRIBE": "Tribe",
"TRIBETOKEN": "TribeToken", "TRIBETOKEN": "TribeToken",
@ -16596,6 +16678,7 @@
"TROLLMODE": "TROLL MODE", "TROLLMODE": "TROLL MODE",
"TROLLRUN": "TROLL", "TROLLRUN": "TROLL",
"TROLLS": "trolls in a memes world", "TROLLS": "trolls in a memes world",
"TRONBETLIVE": "TRONbetLive",
"TRONDOG": "TronDog", "TRONDOG": "TronDog",
"TRONI": "Tron Inu", "TRONI": "Tron Inu",
"TRONP": "Donald Tronp", "TRONP": "Donald Tronp",
@ -16603,7 +16686,7 @@
"TROP": "Interop", "TROP": "Interop",
"TROPPY": "TROPPY", "TROPPY": "TROPPY",
"TROSS": "Trossard", "TROSS": "Trossard",
"TROVE": "Arbitrove Governance Token", "TROVE": "TROVE",
"TROY": "Troy", "TROY": "Troy",
"TRP": "Tronipay", "TRP": "Tronipay",
"TRR": "Terran Coin", "TRR": "Terran Coin",
@ -17048,7 +17131,8 @@
"USA": "Based USA", "USA": "Based USA",
"USACOIN": "American Coin", "USACOIN": "American Coin",
"USAGIBNB": "U", "USAGIBNB": "U",
"USAT": "USAT", "USAT": "Tether America USD",
"USATINC": "USAT",
"USBT": "Universal Blockchain", "USBT": "Universal Blockchain",
"USC": "Ultimate Secure Cash", "USC": "Ultimate Secure Cash",
"USCC": "USC", "USCC": "USC",
@ -17087,7 +17171,8 @@
"USDGLOBI": "Globiance USD Stablecoin", "USDGLOBI": "Globiance USD Stablecoin",
"USDGV1": "USDG v1", "USDGV1": "USDG v1",
"USDGV2": "USDG", "USDGV2": "USDG",
"USDH": "USDH Hubble Stablecoin", "USDH": "USDH",
"USDHHUBBLE": "USDH Hubble Stablecoin",
"USDHL": "Hyper USD", "USDHL": "Hyper USD",
"USDI": "Interest Protocol USDi", "USDI": "Interest Protocol USDi",
"USDJ": "USDJ", "USDJ": "USDJ",
@ -17182,8 +17267,9 @@
"UUU": "U Network", "UUU": "U Network",
"UVT": "UvToken", "UVT": "UvToken",
"UW3S": "Utility Web3Shot", "UW3S": "Utility Web3Shot",
"UWU": "UwU Lend", "UWU": "Unlimited Wealth Utility",
"UWUCOIN": "uwu", "UWUCOIN": "uwu",
"UWULEND": "UwU Lend",
"UX": "Umee", "UX": "Umee",
"UXLINK": "UXLINK", "UXLINK": "UXLINK",
"UXLINKV1": "UXLINK v1", "UXLINKV1": "UXLINK v1",
@ -17542,6 +17628,7 @@
"VSYS": "V Systems", "VSYS": "V Systems",
"VT": "Virtual Tourist", "VT": "Virtual Tourist",
"VTC": "Vertcoin", "VTC": "Vertcoin",
"VTCN": "Versatize Coin",
"VTG": "Victory Gem", "VTG": "Victory Gem",
"VTHO": "VeChainThor", "VTHO": "VeChainThor",
"VTIX": "Vanguard xStock", "VTIX": "Vanguard xStock",
@ -17587,6 +17674,7 @@
"VYPER": "VYPER.WIN", "VYPER": "VYPER.WIN",
"VYVO": "Vyvo AI", "VYVO": "Vyvo AI",
"VZ": "Vault Zero", "VZ": "Vault Zero",
"VZON": "Verizon (Ondo Tokenized)",
"VZT": "Vezt", "VZT": "Vezt",
"W": "Wormhole", "W": "Wormhole",
"W1": "W1", "W1": "W1",
@ -17644,7 +17732,7 @@
"WANNA": "Wanna Bot", "WANNA": "Wanna Bot",
"WANUSDT": "wanUSDT", "WANUSDT": "wanUSDT",
"WAP": "Wet Ass Pussy", "WAP": "Wet Ass Pussy",
"WAR": "WeStarter", "WAR": "WAR",
"WARP": "WarpCoin", "WARP": "WarpCoin",
"WARPED": "Warped Games", "WARPED": "Warped Games",
"WARPIE": "Warpie", "WARPIE": "Warpie",
@ -17713,7 +17801,7 @@
"WCFGV1": "Wrapped Centrifuge", "WCFGV1": "Wrapped Centrifuge",
"WCFX": "Wrapped Conflux", "WCFX": "Wrapped Conflux",
"WCG": "World Crypto Gold", "WCG": "World Crypto Gold",
"WCHZ": "Chiliz (Portal Bridge)", "WCHZ": "Wrapped Chiliz",
"WCKB": "Wrapped Nervos Network", "WCKB": "Wrapped Nervos Network",
"WCOIN": "WCoin", "WCOIN": "WCoin",
"WCORE": "Wrapped Core", "WCORE": "Wrapped Core",
@ -17786,6 +17874,7 @@
"WERK": "Werk Family", "WERK": "Werk Family",
"WESHOWTOKEN": "WeShow Token", "WESHOWTOKEN": "WeShow Token",
"WEST": "Waves Enterprise", "WEST": "Waves Enterprise",
"WESTARTER": "WeStarter",
"WET": "HumidiFi Token", "WET": "HumidiFi Token",
"WETH": "WETH", "WETH": "WETH",
"WETHV1": "WETH v1", "WETHV1": "WETH v1",
@ -17830,6 +17919,7 @@
"WHATSONPIC": "WhatsOnPic", "WHATSONPIC": "WhatsOnPic",
"WHBAR": "Wrapped HBAR", "WHBAR": "Wrapped HBAR",
"WHC": "Whales Club", "WHC": "Whales Club",
"WHCHZ": "Chiliz (Portal Bridge)",
"WHEAT": "Wheat Token", "WHEAT": "Wheat Token",
"WHEE": "WHEE (Ordinals)", "WHEE": "WHEE (Ordinals)",
"WHEEL": "Wheelers", "WHEEL": "Wheelers",
@ -18118,6 +18208,7 @@
"WUF": "WUFFI", "WUF": "WUFFI",
"WUK": "WUKONG", "WUK": "WUKONG",
"WUKONG": "Sun Wukong", "WUKONG": "Sun Wukong",
"WULFON": "Terawulf (Ondo Tokenized)",
"WULFY": "Wulfy", "WULFY": "Wulfy",
"WUM": "Unicorn Meat", "WUM": "Unicorn Meat",
"WUSD": "Worldwide USD", "WUSD": "Worldwide USD",
@ -18143,7 +18234,6 @@
"WXPL": "Wrapped XPL", "WXPL": "Wrapped XPL",
"WXRP": "Wrapped XRP", "WXRP": "Wrapped XRP",
"WXT": "WXT", "WXT": "WXT",
"WXTZ": "Wrapped Tezos",
"WYAC": "Woman Yelling At Cat", "WYAC": "Woman Yelling At Cat",
"WYN": "Wynn", "WYN": "Wynn",
"WYNN": "Anita Max Wynn", "WYNN": "Anita Max Wynn",
@ -18186,6 +18276,7 @@
"XAS": "Asch", "XAS": "Asch",
"XAT": "ShareAt", "XAT": "ShareAt",
"XAUC": "XauCoin", "XAUC": "XauCoin",
"XAUH": "Herculis Gold Coin",
"XAUM": "Matrixdock Gold", "XAUM": "Matrixdock Gold",
"XAUR": "Xaurum", "XAUR": "Xaurum",
"XAUT": "Tether Gold", "XAUT": "Tether Gold",
@ -18778,7 +18869,8 @@
"ZEBU": "ZEBU", "ZEBU": "ZEBU",
"ZEC": "ZCash", "ZEC": "ZCash",
"ZECD": "ZCashDarkCoin", "ZECD": "ZCashDarkCoin",
"ZED": "ZedCoins", "ZED": "ZED Token",
"ZEDCOIN": "ZedCoin",
"ZEDD": "ZedDex", "ZEDD": "ZedDex",
"ZEDTOKEN": "Zed Token", "ZEDTOKEN": "Zed Token",
"ZEDX": "ZEDX Сoin", "ZEDX": "ZEDX Сoin",
@ -18932,6 +19024,7 @@
"ZOOM": "ZoomCoin", "ZOOM": "ZoomCoin",
"ZOOMER": "Zoomer Coin", "ZOOMER": "Zoomer Coin",
"ZOON": "CryptoZoon", "ZOON": "CryptoZoon",
"ZOOSTORY": "ZOO",
"ZOOT": "Zoo Token", "ZOOT": "Zoo Token",
"ZOOTOPIA": "Zootopia", "ZOOTOPIA": "Zootopia",
"ZORA": "Zora", "ZORA": "Zora",
@ -19009,5 +19102,13 @@
"vXDEFI": "vXDEFI", "vXDEFI": "vXDEFI",
"wsOHM": "Wrapped Staked Olympus", "wsOHM": "Wrapped Staked Olympus",
"修仙": "修仙", "修仙": "修仙",
"币安人生": "币安人生" "分红狗头": "分红狗头",
"哭哭马": "哭哭马",
"安": "安",
"币安人生": "币安人生",
"恶俗企鹅": "恶俗企鹅",
"我踏马来了": "我踏马来了",
"老子": "老子",
"雪球": "雪球",
"黑马": "黑马"
} }

139
apps/api/src/helper/object.helper.spec.ts

@ -1,4 +1,6 @@
import { query, redactAttributes } from './object.helper'; import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
import { query, redactPaths } from './object.helper';
describe('query', () => { describe('query', () => {
it('should get market price from stock API response', () => { it('should get market price from stock API response', () => {
@ -22,46 +24,38 @@ describe('query', () => {
describe('redactAttributes', () => { describe('redactAttributes', () => {
it('should redact provided attributes', () => { it('should redact provided attributes', () => {
expect(redactAttributes({ object: {}, options: [] })).toStrictEqual({}); expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({});
expect( expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({
redactAttributes({ object: { value: 1000 }, options: [] }) value: 1000
).toStrictEqual({ value: 1000 }); });
expect( expect(
redactAttributes({ redactPaths({
object: { value: 1000 }, object: { value: 1000 },
options: [{ attribute: 'value', valueMap: { '*': null } }] paths: ['value']
}) })
).toStrictEqual({ value: null }); ).toStrictEqual({ value: null });
expect( expect(
redactAttributes({ redactPaths({
object: { value: 'abc' }, object: { value: 'abc' },
options: [{ attribute: 'value', valueMap: { abc: 'xyz' } }] paths: ['value'],
valueMap: { abc: 'xyz' }
}) })
).toStrictEqual({ value: 'xyz' }); ).toStrictEqual({ value: 'xyz' });
expect( expect(
redactAttributes({ redactPaths({
object: { data: [{ value: 'a' }, { value: 'b' }] }, object: { data: [{ value: 'a' }, { value: 'b' }] },
options: [{ attribute: 'value', valueMap: { a: 1, b: 2 } }] paths: ['data[*].value'],
valueMap: { a: 1, b: 2 }
}) })
).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] }); ).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] });
expect(
redactAttributes({
object: { value1: 'a', value2: 'b' },
options: [
{ attribute: 'value1', valueMap: { a: 'x' } },
{ attribute: 'value2', valueMap: { '*': 'y' } }
]
})
).toStrictEqual({ value1: 'x', value2: 'y' });
console.time('redactAttributes execution time'); console.time('redactAttributes execution time');
expect( expect(
redactAttributes({ redactPaths({
object: { object: {
accounts: { accounts: {
'2e937c05-657c-4de9-8fb3-0813a2245f26': { '2e937c05-657c-4de9-8fb3-0813a2245f26': {
@ -117,6 +111,7 @@ describe('redactAttributes', () => {
hasError: false, hasError: false,
holdings: { holdings: {
'AAPL.US': { 'AAPL.US': {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -136,7 +131,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79, marketPrice: 220.79,
symbol: 'AAPL.US', symbol: 'AAPL.US',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135, allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -169,6 +163,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { 'ALV.DE': {
activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -188,7 +183,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5, marketPrice: 296.5,
symbol: 'ALV.DE', symbol: 'ALV.DE',
tags: [], tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527, allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -216,6 +210,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { AMZN: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -235,7 +230,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99, marketPrice: 187.99,
symbol: 'AMZN', symbol: 'AMZN',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275, allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -268,6 +262,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { bitcoin: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 36985.0332704, UNKNOWN: 36985.0332704,
@ -293,7 +288,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654, allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY', assetSubClass: 'CRYPTOCURRENCY',
@ -319,6 +313,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 2231.644722160232, UNKNOWN: 2231.644722160232,
@ -344,7 +339,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777, allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME', assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND', assetSubClass: 'BOND',
@ -370,6 +364,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -395,7 +390,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335, allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -494,6 +488,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { MSFT: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -513,7 +508,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02, marketPrice: 428.02,
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627, allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -546,6 +540,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { TSLA: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -565,7 +560,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46, marketPrice: 260.46,
symbol: 'TSLA', symbol: 'TSLA',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352, allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -598,6 +592,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { VTI: {
activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -617,7 +612,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05, marketPrice: 282.05,
symbol: 'VTI', symbol: 'VTI',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366, allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -770,6 +764,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { 'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -789,7 +784,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62, marketPrice: 117.62,
symbol: 'VWRL.SW', symbol: 'VWRL.SW',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013, allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1178,6 +1172,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { 'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1197,7 +1192,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72, marketPrice: 105.72,
symbol: 'XDWD.DE', symbol: 'XDWD.DE',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562, allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1456,6 +1450,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { USD: {
activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
@ -1478,7 +1473,6 @@ describe('redactAttributes', () => {
sectors: [], sectors: [],
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: 49890, valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }
@ -1564,34 +1558,7 @@ describe('redactAttributes', () => {
currentNetWorth: null currentNetWorth: null
} }
}, },
options: [ paths: DEFAULT_REDACTED_PATHS
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
}) })
).toStrictEqual({ ).toStrictEqual({
accounts: { accounts: {
@ -1648,6 +1615,7 @@ describe('redactAttributes', () => {
hasError: false, hasError: false,
holdings: { holdings: {
'AAPL.US': { 'AAPL.US': {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1667,7 +1635,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79, marketPrice: 220.79,
symbol: 'AAPL.US', symbol: 'AAPL.US',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135, allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1681,7 +1648,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'EOD_HISTORICAL_DATA', dataSource: 'EOD_HISTORICAL_DATA',
dateOfFirstActivity: '2021-11-30T23:00:00.000Z', dateOfFirstActivity: '2021-11-30T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3183066634822068, grossPerformancePercent: 0.3183066634822068,
grossPerformancePercentWithCurrencyEffect: 0.3183066634822068, grossPerformancePercentWithCurrencyEffect: 0.3183066634822068,
@ -1700,6 +1667,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { 'ALV.DE': {
activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1719,7 +1687,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5, marketPrice: 296.5,
symbol: 'ALV.DE', symbol: 'ALV.DE',
tags: [], tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527, allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1728,7 +1695,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-04-22T22:00:00.000Z', dateOfFirstActivity: '2021-04-22T22:00:00.000Z',
dividend: 192, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3719230057375532, grossPerformancePercent: 0.3719230057375532,
grossPerformancePercentWithCurrencyEffect: 0.2650716044872953, grossPerformancePercentWithCurrencyEffect: 0.2650716044872953,
@ -1747,6 +1714,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { AMZN: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1766,7 +1734,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99, marketPrice: 187.99,
symbol: 'AMZN', symbol: 'AMZN',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275, allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1780,7 +1747,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2018-09-30T22:00:00.000Z', dateOfFirstActivity: '2018-09-30T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.8594552890963852, grossPerformancePercent: 0.8594552890963852,
grossPerformancePercentWithCurrencyEffect: 0.8594552890963852, grossPerformancePercentWithCurrencyEffect: 0.8594552890963852,
@ -1799,6 +1766,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { bitcoin: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 36985.0332704, UNKNOWN: 36985.0332704,
@ -1824,14 +1792,13 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654, allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY', assetSubClass: 'CRYPTOCURRENCY',
countries: [], countries: [],
dataSource: 'COINGECKO', dataSource: 'COINGECKO',
dateOfFirstActivity: '2017-08-15T22:00:00.000Z', dateOfFirstActivity: '2017-08-15T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 17.4925166352, grossPerformancePercent: 17.4925166352,
grossPerformancePercentWithCurrencyEffect: 17.4925166352, grossPerformancePercentWithCurrencyEffect: 17.4925166352,
@ -1850,6 +1817,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 2231.644722160232, UNKNOWN: 2231.644722160232,
@ -1875,14 +1843,13 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777, allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME', assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND', assetSubClass: 'BOND',
countries: [], countries: [],
dataSource: 'MANUAL', dataSource: 'MANUAL',
dateOfFirstActivity: '2021-01-31T23:00:00.000Z', dateOfFirstActivity: '2021-01-31T23:00:00.000Z',
dividend: 11.45, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: -0.06153834320225245, grossPerformancePercentWithCurrencyEffect: -0.06153834320225245,
@ -1901,6 +1868,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1926,7 +1894,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335, allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1986,7 +1953,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'MANUAL', dataSource: 'MANUAL',
dateOfFirstActivity: '2021-03-31T22:00:00.000Z', dateOfFirstActivity: '2021-03-31T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.27579517683678895, grossPerformancePercent: 0.27579517683678895,
grossPerformancePercentWithCurrencyEffect: 0.458553421589667, grossPerformancePercentWithCurrencyEffect: 0.458553421589667,
@ -2005,6 +1972,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { MSFT: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2024,7 +1992,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02, marketPrice: 428.02,
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627, allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -2038,7 +2005,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-02T23:00:00.000Z', dateOfFirstActivity: '2023-01-02T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.7865431171216295, grossPerformancePercent: 0.7865431171216295,
grossPerformancePercentWithCurrencyEffect: 0.7865431171216295, grossPerformancePercentWithCurrencyEffect: 0.7865431171216295,
@ -2057,6 +2024,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { TSLA: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2076,7 +2044,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46, marketPrice: 260.46,
symbol: 'TSLA', symbol: 'TSLA',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352, allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -2090,7 +2057,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2017-01-02T23:00:00.000Z', dateOfFirstActivity: '2017-01-02T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 17.184314638161936, grossPerformancePercent: 17.184314638161936,
grossPerformancePercentWithCurrencyEffect: 17.184314638161936, grossPerformancePercentWithCurrencyEffect: 17.184314638161936,
@ -2109,6 +2076,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { VTI: {
activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2128,7 +2096,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05, marketPrice: 282.05,
symbol: 'VTI', symbol: 'VTI',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366, allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2172,7 +2139,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2019-02-28T23:00:00.000Z', dateOfFirstActivity: '2019-02-28T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.8832083851170418, grossPerformancePercent: 0.8832083851170418,
grossPerformancePercentWithCurrencyEffect: 0.8832083851170418, grossPerformancePercentWithCurrencyEffect: 0.8832083851170418,
@ -2281,6 +2248,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { 'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2300,7 +2268,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62, marketPrice: 117.62,
symbol: 'VWRL.SW', symbol: 'VWRL.SW',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013, allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2567,7 +2534,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2018-02-28T23:00:00.000Z', dateOfFirstActivity: '2018-02-28T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3683200415015591, grossPerformancePercent: 0.3683200415015591,
grossPerformancePercentWithCurrencyEffect: 0.5806366182968891, grossPerformancePercentWithCurrencyEffect: 0.5806366182968891,
@ -2681,6 +2648,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { 'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2700,7 +2668,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72, marketPrice: 105.72,
symbol: 'XDWD.DE', symbol: 'XDWD.DE',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562, allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2846,7 +2813,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-08-18T22:00:00.000Z', dateOfFirstActivity: '2021-08-18T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3474381850624522, grossPerformancePercent: 0.3474381850624522,
grossPerformancePercentWithCurrencyEffect: 0.28744846894552306, grossPerformancePercentWithCurrencyEffect: 0.28744846894552306,
@ -2959,12 +2926,13 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { USD: {
activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CASH', assetSubClass: 'CASH',
countries: [], countries: [],
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
@ -2981,7 +2949,6 @@ describe('redactAttributes', () => {
sectors: [], sectors: [],
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }

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

@ -1,6 +1,6 @@
import { Big } from 'big.js'; import fastRedact from 'fast-redact';
import jsonpath from 'jsonpath'; import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) { for (const key in aObject) {
@ -42,60 +42,29 @@ export function query({
return jsonpath.query(object, pathExpression); return jsonpath.query(object, pathExpression);
} }
export function redactAttributes({ export function redactPaths({
isFirstRun = true,
object, object,
options paths,
valueMap
}: { }: {
isFirstRun?: boolean;
object: any; object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[]; paths: fastRedact.RedactOptions['paths'];
valueMap?: { [key: string]: any };
}): any { }): any {
if (!object || !options?.length) { const redact = fastRedact({
return object; paths,
} censor: (value) => {
if (valueMap) {
// Create deep clone if (valueMap[value]) {
const redactedObject = isFirstRun return valueMap[value];
? JSON.parse(JSON.stringify(object)) } else {
: object; return value;
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
}
);
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
isFirstRun: false,
object: redactedObject[property]
});
} }
} else {
return null;
} }
} }
} });
return redactedObject; return JSON.parse(redact(object));
} }

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

@ -1,5 +1,8 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DEFAULT_REDACTED_PATHS,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
isRestrictedView isRestrictedView
@ -39,40 +42,9 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
}) || }) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {
data = redactAttributes({ data = redactPaths({
object: data, object: data,
options: [ paths: DEFAULT_REDACTED_PATHS
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
}); });
} }

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

@ -1,4 +1,4 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { encodeDataSource } from '@ghostfolio/common/helper'; import { encodeDataSource } from '@ghostfolio/common/helper';
@ -58,13 +58,18 @@ export class TransformDataSourceInResponseInterceptor<
} }
} }
data = redactAttributes({ data = redactPaths({
valueMap,
object: data, object: data,
options: [ paths: [
{ 'activities[*].SymbolProfile.dataSource',
valueMap, 'benchmarks[*].dataSource',
attribute: 'dataSource' 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
} 'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
] ]
}); });
} }

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

@ -206,26 +206,26 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
); );
if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) { if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.sectors = []; response.holdings =
assetProfile.topHoldings?.holdings?.map(
for (const sectorWeighting of assetProfile.topHoldings ({ holdingName, holdingPercent }) => {
?.sectorWeightings ?? []) { return {
for (const [sector, weight] of Object.entries(sectorWeighting)) { name: this.formatName({ longName: holdingName }),
response.sectors.push({ weight: holdingPercent
};
}
) ?? [];
response.sectors = (
assetProfile.topHoldings?.sectorWeightings ?? []
).flatMap((sectorWeighting) => {
return Object.entries(sectorWeighting).map(([sector, weight]) => {
return {
name: this.parseSector(sector), name: this.parseSector(sector),
weight: weight as number weight: weight as number
});
}
}
response.holdings = assetProfile.topHoldings.holdings.map(
({ holdingName, holdingPercent }) => {
return {
name: this.formatName({ longName: holdingName }),
weight: holdingPercent
}; };
} });
); });
} else if ( } else if (
assetSubClass === 'STOCK' && assetSubClass === 'STOCK' &&
assetProfile.summaryProfile?.country assetProfile.summaryProfile?.country

16
apps/api/src/services/tag/tag.service.ts

@ -75,12 +75,16 @@ export class TagService {
} }
}); });
return tags.map(({ _count, id, name, userId }) => ({ return tags
id, .map(({ _count, id, name, userId }) => ({
name, id,
userId, name,
isUsed: _count.activities > 0 userId,
})); isUsed: _count.activities > 0
}))
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
} }
public async getTagsWithActivityCount() { public async getTagsWithActivityCount() {

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

@ -80,6 +80,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances']; public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public activitiesCount: number;
public balance: number; public balance: number;
public balancePrecision = 2; public balancePrecision = 2;
public currency: string; public currency: string;
@ -100,7 +101,6 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public totalItems: number; public totalItems: number;
public transactionCount: number;
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
@ -215,16 +215,17 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
activitiesCount,
balance, balance,
currency, currency,
dividendInBaseCurrency, dividendInBaseCurrency,
interestInBaseCurrency, interestInBaseCurrency,
name, name,
platform, platform,
transactionCount,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
this.activitiesCount = activitiesCount;
this.balance = balance; this.balance = balance;
if ( if (
@ -270,7 +271,6 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.name = name; this.name = name;
this.platformName = platform?.name ?? '-'; this.platformName = platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

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

@ -82,7 +82,7 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="transactionCount" <gf-value i18n size="medium" [value]="activitiesCount"
>Activities</gf-value >Activities</gf-value
> >
</div> </div>
@ -102,8 +102,6 @@
<div class="d-none d-sm-block ml-2" i18n>Holdings</div> <div class="d-none d-sm-block ml-2" i18n>Holdings</div>
</ng-template> </ng-template>
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

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

@ -57,7 +57,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { switchMap, takeUntil, tap } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
@ -139,8 +139,25 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
]; ];
} }
this.route.paramMap this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
takeUntil(this.unsubscribeSubject),
tap((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToImpersonateAllUsers = hasPermission(
this.user.permissions,
permissions.impersonateAllUsers
);
}
}),
switchMap(() => this.route.paramMap)
)
.subscribe((params) => { .subscribe((params) => {
const userId = params.get('userId'); const userId = params.get('userId');
@ -149,23 +166,6 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
} }
}); });
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToImpersonateAllUsers = hasPermission(
this.user.permissions,
permissions.impersonateAllUsers
);
}
});
addIcons({ addIcons({
contractOutline, contractOutline,
ellipsisHorizontal, ellipsisHorizontal,
@ -208,10 +208,13 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId) .deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchUsers(); this.router.navigate(['..'], { relativeTo: this.route });
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
discardFn: () => {
this.router.navigate(['..'], { relativeTo: this.route });
},
title: $localize`Do you really want to delete this user?` title: $localize`Do you really want to delete this user?`
}); });
} }
@ -293,6 +296,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
>(GfUserDetailDialogComponent, { >(GfUserDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
currentUserId: this.user?.id,
deviceType: this.deviceType, deviceType: this.deviceType,
hasPermissionForSubscription: this.hasPermissionForSubscription, hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale, locale: this.user?.settings?.locale,
@ -305,10 +309,14 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((data) => {
this.router.navigate( if (data?.action === 'delete' && data?.userId) {
internalRoutes.adminControl.subRoutes.users.routerLink this.onDeleteUser(data.userId);
); } else {
this.router.navigate(
internalRoutes.adminControl.subRoutes.users.routerLink
);
}
}); });
} }
} }

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

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
@ -15,12 +14,14 @@ import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
type ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
@ -42,7 +43,7 @@ import {
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
@ -78,7 +79,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Output() benchmarkChanged = new EventEmitter<string>(); @Output() benchmarkChanged = new EventEmitter<string>();
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'line'>; public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -96,8 +97,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
addIcons({ arrowForwardOutline }); addIcons({ arrowForwardOutline });
} }
@ -157,8 +157,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -196,7 +198,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -253,7 +255,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -261,7 +263,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
unit: '%' unit: '%'
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

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

@ -377,13 +377,12 @@
<gf-accounts-table <gf-accounts-table
[accounts]="accounts" [accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActivitiesCount]="false"
[showAllocationInPercentage]="user?.settings?.isExperimentalFeatures" [showAllocationInPercentage]="user?.settings?.isExperimentalFeatures"
[showBalance]="false" [showBalance]="false"
[showFooter]="false" [showFooter]="false"
[showTransactions]="false"
[showValue]="false" [showValue]="false"
[showValueInBaseCurrency]="false" [showValueInBaseCurrency]="false"
/> />

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

@ -46,8 +46,6 @@
} }
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }"> <div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToShowQuantities]="false" [hasPermissionToShowQuantities]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

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

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin, getVerticalHoverLinePlugin,
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
@ -15,11 +14,13 @@ import {
import { LineChartItem } from '@ghostfolio/common/interfaces'; import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
type ElementRef,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
@ -34,12 +35,15 @@ import {
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
type ScriptableLineSegmentContext,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin, {
type AnnotationOptions
} from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns'; import { isAfter } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -62,7 +66,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'bar' | 'line'>; public chart: Chart<'bar' | 'line'>;
private investments: InvestmentItem[]; private investments: InvestmentItem[];
@ -81,8 +85,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
} }
public ngOnChanges() { public ngOnChanges() {
@ -121,12 +124,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}), }),
label: this.benchmarkDataLabel, label: this.benchmarkDataLabel,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context) =>
this.isInFuture( this.isInFuture(
context, context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)` `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
), ),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) borderDash: (context) => this.isInFuture(context, [2, 2])
}, },
stepped: true stepped: true
}, },
@ -143,12 +146,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
label: $localize`Total Amount`, label: $localize`Total Amount`,
pointRadius: 0, pointRadius: 0,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context) =>
this.isInFuture( this.isInFuture(
context, context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)` `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
), ),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) borderDash: (context) => this.isInFuture(context, [2, 2])
} }
} }
] ]
@ -157,17 +160,14 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = chartData; this.chart.data = chartData;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
if ( const annotations = this.chart.options.plugins.annotation
this.savingsRate && .annotations as Record<string, AnnotationOptions<'line'>>;
// @ts-ignore if (this.savingsRate && annotations.savingsRate) {
this.chart.options.plugins.annotation.annotations.savingsRate annotations.savingsRate.value = this.savingsRate;
) {
// @ts-ignore
this.chart.options.plugins.annotation.annotations.savingsRate.value =
this.savingsRate;
} }
this.chart.update(); this.chart.update();
@ -201,7 +201,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
color: 'white', color: 'white',
content: $localize`Savings Rate`, content: $localize`Savings Rate`,
display: true, display: true,
font: { size: '10px', weight: 'normal' }, font: { size: 10, weight: 'normal' },
padding: { padding: {
x: 4, x: 4,
y: 2 y: 2
@ -229,7 +229,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -286,7 +286,9 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
} }
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<
TooltipOptions<'bar' | 'line'>
> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -296,13 +298,13 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
unit: this.isInPercent ? '%' : undefined unit: this.isInPercent ? '%' : undefined
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };
} }
private isInFuture<T>(aContext: any, aValue: T) { private isInFuture<T>(aContext: ScriptableLineSegmentContext, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue ? aValue
: undefined; : undefined;

1
apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts

@ -1,4 +1,5 @@
export interface UserDetailDialogParams { export interface UserDetailDialogParams {
currentUserId: string;
deviceType: string; deviceType: string;
hasPermissionForSubscription: boolean; hasPermissionForSubscription: boolean;
locale: string; locale: string;

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

@ -1,6 +1,4 @@
import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -16,6 +14,10 @@ import {
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
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 { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -25,11 +27,11 @@ import { UserDetailDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfValueComponent, GfValueComponent,
IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule MatDialogModule,
MatMenuModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog', selector: 'gf-user-detail-dialog',
@ -46,7 +48,11 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent> public dialogRef: MatDialogRef<GfUserDetailDialogComponent>
) {} ) {
addIcons({
ellipsisVertical
});
}
public ngOnInit() { public ngOnInit() {
this.adminService this.adminService
@ -66,6 +72,13 @@ export class GfUserDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public deleteUser() {
this.dialogRef.close({
action: 'delete',
userId: this.data.userId
});
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

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

@ -1,9 +1,28 @@
<gf-dialog-header <div class="d-flex justify-content-end">
position="center" <button
[deviceType]="data.deviceType" class="mx-1 no-min-width px-2"
(closeButtonClicked)="onClose()" mat-button
/> type="button"
[matMenuTriggerFor]="userDetailActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu
#userDetailActionsMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button
mat-menu-item
type="button"
[disabled]="this.data.currentUserId === this.data.userId"
(click)="deleteUser()"
>
<ng-container i18n>Delete</ng-container>
</button>
</mat-menu>
</div>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div class="container p-0"> <div class="container p-0">
<div class="mb-3 row"> <div class="mb-3 row">
@ -103,7 +122,8 @@
</div> </div>
</div> </div>
<gf-dialog-footer <div class="justify-content-end" mat-dialog-actions>
[deviceType]="data.deviceType" <button mat-button type="button" (click)="onClose()">
(closeButtonClicked)="onClose()" <ng-container i18n>Close</ng-container>
/> </button>
</div>

8
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -209,7 +209,7 @@
<div class="text-center"> <div class="text-center">
<div class="mb-2 small">Browser testing via</div> <div class="mb-2 small">Browser testing via</div>
<a <a
href="https://www.testmu.ai?utm_medium=sponsor&utm_source=ghostfolio" href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio"
target="_blank" target="_blank"
title="TestMu AI - AI Powered Testing Tool" title="TestMu AI - AI Powered Testing Tool"
> >
@ -217,9 +217,9 @@
alt="TestMu AI Logo" alt="TestMu AI Logo"
height="32" height="32"
[src]=" [src]="
user?.settings?.colorScheme === 'LIGHT' user?.settings?.colorScheme === 'DARK'
? '../assets/images/sponsors/logo-testmu-dark.svg' ? '../assets/images/sponsors/logo-testmu-light.svg'
: '../assets/images/sponsors/logo-testmu-light.svg' : '../assets/images/sponsors/logo-testmu-dark.svg'
" "
/> />
</a> </a>

10
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -38,6 +38,7 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
}) })
export class GfAccountsPageComponent implements OnDestroy, OnInit { export class GfAccountsPageComponent implements OnDestroy, OnInit {
public accounts: AccountModel[]; public accounts: AccountModel[];
public activitiesCount = 0;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateAccount: boolean; public hasPermissionToCreateAccount: boolean;
@ -45,7 +46,6 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public totalBalanceInBaseCurrency = 0; public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0; public totalValueInBaseCurrency = 0;
public transactionCount = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -128,14 +128,14 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
.subscribe( .subscribe(
({ ({
accounts, accounts,
activitiesCount,
totalBalanceInBaseCurrency, totalBalanceInBaseCurrency,
totalValueInBaseCurrency, totalValueInBaseCurrency
transactionCount
}) => { }) => {
this.accounts = accounts; this.accounts = accounts;
this.activitiesCount = activitiesCount;
this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency; this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency;
this.totalValueInBaseCurrency = totalValueInBaseCurrency; this.totalValueInBaseCurrency = totalValueInBaseCurrency;
this.transactionCount = transactionCount;
if (this.accounts?.length <= 0) { if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
@ -358,8 +358,8 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
private reset() { private reset() {
this.accounts = undefined; this.accounts = undefined;
this.activitiesCount = 0;
this.totalBalanceInBaseCurrency = 0; this.totalBalanceInBaseCurrency = 0;
this.totalValueInBaseCurrency = 0; this.totalValueInBaseCurrency = 0;
this.transactionCount = 0;
} }
} }

3
apps/client/src/app/pages/accounts/accounts-page.html

@ -4,8 +4,8 @@
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Accounts</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Accounts</h1>
<gf-accounts-table <gf-accounts-table
[accounts]="accounts" [accounts]="accounts"
[activitiesCount]="activitiesCount"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!hasImpersonationId && !hasImpersonationId &&
@ -14,7 +14,6 @@
" "
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)" (accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)" (accountToUpdate)="onUpdateAccount($event)"
(transferBalance)="onTransferBalance()" (transferBalance)="onTransferBalance()"

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

@ -78,7 +78,6 @@
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
/> />
<gf-holdings-table <gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false" [hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false" [hasPermissionToShowValues]="false"

4
libs/common/src/lib/calculation-helper.ts

@ -9,7 +9,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isNumber } from 'lodash'; import { isFinite, isNumber } from 'lodash';
import { resetHours } from './helper'; import { resetHours } from './helper';
import { DateRange } from './types'; import { DateRange } from './types';
@ -28,7 +28,7 @@ export function getAnnualizedPerformancePercent({
exponent exponent
); );
if (!isNaN(growthFactor)) { if (isFinite(growthFactor)) {
return new Big(growthFactor).minus(1); return new Big(growthFactor).minus(1);
} }
} }

55
libs/common/src/lib/chart-helper.ts

@ -1,8 +1,11 @@
import type { ElementRef } from '@angular/core'; import type { ElementRef } from '@angular/core';
import type { import type {
Chart, Chart,
ChartTypeRegistry, ChartType,
ControllerDatasetOptions,
Plugin, Plugin,
Point,
TooltipOptions,
TooltipPosition TooltipPosition
} from 'chart.js'; } from 'chart.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -21,7 +24,7 @@ export function formatGroupedDate({
date, date,
groupBy groupBy
}: { }: {
date: Date; date: number;
groupBy: GroupBy; groupBy: GroupBy;
}) { }) {
if (groupBy === 'month') { if (groupBy === 'month') {
@ -33,7 +36,7 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT); return format(date, DATE_FORMAT);
} }
export function getTooltipOptions({ export function getTooltipOptions<T extends ChartType>({
colorScheme, colorScheme,
currency = '', currency = '',
groupBy, groupBy,
@ -45,35 +48,43 @@ export function getTooltipOptions({
groupBy?: GroupBy; groupBy?: GroupBy;
locale?: string; locale?: string;
unit?: string; unit?: string;
}) { }): Partial<TooltipOptions<T>> {
return { return {
backgroundColor: getBackgroundColor(colorScheme), backgroundColor: getBackgroundColor(colorScheme),
bodyColor: `rgb(${getTextColor(colorScheme)})`, bodyColor: `rgb(${getTextColor(colorScheme)})`,
borderWidth: 1, borderWidth: 1,
borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`, borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`,
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context) => { label: (context) => {
let label = context.dataset.label ?? ''; let label = (context.dataset as ControllerDatasetOptions).label ?? '';
if (label) { if (label) {
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) {
const yPoint = (context.parsed as Point).y;
if (yPoint !== null) {
if (currency) { if (currency) {
label += `${context.parsed.y.toLocaleString(locale, { label += `${yPoint.toLocaleString(locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
})} ${currency}`; })} ${currency}`;
} else if (unit) { } else if (unit) {
label += `${context.parsed.y.toFixed(2)} ${unit}`; label += `${yPoint.toFixed(2)} ${unit}`;
} else { } else {
label += context.parsed.y.toFixed(2); label += yPoint.toFixed(2);
} }
} }
return label; return label;
}, },
title: (contexts) => { title: (contexts) => {
if (groupBy) { const xPoint = (contexts[0].parsed as Point).x;
return formatGroupedDate({ groupBy, date: contexts[0].parsed.x });
if (groupBy && xPoint !== null) {
return formatGroupedDate({ groupBy, date: xPoint });
} }
return contexts[0].label; return contexts[0].label;
@ -98,16 +109,17 @@ export function getTooltipPositionerMapTop(
if (!position || !chart?.chartArea) { if (!position || !chart?.chartArea) {
return false; return false;
} }
return { return {
x: position.x, x: position.x,
y: chart.chartArea.top y: chart.chartArea.top
}; };
} }
export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>( export function getVerticalHoverLinePlugin<T extends 'line' | 'bar'>(
chartCanvas: ElementRef, chartCanvas: ElementRef<HTMLCanvasElement>,
colorScheme: ColorScheme colorScheme: ColorScheme
): Plugin<T> { ): Plugin<T, { color: string; width: number }> {
return { return {
afterDatasetsDraw: (chart, _, options) => { afterDatasetsDraw: (chart, _, options) => {
const active = chart.getActiveElements(); const active = chart.getActiveElements();
@ -125,13 +137,16 @@ export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>(
const xValue = active[0].element.x; const xValue = active[0].element.x;
const context = chartCanvas.nativeElement.getContext('2d'); const context = chartCanvas.nativeElement.getContext('2d');
context.lineWidth = width;
context.strokeStyle = color;
context.beginPath(); if (context) {
context.moveTo(xValue, top); context.lineWidth = width;
context.lineTo(xValue, bottom); context.strokeStyle = color;
context.stroke();
context.beginPath();
context.moveTo(xValue, top);
context.lineTo(xValue, bottom);
context.stroke();
}
}, },
id: 'verticalHoverLine' id: 'verticalHoverLine'
}; };

52
libs/common/src/lib/config.ts

@ -78,6 +78,58 @@ export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000;
export const DEFAULT_REDACTED_PATHS = [
'accounts[*].balance',
'accounts[*].valueInBaseCurrency',
'activities[*].account.balance',
'activities[*].account.comment',
'activities[*].comment',
'activities[*].fee',
'activities[*].feeInAssetProfileCurrency',
'activities[*].feeInBaseCurrency',
'activities[*].quantity',
'activities[*].SymbolProfile.symbolMapping',
'activities[*].SymbolProfile.watchedByCount',
'activities[*].value',
'activities[*].valueInBaseCurrency',
'balance',
'balanceInBaseCurrency',
'balances[*].account.balance',
'balances[*].account.comment',
'balances[*].value',
'balances[*].valueInBaseCurrency',
'comment',
'dividendInBaseCurrency',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'historicalData[*].quantity',
'holdings[*].dividend',
'holdings[*].grossPerformance',
'holdings[*].grossPerformanceWithCurrencyEffect',
'holdings[*].holdings[*].valueInBaseCurrency',
'holdings[*].investment',
'holdings[*].netPerformance',
'holdings[*].netPerformanceWithCurrencyEffect',
'holdings[*].quantity',
'holdings[*].valueInBaseCurrency',
'interestInBaseCurrency',
'investmentInBaseCurrencyWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'platforms[*].balance',
'platforms[*].valueInBaseCurrency',
'quantity',
'SymbolProfile.symbolMapping',
'SymbolProfile.watchedByCount',
'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'value',
'valueInBaseCurrency'
];
// USX is handled separately // USX is handled separately
export const DERIVED_CURRENCIES = [ export const DERIVED_CURRENCIES = [
{ {

2
libs/common/src/lib/interfaces/activities.interface.ts

@ -8,7 +8,7 @@ export interface Activity extends Order {
error?: ActivityError; error?: ActivityError;
feeInAssetProfileCurrency: number; feeInAssetProfileCurrency: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tagIds?: string[]; tagIds?: string[];
tags?: Tag[]; tags?: Tag[];
unitPriceInAssetProfileCurrency: number; unitPriceInAssetProfileCurrency: number;

4
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -7,10 +7,6 @@ export interface AdminData {
useForExchangeRates: boolean; useForExchangeRates: boolean;
})[]; })[];
settings: { [key: string]: boolean | object | string | string[] }; settings: { [key: string]: boolean | object | string | string[] };
/** @deprecated use activitiesCount instead */
transactionCount: number;
userCount: number; userCount: number;
version: string; version: string;
} }

4
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -39,10 +39,6 @@ export interface PortfolioPosition {
sectors: Sector[]; sectors: Sector[];
symbol: string; symbol: string;
tags?: Tag[]; tags?: Tag[];
/** @deprecated use activitiesCount instead */
transactionCount: number;
type?: string; type?: string;
url?: string; url?: string;
valueInBaseCurrency?: number; valueInBaseCurrency?: number;

3
libs/common/src/lib/interfaces/responses/accounts-response.interface.ts

@ -7,7 +7,4 @@ export interface AccountsResponse {
totalDividendInBaseCurrency: number; totalDividendInBaseCurrency: number;
totalInterestInBaseCurrency: number; totalInterestInBaseCurrency: number;
totalValueInBaseCurrency: number; totalValueInBaseCurrency: number;
/** @deprecated use activitiesCount instead */
transactionCount: number;
} }

3
libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts

@ -1,6 +1,3 @@
export interface CreateStripeCheckoutSessionResponse { export interface CreateStripeCheckoutSessionResponse {
/** @deprecated */
sessionId: string;
sessionUrl: string; sessionUrl: string;
} }

3
libs/common/src/lib/models/timeline-position.ts

@ -93,9 +93,6 @@ export class TimelinePosition {
@Type(() => Big) @Type(() => Big)
timeWeightedInvestmentWithCurrencyEffect: Big; timeWeightedInvestmentWithCurrencyEffect: Big;
/** @deprecated use activitiesCount instead */
transactionCount: number;
@Transform(transformToBig, { toClassOnly: true }) @Transform(transformToBig, { toClassOnly: true })
@Type(() => Big) @Type(() => Big)
valueInBaseCurrency: Big; valueInBaseCurrency: Big;

4
libs/common/src/lib/types/account-with-value.type.ts

@ -7,10 +7,6 @@ export type AccountWithValue = AccountModel & {
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
interestInBaseCurrency: number; interestInBaseCurrency: number;
platform?: Platform; platform?: Platform;
/** @deprecated use activitiesCount instead */
transactionCount: number;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;
}; };

46
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -1,4 +1,4 @@
@if (showActions) { @if (showActions()) {
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
@ -79,7 +79,7 @@
{{ element.currency }} {{ element.currency }}
</td> </td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell> <td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }} {{ baseCurrency() }}
</td> </td>
</ng-container> </ng-container>
@ -115,21 +115,21 @@
></td> ></td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="activitiesCount">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="transactionCount" mat-sort-header="activitiesCount"
> >
<span class="d-block d-sm-none">#</span> <span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Activities</span> <span class="d-none d-sm-block" i18n>Activities</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.transactionCount }} {{ element.activitiesCount }}
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }} {{ activitiesCount() }}
</td> </td>
</ng-container> </ng-container>
@ -150,7 +150,7 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="element.balance" [value]="element.balance"
/> />
</td> </td>
@ -162,8 +162,8 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="totalBalanceInBaseCurrency" [value]="totalBalanceInBaseCurrency()"
/> />
</td> </td>
</ng-container> </ng-container>
@ -185,7 +185,7 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="element.value" [value]="element.value"
/> />
</td> </td>
@ -197,8 +197,8 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="totalValueInBaseCurrency" [value]="totalValueInBaseCurrency()"
/> />
</td> </td>
</ng-container> </ng-container>
@ -220,7 +220,7 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="element.valueInBaseCurrency" [value]="element.valueInBaseCurrency"
/> />
</td> </td>
@ -232,8 +232,8 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="totalValueInBaseCurrency" [value]="totalValueInBaseCurrency()"
/> />
</td> </td>
</ng-container> </ng-container>
@ -255,7 +255,7 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale()"
[precision]="2" [precision]="2"
[value]="element.allocationInPercentage" [value]="element.allocationInPercentage"
/> />
@ -323,7 +323,7 @@
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.transactionCount > 0" [disabled]="element.activitiesCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -336,24 +336,24 @@
<td *matFooterCellDef class="px-1" mat-footer-cell></td> <td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns()"
mat-row mat-row
[ngClass]="{ [ngClass]="{
'cursor-pointer': hasPermissionToOpenDetails 'cursor-pointer': hasPermissionToOpenDetails()
}" }"
(click)="onOpenAccountDetailDialog(row.id)" (click)="onOpenAccountDetailDialog(row.id)"
></tr> ></tr>
<tr <tr
*matFooterRowDef="displayedColumns" *matFooterRowDef="displayedColumns()"
mat-footer-row mat-footer-row
[ngClass]="{ 'd-none': isLoading || !showFooter }" [ngClass]="{ 'd-none': isLoading() || !showFooter() }"
></tr> ></tr>
</table> </table>
</div> </div>
@if (isLoading) { @if (isLoading()) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"

23
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -16,6 +16,7 @@ import { GfAccountsTableComponent } from './accounts-table.component';
const accounts = [ const accounts = [
{ {
activitiesCount: 0,
allocationInPercentage: null, allocationInPercentage: null,
balance: 278, balance: 278,
balanceInBaseCurrency: 278, balanceInBaseCurrency: 278,
@ -31,13 +32,13 @@ const accounts = [
url: 'https://www.coinbase.com' url: 'https://www.coinbase.com'
}, },
platformId: '8dc24b88-bb92-4152-af25-fe6a31643e26', platformId: '8dc24b88-bb92-4152-af25-fe6a31643e26',
transactionCount: 0,
updatedAt: new Date('2025-06-01T06:52:49.063Z'), updatedAt: new Date('2025-06-01T06:52:49.063Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
value: 278, value: 278,
valueInBaseCurrency: 278 valueInBaseCurrency: 278
}, },
{ {
activitiesCount: 0,
allocationInPercentage: null, allocationInPercentage: null,
balance: 12000, balance: 12000,
balanceInBaseCurrency: 12000, balanceInBaseCurrency: 12000,
@ -53,13 +54,13 @@ const accounts = [
url: 'https://www.jpmorgan.com' url: 'https://www.jpmorgan.com'
}, },
platformId: '43e8fcd1-5b79-4100-b678-d2229bd1660d', platformId: '43e8fcd1-5b79-4100-b678-d2229bd1660d',
transactionCount: 0,
updatedAt: new Date('2025-06-01T06:48:53.055Z'), updatedAt: new Date('2025-06-01T06:48:53.055Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
value: 12000, value: 12000,
valueInBaseCurrency: 12000 valueInBaseCurrency: 12000
}, },
{ {
activitiesCount: 12,
allocationInPercentage: null, allocationInPercentage: null,
balance: 150.2, balance: 150.2,
balanceInBaseCurrency: 150.2, balanceInBaseCurrency: 150.2,
@ -75,7 +76,6 @@ const accounts = [
url: 'https://interactivebrokers.com' url: 'https://interactivebrokers.com'
}, },
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
transactionCount: 12,
valueInBaseCurrency: 95693.70321466809, valueInBaseCurrency: 95693.70321466809,
updatedAt: new Date('2025-06-01T06:53:10.569Z'), updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
@ -111,14 +111,13 @@ export const Loading: Story = {
args: { args: {
accounts: undefined, accounts: undefined,
baseCurrency: 'USD', baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
locale: 'en-US', locale: 'en-US',
showActions: false, showActions: false,
showActivitiesCount: true,
showAllocationInPercentage: false, showAllocationInPercentage: false,
showBalance: true, showBalance: true,
showFooter: true, showFooter: true,
showTransactions: true,
showValue: true, showValue: true,
showValueInBaseCurrency: true showValueInBaseCurrency: true
} }
@ -127,39 +126,37 @@ export const Loading: Story = {
export const Default: Story = { export const Default: Story = {
args: { args: {
accounts, accounts,
activitiesCount: 12,
baseCurrency: 'USD', baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
locale: 'en-US', locale: 'en-US',
showActions: false, showActions: false,
showActivitiesCount: true,
showAllocationInPercentage: false, showAllocationInPercentage: false,
showBalance: true, showBalance: true,
showFooter: true, showFooter: true,
showTransactions: true,
showValue: true, showValue: true,
showValueInBaseCurrency: true, showValueInBaseCurrency: true,
totalBalanceInBaseCurrency: 12428.2, totalBalanceInBaseCurrency: 12428.2,
totalValueInBaseCurrency: 107971.70321466809, totalValueInBaseCurrency: 107971.70321466809
transactionCount: 12
} }
}; };
export const WithoutFooter: Story = { export const WithoutFooter: Story = {
args: { args: {
accounts, accounts,
activitiesCount: 12,
baseCurrency: 'USD', baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
locale: 'en-US', locale: 'en-US',
showActions: false, showActions: false,
showActivitiesCount: true,
showAllocationInPercentage: false, showAllocationInPercentage: false,
showBalance: true, showBalance: true,
showFooter: false, showFooter: false,
showTransactions: true,
showValue: true, showValue: true,
showValueInBaseCurrency: true, showValueInBaseCurrency: true,
totalBalanceInBaseCurrency: 12428.2, totalBalanceInBaseCurrency: 12428.2,
totalValueInBaseCurrency: 107971.70321466809, totalValueInBaseCurrency: 107971.70321466809
transactionCount: 12
} }
}; };

165
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -8,12 +8,12 @@ import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter, computed,
Input, effect,
OnChanges, inject,
OnDestroy, input,
Output, output,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -33,7 +33,6 @@ import {
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, Subscription } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -53,95 +52,94 @@ import { Subject, Subscription } from 'rxjs';
styleUrls: ['./accounts-table.component.scss'], styleUrls: ['./accounts-table.component.scss'],
templateUrl: './accounts-table.component.html' templateUrl: './accounts-table.component.html'
}) })
export class GfAccountsTableComponent implements OnChanges, OnDestroy { export class GfAccountsTableComponent {
@Input() accounts: Account[]; public readonly accounts = input.required<Account[] | undefined>();
@Input() baseCurrency: string; public readonly activitiesCount = input<number>();
@Input() deviceType: string; public readonly baseCurrency = input<string>();
@Input() hasPermissionToOpenDetails = true; public readonly hasPermissionToOpenDetails = input(true);
@Input() locale = getLocale(); public readonly locale = input(getLocale());
@Input() showActions: boolean; public readonly showActions = input<boolean>();
@Input() showAllocationInPercentage: boolean; public readonly showActivitiesCount = input(true);
@Input() showBalance = true; public readonly showAllocationInPercentage = input<boolean>();
@Input() showFooter = true; public readonly showBalance = input(true);
@Input() showTransactions = true; public readonly showFooter = input(true);
@Input() showValue = true; public readonly showValue = input(true);
@Input() showValueInBaseCurrency = true; public readonly showValueInBaseCurrency = input(false);
@Input() totalBalanceInBaseCurrency: number; public readonly totalBalanceInBaseCurrency = input<number>();
@Input() totalValueInBaseCurrency: number; public readonly totalValueInBaseCurrency = input<number>();
@Input() transactionCount: number;
public readonly accountDeleted = output<string>();
@Output() accountDeleted = new EventEmitter<string>(); public readonly accountToUpdate = output<Account>();
@Output() accountToUpdate = new EventEmitter<Account>(); public readonly transferBalance = output<void>();
@Output() transferBalance = new EventEmitter<void>();
public readonly sort = viewChild.required(MatSort);
@ViewChild(MatSort) sort: MatSort;
protected readonly dataSource = new MatTableDataSource<Account>([]);
public dataSource = new MatTableDataSource<Account>();
public displayedColumns = []; protected readonly displayedColumns = computed(() => {
public isLoading = true; const columns = ['status', 'account', 'platform'];
public routeQueryParams: Subscription;
if (this.showActivitiesCount()) {
private unsubscribeSubject = new Subject<void>(); columns.push('activitiesCount');
public constructor(
private notificationService: NotificationService,
private router: Router
) {
addIcons({
arrowRedoOutline,
createOutline,
documentTextOutline,
ellipsisHorizontal,
eyeOffOutline,
trashOutline,
walletOutline
});
}
public ngOnChanges() {
this.displayedColumns = ['status', 'account', 'platform'];
if (this.showTransactions) {
this.displayedColumns.push('transactions');
} }
if (this.showBalance) { if (this.showBalance()) {
this.displayedColumns.push('balance'); columns.push('balance');
} }
if (this.showValue) { if (this.showValue()) {
this.displayedColumns.push('value'); columns.push('value');
} }
this.displayedColumns.push('currency'); columns.push('currency');
if (this.showValueInBaseCurrency) { if (this.showValueInBaseCurrency()) {
this.displayedColumns.push('valueInBaseCurrency'); columns.push('valueInBaseCurrency');
} }
if (this.showAllocationInPercentage) { if (this.showAllocationInPercentage()) {
this.displayedColumns.push('allocation'); columns.push('allocation');
} }
this.displayedColumns.push('comment'); columns.push('comment');
if (this.showActions) { if (this.showActions()) {
this.displayedColumns.push('actions'); columns.push('actions');
} }
this.isLoading = true; return columns;
});
protected readonly isLoading = computed(() => !this.accounts());
private readonly notificationService = inject(NotificationService);
private readonly router = inject(Router);
public constructor() {
addIcons({
arrowRedoOutline,
createOutline,
documentTextOutline,
ellipsisHorizontal,
eyeOffOutline,
trashOutline,
walletOutline
});
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sort = this.sort; // Reactive data update
effect(() => {
this.dataSource.data = this.accounts();
});
if (this.accounts) { // Reactive view connection
this.isLoading = false; effect(() => {
} this.dataSource.sort = this.sort();
});
} }
public onDeleteAccount(aId: string) { protected onDeleteAccount(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.accountDeleted.emit(aId); this.accountDeleted.emit(aId);
@ -151,30 +149,25 @@ export class GfAccountsTableComponent implements OnChanges, OnDestroy {
}); });
} }
public onOpenAccountDetailDialog(accountId: string) { protected onOpenAccountDetailDialog(accountId: string) {
if (this.hasPermissionToOpenDetails) { if (this.hasPermissionToOpenDetails()) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true } queryParams: { accountId, accountDetailDialog: true }
}); });
} }
} }
public onOpenComment(aComment: string) { protected onOpenComment(aComment: string) {
this.notificationService.alert({ this.notificationService.alert({
title: aComment title: aComment
}); });
} }
public onTransferBalance() { protected onTransferBalance() {
this.transferBalance.emit(); this.transferBalance.emit();
} }
public onUpdateAccount(aAccount: Account) { protected onUpdateAccount(aAccount: Account) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

36
libs/ui/src/lib/activities-table/activities-table.component.html

@ -21,7 +21,7 @@
<mat-menu #activitiesMenu="matMenu" class="no-max-width" xPosition="before"> <mat-menu #activitiesMenu="matMenu" class="no-max-width" xPosition="before">
<button <button
mat-menu-item mat-menu-item
[disabled]="dataSource?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
(click)="onImportDividends()" (click)="onImportDividends()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -33,7 +33,7 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -60,7 +60,7 @@
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]=" [disabled]="
dataSource?.data.length === 0 || !hasPermissionToDeleteActivity dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
" "
(click)="onDeleteActivities()" (click)="onDeleteActivities()"
> >
@ -78,7 +78,7 @@
class="gf-table w-100" class="gf-table w-100"
mat-table mat-table
matSort matSort
[dataSource]="dataSource" [dataSource]="dataSource()"
[matSortActive]="sortColumn" [matSortActive]="sortColumn"
[matSortDirection]="sortDirection" [matSortDirection]="sortDirection"
[matSortDisabled]="sortDisabled" [matSortDisabled]="sortDisabled"
@ -177,7 +177,7 @@
[deviceType]="deviceType" [deviceType]="deviceType"
[isDate]="true" [isDate]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.date" [value]="isLoading() ? undefined : element.date"
/> />
</div> </div>
</td> </td>
@ -201,7 +201,7 @@
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.quantity" [value]="isLoading() ? undefined : element.quantity"
/> />
</div> </div>
</td> </td>
@ -225,7 +225,7 @@
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.unitPrice" [value]="isLoading() ? undefined : element.unitPrice"
/> />
</div> </div>
</td> </td>
@ -249,7 +249,7 @@
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.fee" [value]="isLoading() ? undefined : element.fee"
/> />
</div> </div>
</td> </td>
@ -272,7 +272,7 @@
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.value" [value]="isLoading() ? undefined : element.value"
/> />
</div> </div>
</td> </td>
@ -304,7 +304,7 @@
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.valueInBaseCurrency" [value]="isLoading() ? undefined : element.valueInBaseCurrency"
/> />
</div> </div>
</td> </td>
@ -388,7 +388,7 @@
@if (hasPermissionToCreateActivity) { @if (hasPermissionToCreateActivity) {
<button <button
mat-menu-item mat-menu-item
[disabled]="dataSource?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
(click)="onImportDividends()" (click)="onImportDividends()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -403,7 +403,7 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -488,9 +488,9 @@
</td> </td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns()"
mat-row mat-row
[ngClass]="{ [ngClass]="{
'cursor-pointer': canClickActivity(row) 'cursor-pointer': canClickActivity(row)
@ -500,7 +500,7 @@
</table> </table>
</div> </div>
@if (isLoading) { @if (isLoading()) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"
@ -514,7 +514,7 @@
<mat-paginator <mat-paginator
[length]="totalItems" [length]="totalItems"
[ngClass]="{ [ngClass]="{
'd-none': (isLoading && !totalItems) || totalItems <= pageSize 'd-none': (isLoading() && !totalItems) || totalItems <= pageSize
}" }"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
@ -524,9 +524,9 @@
@if ( @if (
!hasActivities && !hasActivities &&
dataSource?.data.length === 0 && dataSource()?.data.length === 0 &&
hasPermissionToCreateActivity && hasPermissionToCreateActivity &&
!isLoading !isLoading()
) { ) {
<div class="p-3 text-center"> <div class="p-3 text-center">
<gf-no-transactions-info-indicator [hasBorder]="false" /> <gf-no-transactions-info-indicator [hasBorder]="false" />

26
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -59,7 +59,7 @@ const activities: Activity[] = [
SymbolProfile: { SymbolProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
comment: null, comment: undefined,
countries: [], countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'), createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD', currency: 'USD',
@ -74,12 +74,12 @@ const activities: Activity[] = [
isin: 'US9220427424', isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares', name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'), updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null, scraperConfiguration: undefined,
sectors: [], sectors: [],
symbol: 'VT', symbol: 'VT',
symbolMapping: {}, symbolMapping: {},
url: 'https://www.vanguard.com', url: 'https://www.vanguard.com',
userId: null, userId: undefined,
activitiesCount: 267, activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
}, },
@ -126,7 +126,7 @@ const activities: Activity[] = [
SymbolProfile: { SymbolProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
comment: null, comment: undefined,
countries: [], countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'), createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD', currency: 'USD',
@ -141,12 +141,12 @@ const activities: Activity[] = [
isin: 'US9220427424', isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares', name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'), updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null, scraperConfiguration: undefined,
sectors: [], sectors: [],
symbol: 'VT', symbol: 'VT',
symbolMapping: {}, symbolMapping: {},
url: 'https://www.vanguard.com', url: 'https://www.vanguard.com',
userId: null, userId: undefined,
activitiesCount: 267, activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
}, },
@ -193,7 +193,7 @@ const activities: Activity[] = [
SymbolProfile: { SymbolProfile: {
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY', assetSubClass: 'CRYPTOCURRENCY',
comment: null, comment: undefined,
countries: [], countries: [],
createdAt: new Date('2024-03-12T15:15:21.217Z'), createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD', currency: 'USD',
@ -208,12 +208,12 @@ const activities: Activity[] = [
isin: 'CA4639181029', isin: 'CA4639181029',
name: 'iShares Bitcoin Trust', name: 'iShares Bitcoin Trust',
updatedAt: new Date('2025-09-29T03:14:07.742Z'), updatedAt: new Date('2025-09-29T03:14:07.742Z'),
scraperConfiguration: null, scraperConfiguration: undefined,
sectors: [], sectors: [],
symbol: 'IBIT', symbol: 'IBIT',
symbolMapping: {}, symbolMapping: {},
url: 'https://www.ishares.com', url: 'https://www.ishares.com',
userId: null, userId: undefined,
activitiesCount: 6, activitiesCount: 6,
dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z') dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z')
}, },
@ -280,7 +280,7 @@ const activities: Activity[] = [
symbol: 'BNDW', symbol: 'BNDW',
symbolMapping: {}, symbolMapping: {},
url: 'https://vanguard.com', url: 'https://vanguard.com',
userId: null, userId: undefined,
activitiesCount: 38, activitiesCount: 38,
dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z') dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z')
}, },
@ -327,7 +327,7 @@ const activities: Activity[] = [
SymbolProfile: { SymbolProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
comment: null, comment: undefined,
countries: [], countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'), createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD', currency: 'USD',
@ -342,12 +342,12 @@ const activities: Activity[] = [
isin: 'US9220427424', isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares', name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'), updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null, scraperConfiguration: undefined,
sectors: [], sectors: [],
symbol: 'VT', symbol: 'VT',
symbolMapping: {}, symbolMapping: {},
url: 'https://www.vanguard.com', url: 'https://www.vanguard.com',
userId: null, userId: undefined,
activitiesCount: 267, activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
}, },

138
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -21,11 +21,13 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output,
ViewChild ViewChild,
computed,
inject,
input
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@ -45,7 +47,6 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
alertCircleOutline, alertCircleOutline,
@ -62,7 +63,7 @@ import {
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, Subscription, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component'; import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
@ -94,10 +95,9 @@ import { GfValueComponent } from '../value/value.component';
templateUrl: './activities-table.component.html' templateUrl: './activities-table.component.html'
}) })
export class GfActivitiesTableComponent export class GfActivitiesTableComponent
implements AfterViewInit, OnChanges, OnDestroy, OnInit implements AfterViewInit, OnDestroy, OnInit
{ {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() dataSource: MatTableDataSource<Activity>;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasActivities: boolean; @Input() hasActivities: boolean;
@Input() hasPermissionToCreateActivity: boolean; @Input() hasPermissionToCreateActivity: boolean;
@ -107,10 +107,7 @@ export class GfActivitiesTableComponent
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() pageIndex: number; @Input() pageIndex: number;
@Input() pageSize = DEFAULT_PAGE_SIZE; @Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showAccountColumn = true;
@Input() showActions = true; @Input() showActions = true;
@Input() showCheckbox = false;
@Input() showNameColumn = true;
@Input() sortColumn: string; @Input() sortColumn: string;
@Input() sortDirection: SortDirection; @Input() sortDirection: SortDirection;
@Input() sortDisabled = false; @Input() sortDisabled = false;
@ -132,19 +129,66 @@ export class GfActivitiesTableComponent
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public displayedColumns = [];
public endOfToday = endOfToday();
public hasDrafts = false; public hasDrafts = false;
public hasErrors = false; public hasErrors = false;
public isAfter = isAfter;
public isLoading = true;
public isUUID = isUUID; public isUUID = isUUID;
public routeQueryParams: Subscription;
public selectedRows = new SelectionModel<Activity>(true, []); public selectedRows = new SelectionModel<Activity>(true, []);
private unsubscribeSubject = new Subject<void>(); public readonly dataSource = input.required<
MatTableDataSource<Activity> | undefined
>();
public readonly showAccountColumn = input(true);
public readonly showCheckbox = input(false);
public readonly showNameColumn = input(true);
public constructor(private notificationService: NotificationService) { protected readonly displayedColumns = computed(() => {
let columns = [
'select',
'importStatus',
'icon',
'nameWithSymbol',
'type',
'date',
'quantity',
'unitPrice',
'fee',
'value',
'currency',
'valueInBaseCurrency',
'account',
'comment',
'actions'
];
if (!this.showAccountColumn()) {
columns = columns.filter((column) => {
return column !== 'account';
});
}
if (!this.showCheckbox()) {
columns = columns.filter((column) => {
return column !== 'importStatus' && column !== 'select';
});
}
if (!this.showNameColumn()) {
columns = columns.filter((column) => {
return column !== 'nameWithSymbol';
});
}
return columns;
});
protected readonly isLoading = computed(() => {
return !this.dataSource();
});
private readonly notificationService = inject(NotificationService);
private readonly unsubscribeSubject = new Subject<void>();
public constructor() {
addIcons({ addIcons({
alertCircleOutline, alertCircleOutline,
calendarClearOutline, calendarClearOutline,
@ -162,7 +206,7 @@ export class GfActivitiesTableComponent
} }
public ngOnInit() { public ngOnInit() {
if (this.showCheckbox) { if (this.showCheckbox()) {
this.toggleAllRows(); this.toggleAllRows();
this.selectedRows.changed this.selectedRows.changed
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -173,8 +217,10 @@ export class GfActivitiesTableComponent
} }
public ngAfterViewInit() { public ngAfterViewInit() {
if (this.dataSource) { const dataSource = this.dataSource();
this.dataSource.paginator = this.paginator;
if (dataSource) {
dataSource.paginator = this.paginator;
} }
this.sort.sortChange.subscribe((value: Sort) => { this.sort.sortChange.subscribe((value: Sort) => {
@ -182,51 +228,9 @@ export class GfActivitiesTableComponent
}); });
} }
public ngOnChanges() {
this.displayedColumns = [
'select',
'importStatus',
'icon',
'nameWithSymbol',
'type',
'date',
'quantity',
'unitPrice',
'fee',
'value',
'currency',
'valueInBaseCurrency',
'account',
'comment',
'actions'
];
if (!this.showAccountColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'account';
});
}
if (!this.showCheckbox) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'importStatus' && column !== 'select';
});
}
if (!this.showNameColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'nameWithSymbol';
});
}
if (this.dataSource) {
this.isLoading = false;
}
}
public areAllRowsSelected() { public areAllRowsSelected() {
const numSelectedRows = this.selectedRows.selected.length; const numSelectedRows = this.selectedRows.selected.length;
const numTotalRows = this.dataSource.data.length; const numTotalRows = this.dataSource()?.data.length;
return numSelectedRows === numTotalRows; return numSelectedRows === numTotalRows;
} }
@ -241,7 +245,7 @@ export class GfActivitiesTableComponent
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
activity.account?.isExcluded || activity.account?.isExcluded ??
activity.tags?.some(({ id }) => { activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) })
@ -253,7 +257,7 @@ export class GfActivitiesTableComponent
} }
public onClickActivity(activity: Activity) { public onClickActivity(activity: Activity) {
if (this.showCheckbox) { if (this.showCheckbox()) {
if (!activity.error) { if (!activity.error) {
this.selectedRows.toggle(activity); this.selectedRows.toggle(activity);
} }
@ -299,8 +303,8 @@ export class GfActivitiesTableComponent
public onExportDrafts() { public onExportDrafts() {
this.exportDrafts.emit( this.exportDrafts.emit(
this.dataSource.filteredData this.dataSource()
.filter((activity) => { ?.filteredData.filter((activity) => {
return activity.isDraft; return activity.isDraft;
}) })
.map((activity) => { .map((activity) => {
@ -331,7 +335,7 @@ export class GfActivitiesTableComponent
if (this.areAllRowsSelected()) { if (this.areAllRowsSelected()) {
this.selectedRows.clear(); this.selectedRows.clear();
} else { } else {
this.dataSource.data.forEach((row) => { this.dataSource()?.data.forEach((row) => {
this.selectedRows.select(row); this.selectedRows.select(row);
}); });
} }

29
libs/ui/src/lib/chart/chart.registry.ts

@ -0,0 +1,29 @@
import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper';
import { Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js';
interface VerticalHoverLinePluginOptions {
color?: string;
width?: number;
}
declare module 'chart.js' {
interface PluginOptionsByType<TType extends ChartType> {
verticalHoverLine: TType extends 'line' | 'bar'
? VerticalHoverLinePluginOptions
: never;
}
interface TooltipPositionerMap {
top: TooltipPositionerFunction<ChartType>;
}
}
export function registerChartConfiguration() {
if (Tooltip.positioners['top']) {
return;
}
Tooltip.positioners.top = function (_elements, eventPosition) {
return getTooltipPositionerMapTop(this.chart, eventPosition);
};
}

1
libs/ui/src/lib/chart/index.ts

@ -0,0 +1 @@
export * from './chart.registry';

18
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -38,6 +38,8 @@ import {
BarElement, BarElement,
CategoryScale, CategoryScale,
Chart, Chart,
type ChartData,
type ChartDataset,
LinearScale, LinearScale,
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
@ -270,7 +272,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'bar'>(this.chartCanvas.nativeElement, {
data: chartData, data: chartData,
options: { options: {
plugins: { plugins: {
@ -280,7 +282,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
callbacks: { callbacks: {
footer: (items) => { footer: (items) => {
const totalAmount = items.reduce( const totalAmount = items.reduce(
(a, b) => a + b.parsed.y, (a, b) => a + (b.parsed.y ?? 0),
0 0
); );
@ -302,8 +304,6 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += new Intl.NumberFormat(this.locale, { label += new Intl.NumberFormat(this.locale, {
currency: this.currency, currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency'
}).format(context.parsed.y); }).format(context.parsed.y);
@ -345,9 +345,9 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.isLoading = false; this.isLoading = false;
} }
private getChartData() { private getChartData(): ChartData<'bar'> {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const labels = []; const labels: number[] = [];
// Principal investment amount // Principal investment amount
const P: number = this.getP(); const P: number = this.getP();
@ -371,13 +371,13 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
labels.push(year); labels.push(year);
} }
const datasetDeposit = { const datasetDeposit: ChartDataset<'bar'> = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
data: [], data: [],
label: $localize`Deposit` label: $localize`Deposit`
}; };
const datasetInterest = { const datasetInterest: ChartDataset<'bar'> = {
backgroundColor: Color( backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
) )
@ -387,7 +387,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
label: $localize`Interest` label: $localize`Interest`
}; };
const datasetSavings = { const datasetSavings: ChartDataset<'bar'> = {
backgroundColor: Color( backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
) )

38
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -52,7 +52,7 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isDate]="element.dateOfFirstActivity ? true : false" [isDate]="element.dateOfFirstActivity ? true : false"
[locale]="locale" [locale]="locale()"
[value]="element.dateOfFirstActivity ?? ''" [value]="element.dateOfFirstActivity ?? ''"
/> />
</div> </div>
@ -76,8 +76,8 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="isLoading ? undefined : element.quantity" [value]="isLoading() ? undefined : element.quantity"
/> />
</div> </div>
</td> </td>
@ -100,8 +100,8 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]="isLoading ? undefined : element.valueInBaseCurrency" [value]="isLoading() ? undefined : element.valueInBaseCurrency"
/> />
</div> </div>
</td> </td>
@ -121,8 +121,8 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale()"
[value]="isLoading ? undefined : element.allocationInPercentage" [value]="isLoading() ? undefined : element.allocationInPercentage"
/> />
</div> </div>
</td> </td>
@ -142,9 +142,9 @@
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]=" [value]="
isLoading ? undefined : element.netPerformanceWithCurrencyEffect isLoading() ? undefined : element.netPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -166,9 +166,9 @@
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale()"
[value]=" [value]="
isLoading isLoading()
? undefined ? undefined
: element.netPerformancePercentWithCurrencyEffect : element.netPerformancePercentWithCurrencyEffect
" "
@ -177,17 +177,13 @@
</td> </td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns()"
mat-row mat-row
[ngClass]="{ [class.cursor-pointer]="canShowDetails(row)"
'cursor-pointer':
hasPermissionToOpenDetails &&
!ignoreAssetSubClasses.includes(row.assetSubClass)
}"
(click)=" (click)="
!ignoreAssetSubClasses.includes(row.assetSubClass) && canShowDetails(row) &&
onOpenHoldingDialog({ onOpenHoldingDialog({
dataSource: row.dataSource, dataSource: row.dataSource,
symbol: row.symbol symbol: row.symbol
@ -199,7 +195,7 @@
<mat-paginator class="d-none" [pageSize]="pageSize" /> <mat-paginator class="d-none" [pageSize]="pageSize" />
@if (isLoading) { @if (isLoading()) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"
@ -210,7 +206,7 @@
/> />
} }
@if (dataSource.data.length > pageSize && !isLoading) { @if (dataSource.data.length > pageSize && !isLoading()) {
<div class="my-3 text-center"> <div class="my-3 text-center">
<button mat-stroked-button (click)="onShowAllHoldings()"> <button mat-stroked-button (click)="onShowAllHoldings()">
<ng-container i18n>Show all</ng-container> <ng-container i18n>Show all</ng-container>

4
libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts

@ -38,8 +38,6 @@ type Story = StoryObj<GfHoldingsTableComponent>;
export const Loading: Story = { export const Loading: Story = {
args: { args: {
holdings: undefined, holdings: undefined,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
hasPermissionToShowQuantities: true, hasPermissionToShowQuantities: true,
hasPermissionToShowValues: true, hasPermissionToShowValues: true,
@ -51,8 +49,6 @@ export const Loading: Story = {
export const Default: Story = { export const Default: Story = {
args: { args: {
holdings, holdings,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
hasPermissionToShowQuantities: true, hasPermissionToShowQuantities: true,
hasPermissionToShowValues: true, hasPermissionToShowValues: true,

103
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -11,10 +11,11 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnChanges,
OnDestroy,
Output, Output,
ViewChild computed,
effect,
input,
viewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@ -23,7 +24,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { AssetSubClass } from '@prisma/client'; import { AssetSubClass } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, Subscription } from 'rxjs';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { GfValueComponent } from '../value/value.component'; import { GfValueComponent } from '../value/value.component';
@ -46,77 +46,82 @@ import { GfValueComponent } from '../value/value.component';
styleUrls: ['./holdings-table.component.scss'], styleUrls: ['./holdings-table.component.scss'],
templateUrl: './holdings-table.component.html' templateUrl: './holdings-table.component.html'
}) })
export class GfHoldingsTableComponent implements OnChanges, OnDestroy { export class GfHoldingsTableComponent {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToOpenDetails = true;
@Input() hasPermissionToShowQuantities = true;
@Input() hasPermissionToShowValues = true;
@Input() holdings: PortfolioPosition[];
@Input() locale = getLocale();
@Input() pageSize = Number.MAX_SAFE_INTEGER; @Input() pageSize = Number.MAX_SAFE_INTEGER;
@Output() holdingClicked = new EventEmitter<AssetProfileIdentifier>(); @Output() holdingClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild(MatPaginator) paginator: MatPaginator; public readonly hasPermissionToOpenDetails = input(true);
@ViewChild(MatSort) sort: MatSort; public readonly hasPermissionToShowQuantities = input(true);
public readonly hasPermissionToShowValues = input(true);
public readonly holdings = input.required<PortfolioPosition[]>();
public readonly locale = input(getLocale());
public readonly paginator = viewChild.required(MatPaginator);
public readonly sort = viewChild.required(MatSort);
public dataSource = new MatTableDataSource<PortfolioPosition>(); protected readonly dataSource = new MatTableDataSource<PortfolioPosition>([]);
public displayedColumns = [];
public ignoreAssetSubClasses = [AssetSubClass.CASH];
public isLoading = true;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>(); protected readonly displayedColumns = computed(() => {
const columns = ['icon', 'nameWithSymbol', 'dateOfFirstActivity'];
public ngOnChanges() { if (this.hasPermissionToShowQuantities()) {
this.displayedColumns = ['icon', 'nameWithSymbol', 'dateOfFirstActivity']; columns.push('quantity');
if (this.hasPermissionToShowQuantities) {
this.displayedColumns.push('quantity');
} }
if (this.hasPermissionToShowValues) { if (this.hasPermissionToShowValues()) {
this.displayedColumns.push('valueInBaseCurrency'); columns.push('valueInBaseCurrency');
} }
this.displayedColumns.push('allocationInPercentage'); columns.push('allocationInPercentage');
if (this.hasPermissionToShowValues) { if (this.hasPermissionToShowValues()) {
this.displayedColumns.push('performance'); columns.push('performance');
} }
this.displayedColumns.push('performanceInPercentage'); columns.push('performanceInPercentage');
return columns;
});
protected readonly ignoreAssetSubClasses: AssetSubClass[] = [
AssetSubClass.CASH
];
this.isLoading = true; protected readonly isLoading = computed(() => !this.holdings());
this.dataSource = new MatTableDataSource(this.holdings); public constructor() {
this.dataSource.paginator = this.paginator;
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sort = this.sort; // Reactive data update
effect(() => {
this.dataSource.data = this.holdings();
});
if (this.holdings) { // Reactive view connection
this.isLoading = false; effect(() => {
} this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort();
});
} }
public onOpenHoldingDialog({ dataSource, symbol }: AssetProfileIdentifier) { protected canShowDetails(holding: PortfolioPosition): boolean {
if (this.hasPermissionToOpenDetails) { return (
this.holdingClicked.emit({ dataSource, symbol }); this.hasPermissionToOpenDetails() &&
} !this.ignoreAssetSubClasses.includes(holding.assetSubClass)
);
}
protected onOpenHoldingDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.holdingClicked.emit({ dataSource, symbol });
} }
public onShowAllHoldings() { protected onShowAllHoldings() {
this.pageSize = Number.MAX_SAFE_INTEGER; this.pageSize = Number.MAX_SAFE_INTEGER;
setTimeout(() => { setTimeout(() => {
this.dataSource.paginator = this.paginator; this.dataSource.paginator = this.paginator();
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

58
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
@ -19,12 +18,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
type ElementRef,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { import {
type AnimationsSpec,
Chart, Chart,
Filler, Filler,
LinearScale, LinearScale,
@ -33,11 +34,13 @@ import {
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { registerChartConfiguration } from '../chart';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule],
@ -67,7 +70,7 @@ export class GfLineChartComponent
@Input() yMin: number; @Input() yMin: number;
@Input() yMinLabel: string; @Input() yMinLabel: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'line'>; public chart: Chart<'line'>;
public isLoading = true; public isLoading = true;
@ -85,8 +88,7 @@ export class GfLineChartComponent
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -117,9 +119,9 @@ export class GfLineChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices = []; const benchmarkPrices: number[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices = []; const marketPrices: number[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value);
@ -129,11 +131,14 @@ export class GfLineChartComponent
const gradient = this.chartCanvas?.nativeElement const gradient = this.chartCanvas?.nativeElement
?.getContext('2d') ?.getContext('2d')
.createLinearGradient( ?.createLinearGradient(
0, 0,
0, 0,
0, 0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5 ((this.chartCanvas.nativeElement.parentNode as HTMLElement)
.offsetHeight *
4) /
5
); );
if (gradient && this.showGradient) { if (gradient && this.showGradient) {
@ -169,27 +174,26 @@ export class GfLineChartComponent
}; };
if (this.chartCanvas) { if (this.chartCanvas) {
const animations = {
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
};
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.options.animation = this.chart.options.animations = this.isAnimated
this.isAnimated && ? animations
({ : undefined;
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
} as unknown);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animation: animations: this.isAnimated ? animations : undefined,
this.isAnimated &&
({
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
} as unknown),
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
elements: { elements: {
point: { point: {
@ -208,7 +212,7 @@ export class GfLineChartComponent
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
scales: { scales: {
x: { x: {
border: { border: {
@ -298,7 +302,7 @@ export class GfLineChartComponent
}: { }: {
axis: 'x' | 'y'; axis: 'x' | 'y';
labels: string[]; labels: string[];
}) { }): Partial<AnimationsSpec<'line'>[string]> {
const delayBetweenPoints = this.ANIMATION_DURATION / labels.length; const delayBetweenPoints = this.ANIMATION_DURATION / labels.length;
return { return {
@ -308,7 +312,7 @@ export class GfLineChartComponent
} }
context[`${axis}Started`] = true; context[`${axis}Started`] = true;
return context.index * delayBetweenPoints; return context.dataIndex * delayBetweenPoints;
}, },
duration: delayBetweenPoints, duration: delayBetweenPoints,
easing: 'linear', easing: 'linear',
@ -317,7 +321,7 @@ export class GfLineChartComponent
}; };
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -326,7 +330,7 @@ export class GfLineChartComponent
unit: this.unit unit: this.unit
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

51
libs/ui/src/lib/mocks/holdings.ts

@ -4,9 +4,9 @@ export const holdings: PortfolioPosition[] = [
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.042990776363386086, allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any, assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock', assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
@ -17,7 +17,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 3856, grossPerformance: 3856,
@ -41,16 +41,15 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'AAPL', symbol: 'AAPL',
tags: [], tags: [],
transactionCount: 1,
url: 'https://www.apple.com', url: 'https://www.apple.com',
valueInBaseCurrency: 12230 valueInBaseCurrency: 12230
}, },
{ {
activitiesCount: 2, activitiesCount: 2,
allocationInPercentage: 0.02377401948293552, allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any, assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock', assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
@ -61,7 +60,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'EUR', currency: 'EUR',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'),
dividend: 192, dividend: 192,
grossPerformance: 2226.700251889169, grossPerformance: 2226.700251889169,
@ -85,16 +84,15 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'ALV.DE', symbol: 'ALV.DE',
tags: [], tags: [],
transactionCount: 2,
url: 'https://www.allianz.com', url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202 valueInBaseCurrency: 6763.224181360202
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.08038536990007467, allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any, assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock', assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
@ -105,7 +103,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 12758.05, grossPerformance: 12758.05,
@ -129,20 +127,19 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'AMZN', symbol: 'AMZN',
tags: [], tags: [],
transactionCount: 1,
url: 'https://www.aboutamazon.com', url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868 valueInBaseCurrency: 22868
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.19216416482928922, allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY' as any, assetClass: 'LIQUIDITY',
assetClassLabel: 'Liquidity', assetClassLabel: 'Liquidity',
assetSubClass: 'CRYPTOCURRENCY' as any, assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency', assetSubClassLabel: 'Cryptocurrency',
countries: [], countries: [],
currency: 'USD', currency: 'USD',
dataSource: 'COINGECKO' as any, dataSource: 'COINGECKO',
dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 52666.7898248, grossPerformance: 52666.7898248,
@ -161,16 +158,15 @@ export const holdings: PortfolioPosition[] = [
sectors: [], sectors: [],
symbol: 'bitcoin', symbol: 'bitcoin',
tags: [], tags: [],
transactionCount: 1, url: undefined,
url: null,
valueInBaseCurrency: 54666.7898248 valueInBaseCurrency: 54666.7898248
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.04307127421937313, allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any, assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock', assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
@ -181,7 +177,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 5065.5, grossPerformance: 5065.5,
@ -205,16 +201,15 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: [],
transactionCount: 1,
url: 'https://www.microsoft.com', url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9 valueInBaseCurrency: 12252.9
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.18762679306394897, allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any, assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock', assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
@ -225,7 +220,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 51227.500000005, grossPerformance: 51227.500000005,
@ -249,16 +244,15 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'TSLA', symbol: 'TSLA',
tags: [], tags: [],
transactionCount: 1,
url: 'https://www.tesla.com', url: 'https://www.tesla.com',
valueInBaseCurrency: 53376 valueInBaseCurrency: 53376
}, },
{ {
activitiesCount: 5, activitiesCount: 5,
allocationInPercentage: 0.053051250766657634, allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY' as any, assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
assetSubClass: 'ETF' as any, assetSubClass: 'ETF',
assetSubClassLabel: 'ETF', assetSubClassLabel: 'ETF',
countries: [ countries: [
{ {
@ -269,7 +263,7 @@ export const holdings: PortfolioPosition[] = [
} }
], ],
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO' as any, dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 6845.8, grossPerformance: 6845.8,
@ -293,7 +287,6 @@ export const holdings: PortfolioPosition[] = [
], ],
symbol: 'VTI', symbol: 'VTI',
tags: [], tags: [],
transactionCount: 5,
url: 'https://www.vanguard.com', url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092 valueInBaseCurrency: 15092
} }

15
libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts

@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -12,14 +12,15 @@ import { AlertDialogParams } from './interfaces/interfaces';
}) })
export class GfAlertDialogComponent { export class GfAlertDialogComponent {
public discardLabel: string; public discardLabel: string;
public message: string; public message?: string;
public title: string; public title: string;
public constructor(public dialogRef: MatDialogRef<GfAlertDialogComponent>) {} protected readonly dialogRef =
inject<MatDialogRef<GfAlertDialogComponent>>(MatDialogRef);
public initialize(aParams: AlertDialogParams) { public initialize({ discardLabel, message, title }: AlertDialogParams) {
this.discardLabel = aParams.discardLabel; this.discardLabel = discardLabel;
this.message = aParams.message; this.message = message;
this.title = aParams.title; this.title = title;
} }
} }

3
libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts

@ -1,6 +1,5 @@
export interface AlertDialogParams { export interface AlertDialogParams {
confirmLabel?: string; discardLabel: string;
discardLabel?: string;
message?: string; message?: string;
title: string; title: string;
} }

27
libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts

@ -1,6 +1,6 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Component, HostListener } from '@angular/core'; import { Component, HostListener, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -16,12 +16,11 @@ export class GfConfirmationDialogComponent {
public confirmLabel: string; public confirmLabel: string;
public confirmType: ConfirmationDialogType; public confirmType: ConfirmationDialogType;
public discardLabel: string; public discardLabel: string;
public message: string; public message?: string;
public title: string; public title: string;
public constructor( protected readonly dialogRef =
public dialogRef: MatDialogRef<GfConfirmationDialogComponent> inject<MatDialogRef<GfConfirmationDialogComponent>>(MatDialogRef);
) {}
@HostListener('window:keyup', ['$event']) @HostListener('window:keyup', ['$event'])
public keyEvent(event: KeyboardEvent) { public keyEvent(event: KeyboardEvent) {
@ -30,11 +29,17 @@ export class GfConfirmationDialogComponent {
} }
} }
public initialize(aParams: ConfirmDialogParams) { public initialize({
this.confirmLabel = aParams.confirmLabel; confirmLabel,
this.confirmType = aParams.confirmType; confirmType,
this.discardLabel = aParams.discardLabel; discardLabel,
this.message = aParams.message; message,
this.title = aParams.title; title
}: ConfirmDialogParams) {
this.confirmLabel = confirmLabel;
this.confirmType = confirmType;
this.discardLabel = discardLabel;
this.message = message;
this.title = title;
} }
} }

4
libs/ui/src/lib/notifications/confirmation-dialog/interfaces/interfaces.ts

@ -1,9 +1,9 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
export interface ConfirmDialogParams { export interface ConfirmDialogParams {
confirmLabel?: string; confirmLabel: string;
confirmType: ConfirmationDialogType; confirmType: ConfirmationDialogType;
discardLabel?: string; discardLabel: string;
message?: string; message?: string;
title: string; title: string;
} }

30
libs/ui/src/lib/notifications/notification.service.ts

@ -1,7 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
@ -19,12 +19,10 @@ export class NotificationService {
private dialogMaxWidth: string; private dialogMaxWidth: string;
private dialogWidth: string; private dialogWidth: string;
public constructor(private matDialog: MatDialog) {} private readonly matDialog = inject(MatDialog);
public alert(aParams: AlertParams) { public alert(aParams: AlertParams) {
if (!aParams.discardLabel) { aParams.discardLabel ??= translate('CLOSE');
aParams.discardLabel = translate('CLOSE');
}
const dialog = this.matDialog.open(GfAlertDialogComponent, { const dialog = this.matDialog.open(GfAlertDialogComponent, {
autoFocus: false, autoFocus: false,
@ -46,24 +44,19 @@ export class NotificationService {
} }
public confirm(aParams: ConfirmParams) { public confirm(aParams: ConfirmParams) {
if (!aParams.confirmLabel) { aParams.confirmLabel ??= translate('YES');
aParams.confirmLabel = translate('YES'); aParams.discardLabel ??= translate('CANCEL');
}
if (!aParams.discardLabel) {
aParams.discardLabel = translate('CANCEL');
}
const dialog = this.matDialog.open(GfConfirmationDialogComponent, { const dialog = this.matDialog.open(GfConfirmationDialogComponent, {
autoFocus: false, autoFocus: false,
disableClose: aParams.disableClose || false, disableClose: aParams.disableClose ?? false,
maxWidth: this.dialogMaxWidth, maxWidth: this.dialogMaxWidth,
width: this.dialogWidth width: this.dialogWidth
}); });
dialog.componentInstance.initialize({ dialog.componentInstance.initialize({
confirmLabel: aParams.confirmLabel, confirmLabel: aParams.confirmLabel,
confirmType: aParams.confirmType || ConfirmationDialogType.Primary, confirmType: aParams.confirmType ?? ConfirmationDialogType.Primary,
discardLabel: aParams.discardLabel, discardLabel: aParams.discardLabel,
message: aParams.message, message: aParams.message,
title: aParams.title title: aParams.title
@ -79,13 +72,8 @@ export class NotificationService {
} }
public prompt(aParams: PromptParams) { public prompt(aParams: PromptParams) {
if (!aParams.confirmLabel) { aParams.confirmLabel ??= translate('OK');
aParams.confirmLabel = translate('OK'); aParams.discardLabel ??= translate('CANCEL');
}
if (!aParams.discardLabel) {
aParams.discardLabel = translate('CANCEL');
}
const dialog = this.matDialog.open(GfPromptDialogComponent, { const dialog = this.matDialog.open(GfPromptDialogComponent, {
autoFocus: true, autoFocus: true,

7
libs/ui/src/lib/notifications/prompt-dialog/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface PromptDialogParams {
confirmLabel: string;
defaultValue?: string;
discardLabel: string;
title: string;
valueLabel?: string;
}

37
libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts

@ -1,10 +1,12 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { PromptDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [ imports: [
MatButtonModule, MatButtonModule,
@ -18,26 +20,27 @@ import { MatInputModule } from '@angular/material/input';
}) })
export class GfPromptDialogComponent { export class GfPromptDialogComponent {
public confirmLabel: string; public confirmLabel: string;
public defaultValue: string; public defaultValue?: string;
public discardLabel: string; public discardLabel: string;
public formControl = new FormControl(''); public formControl = new FormControl('');
public title: string; public title: string;
public valueLabel: string; public valueLabel?: string;
public constructor(public dialogRef: MatDialogRef<GfPromptDialogComponent>) {} protected readonly dialogRef =
inject<MatDialogRef<GfPromptDialogComponent>>(MatDialogRef);
public initialize(aParams: { public initialize({
confirmLabel?: string; confirmLabel,
defaultValue?: string; defaultValue,
discardLabel?: string; discardLabel,
title: string; title,
valueLabel?: string; valueLabel
}) { }: PromptDialogParams) {
this.confirmLabel = aParams.confirmLabel; this.confirmLabel = confirmLabel;
this.defaultValue = aParams.defaultValue; this.defaultValue = defaultValue;
this.discardLabel = aParams.discardLabel; this.discardLabel = discardLabel;
this.formControl.setValue(aParams.defaultValue); this.formControl.setValue(defaultValue ?? null);
this.title = aParams.title; this.title = title;
this.valueLabel = aParams.valueLabel; this.valueLabel = valueLabel;
} }
} }

56
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -22,11 +22,16 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ChartConfiguration, Tooltip } from 'chart.js'; import {
import { LinearScale } from 'chart.js'; ArcElement,
import { ArcElement } from 'chart.js'; Chart,
import { DoughnutController } from 'chart.js'; type ChartData,
import { Chart } from 'chart.js'; type ChartDataset,
DoughnutController,
LinearScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import Color from 'color'; import Color from 'color';
@ -286,7 +291,7 @@ export class GfPortfolioProportionChartComponent
}); });
}); });
const datasets: ChartConfiguration<'doughnut'>['data']['datasets'] = [ const datasets: ChartDataset<'doughnut'>[] = [
{ {
backgroundColor: chartDataSorted.map(([, item]) => { backgroundColor: chartDataSorted.map(([, item]) => {
return item.color; return item.color;
@ -324,7 +329,7 @@ export class GfPortfolioProportionChartComponent
datasets[1].data[1] = Number.MAX_SAFE_INTEGER; datasets[1].data[1] = Number.MAX_SAFE_INTEGER;
} }
const data: ChartConfiguration<'doughnut'>['data'] = { const data: ChartData<'doughnut'> = {
datasets, datasets,
labels labels
}; };
@ -332,9 +337,10 @@ export class GfPortfolioProportionChartComponent
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = this.getTooltipPluginConfiguration( this.chart.options.plugins ??= {};
data this.chart.options.plugins.tooltip =
) as unknown; this.getTooltipPluginConfiguration(data);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, { this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, {
@ -345,21 +351,22 @@ export class GfPortfolioProportionChartComponent
layout: { layout: {
padding: this.showLabels === true ? 100 : 0 padding: this.showLabels === true ? 100 : 0
}, },
onClick: (event, activeElements) => { onClick: (_, activeElements, chart) => {
try { try {
const dataIndex = activeElements[0].index; const dataIndex = activeElements[0].index;
const symbol: string = event.chart.data.labels[dataIndex]; const symbol = chart.data.labels?.[dataIndex] as string;
const dataSource = this.data[symbol]?.dataSource; const dataSource = this.data[symbol].dataSource;
this.proportionChartClicked.emit({ dataSource, symbol }); if (dataSource) {
this.proportionChartClicked.emit({ dataSource, symbol });
}
} catch {} } catch {}
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
event.native.target.style.cursor = chartElement[0] (event.native?.target as HTMLElement).style.cursor =
? this.cursor chartElement[0] ? this.cursor : 'default';
: 'default';
} }
}, },
plugins: { plugins: {
@ -392,7 +399,7 @@ export class GfPortfolioProportionChartComponent
legend: { display: false }, legend: { display: false },
tooltip: this.getTooltipPluginConfiguration(data) tooltip: this.getTooltipPluginConfiguration(data)
} }
} as unknown, },
plugins: [ChartDataLabels], plugins: [ChartDataLabels],
type: 'doughnut' type: 'doughnut'
}); });
@ -419,19 +426,24 @@ export class GfPortfolioProportionChartComponent
]; ];
} }
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) { private getTooltipPluginConfiguration(
data: ChartData<'doughnut'>
): Partial<TooltipOptions<'doughnut'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const labelIndex = const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) + (data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) +
context.dataIndex; context.dataIndex;
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
let symbol =
(context.chart.data.labels?.[labelIndex] as string) ?? '';
if (symbol === this.OTHER_KEY) { if (symbol === this.OTHER_KEY) {
symbol = $localize`Other`; symbol = $localize`Other`;
@ -439,9 +451,10 @@ export class GfPortfolioProportionChartComponent
symbol = $localize`No data available`; symbol = $localize`No data available`;
} }
const name = translate(this.data[symbol as string]?.name); const name = translate(this.data[symbol]?.name);
let sum = 0; let sum = 0;
for (const item of context.dataset.data) { for (const item of context.dataset.data) {
sum += item; sum += item;
} }
@ -454,6 +467,7 @@ export class GfPortfolioProportionChartComponent
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`]; return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else { } else {
const value = context.raw as number; const value = context.raw as number;
return [ return [
`${name ?? symbol}`, `${name ?? symbol}`,
`${value.toLocaleString(this.locale, { `${value.toLocaleString(this.locale, {

16
libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts

@ -1,5 +1,21 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ScriptableContext, TooltipItem } from 'chart.js';
import { TreemapDataPoint } from 'chartjs-chart-treemap';
export interface GetColorParams { export interface GetColorParams {
annualizedNetPerformancePercent: number; annualizedNetPerformancePercent: number;
negativeNetPerformancePercentsRange: { max: number; min: number }; negativeNetPerformancePercentsRange: { max: number; min: number };
positiveNetPerformancePercentsRange: { max: number; min: number }; positiveNetPerformancePercentsRange: { max: number; min: number };
} }
interface GfTreemapDataPoint extends TreemapDataPoint {
_data: PortfolioPosition;
}
export interface GfTreemapScriptableContext extends ScriptableContext<'treemap'> {
raw: GfTreemapDataPoint;
}
export interface GfTreemapTooltipItem extends TooltipItem<'treemap'> {
raw: GfTreemapDataPoint;
}

49
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -25,7 +25,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js'; import type { ChartData, TooltipOptions } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { Chart, Tooltip } from 'chart.js'; import { Chart, Tooltip } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
@ -35,7 +35,11 @@ import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color'; import OpenColor from 'open-color';
import { GetColorParams } from './interfaces/interfaces'; import type {
GetColorParams,
GfTreemapScriptableContext,
GfTreemapTooltipItem
} from './interfaces/interfaces';
const { gray, green, red } = OpenColor; const { gray, green, red } = OpenColor;
@ -198,10 +202,10 @@ export class GfTreemapChartComponent
min: Math.min(...negativeNetPerformancePercents) min: Math.min(...negativeNetPerformancePercents)
}; };
const data: ChartConfiguration<'treemap'>['data'] = { const data: ChartData<'treemap'> = {
datasets: [ datasets: [
{ {
backgroundColor: (context) => { backgroundColor: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -232,7 +236,7 @@ export class GfTreemapChartComponent
key: 'allocationInPercentage', key: 'allocationInPercentage',
labels: { labels: {
align: 'left', align: 'left',
color: (context) => { color: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -261,7 +265,7 @@ export class GfTreemapChartComponent
}, },
display: true, display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }], font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: ({ raw }) => { formatter: ({ raw }: GfTreemapScriptableContext) => {
// Round to 4 decimal places // Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect = let netPerformancePercentWithCurrencyEffect =
Math.round( Math.round(
@ -286,32 +290,35 @@ export class GfTreemapChartComponent
position: 'top' position: 'top'
}, },
spacing: 1, spacing: 1,
// @ts-expect-error: should be PortfolioPosition[]
tree: this.holdings tree: this.holdings
} }
] ]
} as any; };
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'treemap'>(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animation: false, animation: false,
onClick: (event, activeElements) => { onClick: (_, activeElements, chart: Chart<'treemap'>) => {
try { try {
const dataIndex = activeElements[0].index; const dataIndex = activeElements[0].index;
const datasetIndex = activeElements[0].datasetIndex; const datasetIndex = activeElements[0].datasetIndex;
const dataset = orderBy( const dataset = orderBy(
event.chart.data.datasets[datasetIndex].tree, chart.data.datasets[datasetIndex].tree,
['allocationInPercentage'], ['allocationInPercentage'],
['desc'] ['desc']
); ) as PortfolioPosition[];
const dataSource: DataSource = dataset[dataIndex].dataSource; const dataSource: DataSource = dataset[dataIndex].dataSource;
const symbol: string = dataset[dataIndex].symbol; const symbol: string = dataset[dataIndex].symbol;
@ -321,15 +328,14 @@ export class GfTreemapChartComponent
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
event.native.target.style.cursor = chartElement[0] (event.native?.target as HTMLElement).style.cursor =
? this.cursor chartElement[0] ? this.cursor : 'default';
: 'default';
} }
}, },
plugins: { plugins: {
tooltip: this.getTooltipPluginConfiguration() tooltip: this.getTooltipPluginConfiguration()
} }
} as unknown, },
type: 'treemap' type: 'treemap'
}); });
} }
@ -338,16 +344,17 @@ export class GfTreemapChartComponent
this.isLoading = false; this.isLoading = false;
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'treemap'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: ({ raw }) => { label: ({ raw }: GfTreemapTooltipItem) => {
const allocationInPercentage = `${((raw._data.allocationInPercentage as number) * 100).toFixed(2)}%`; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`;
const name = raw._data.name; const name = raw._data.name;
const sign = const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
@ -356,11 +363,11 @@ export class GfTreemapChartComponent
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency as number; const value = raw._data.valueInBaseCurrency;
return [ return [
`${name ?? symbol} (${allocationInPercentage})`, `${name ?? symbol} (${allocationInPercentage})`,
`${value.toLocaleString(this.locale, { `${value?.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
})} ${this.baseCurrency}`, })} ${this.baseCurrency}`,

59
libs/ui/src/lib/value/value.component.ts

@ -6,7 +6,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input, Input,
OnChanges OnChanges,
computed,
input
} from '@angular/core'; } from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -30,7 +32,6 @@ export class GfValueComponent implements OnChanges {
@Input() isPercent = false; @Input() isPercent = false;
@Input() locale: string; @Input() locale: string;
@Input() position = ''; @Input() position = '';
@Input() precision: number;
@Input() size: 'large' | 'medium' | 'small' = 'small'; @Input() size: 'large' | 'medium' | 'small' = 'small';
@Input() subLabel = ''; @Input() subLabel = '';
@Input() unit = ''; @Input() unit = '';
@ -42,6 +43,22 @@ export class GfValueComponent implements OnChanges {
public isString = false; public isString = false;
public useAbsoluteValue = false; public useAbsoluteValue = false;
public readonly precision = input<number>();
private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => {
const digits = this.hasPrecision ? this.precision() : 2;
return {
maximumFractionDigits: digits,
minimumFractionDigits: digits
};
});
private get hasPrecision() {
const precision = this.precision();
return precision !== undefined && precision >= 0;
}
public ngOnChanges() { public ngOnChanges() {
this.initializeVariables(); this.initializeVariables();
@ -56,50 +73,37 @@ export class GfValueComponent implements OnChanges {
try { try {
this.formattedValue = this.absoluteValue.toLocaleString( this.formattedValue = this.absoluteValue.toLocaleString(
this.locale, this.locale,
{ this.formatOptions()
maximumFractionDigits:
this.precision >= 0 ? this.precision : 2,
minimumFractionDigits:
this.precision >= 0 ? this.precision : 2
}
); );
} catch {} } catch {}
} else if (this.isPercent) { } else if (this.isPercent) {
try { try {
this.formattedValue = (this.absoluteValue * 100).toLocaleString( this.formattedValue = (this.absoluteValue * 100).toLocaleString(
this.locale, this.locale,
{ this.formatOptions()
maximumFractionDigits:
this.precision >= 0 ? this.precision : 2,
minimumFractionDigits:
this.precision >= 0 ? this.precision : 2
}
); );
} catch {} } catch {}
} }
} else if (this.isCurrency) { } else if (this.isCurrency) {
try { try {
this.formattedValue = this.value?.toLocaleString(this.locale, { this.formattedValue = this.value?.toLocaleString(
maximumFractionDigits: this.precision >= 0 ? this.precision : 2, this.locale,
minimumFractionDigits: this.precision >= 0 ? this.precision : 2 this.formatOptions()
}); );
} catch {} } catch {}
} else if (this.isPercent) { } else if (this.isPercent) {
try { try {
this.formattedValue = (this.value * 100).toLocaleString( this.formattedValue = (this.value * 100).toLocaleString(
this.locale, this.locale,
{ this.formatOptions()
maximumFractionDigits: this.precision >= 0 ? this.precision : 2,
minimumFractionDigits: this.precision >= 0 ? this.precision : 2
}
); );
} catch {} } catch {}
} else if (this.precision >= 0) { } else if (this.hasPrecision) {
try { try {
this.formattedValue = this.value?.toLocaleString(this.locale, { this.formattedValue = this.value?.toLocaleString(
maximumFractionDigits: this.precision, this.locale,
minimumFractionDigits: this.precision this.formatOptions()
}); );
} catch {} } catch {}
} else { } else {
this.formattedValue = this.value?.toLocaleString(this.locale); this.formattedValue = this.value?.toLocaleString(this.locale);
@ -139,7 +143,6 @@ export class GfValueComponent implements OnChanges {
this.isNumber = false; this.isNumber = false;
this.isString = false; this.isString = false;
this.locale = this.locale || getLocale(); this.locale = this.locale || getLocale();
this.precision = this.precision >= 0 ? this.precision : undefined;
this.useAbsoluteValue = false; this.useAbsoluteValue = false;
} }
} }

634
package-lock.json

File diff suppressed because it is too large

34
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.234.0", "version": "2.238.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -106,6 +106,7 @@
"dotenv": "17.2.3", "dotenv": "17.2.3",
"dotenv-expand": "12.0.3", "dotenv-expand": "12.0.3",
"envalid": "8.1.1", "envalid": "8.1.1",
"fast-redact": "3.5.0",
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",
"helmet": "7.0.0", "helmet": "7.0.0",
@ -118,7 +119,7 @@
"ng-extract-i18n-merge": "3.2.1", "ng-extract-i18n-merge": "3.2.1",
"ngx-device-detector": "11.0.0", "ngx-device-detector": "11.0.0",
"ngx-markdown": "21.0.1", "ngx-markdown": "21.0.1",
"ngx-skeleton-loader": "11.3.0", "ngx-skeleton-loader": "12.0.0",
"open-color": "1.9.1", "open-color": "1.9.1",
"papaparse": "5.3.1", "papaparse": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
@ -128,11 +129,11 @@
"passport-openidconnect": "0.1.2", "passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "20.1.0", "stripe": "20.3.0",
"svgmap": "2.14.0", "svgmap": "2.14.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.27.0", "twitter-api-v2": "1.29.0",
"yahoo-finance2": "3.11.2", "yahoo-finance2": "3.13.0",
"zone.js": "0.16.0" "zone.js": "0.16.0"
}, },
"devDependencies": { "devDependencies": {
@ -151,21 +152,22 @@
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.0.9", "@nestjs/schematics": "11.0.9",
"@nestjs/testing": "11.1.8", "@nestjs/testing": "11.1.8",
"@nx/angular": "22.4.1", "@nx/angular": "22.4.5",
"@nx/eslint-plugin": "22.4.1", "@nx/eslint-plugin": "22.4.5",
"@nx/jest": "22.4.1", "@nx/jest": "22.4.5",
"@nx/js": "22.4.1", "@nx/js": "22.4.5",
"@nx/module-federation": "22.4.1", "@nx/module-federation": "22.4.5",
"@nx/nest": "22.4.1", "@nx/nest": "22.4.5",
"@nx/node": "22.4.1", "@nx/node": "22.4.5",
"@nx/storybook": "22.4.1", "@nx/storybook": "22.4.5",
"@nx/web": "22.4.1", "@nx/web": "22.4.5",
"@nx/workspace": "22.4.1", "@nx/workspace": "22.4.5",
"@schematics/angular": "21.1.1", "@schematics/angular": "21.1.1",
"@storybook/addon-docs": "10.1.10", "@storybook/addon-docs": "10.1.10",
"@storybook/angular": "10.1.10", "@storybook/angular": "10.1.10",
"@trivago/prettier-plugin-sort-imports": "5.2.2", "@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/big.js": "6.2.2", "@types/big.js": "6.2.2",
"@types/fast-redact": "3.0.4",
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/jsonpath": "0.2.4", "@types/jsonpath": "0.2.4",
@ -184,7 +186,7 @@
"jest": "30.2.0", "jest": "30.2.0",
"jest-environment-jsdom": "30.2.0", "jest-environment-jsdom": "30.2.0",
"jest-preset-angular": "16.0.0", "jest-preset-angular": "16.0.0",
"nx": "22.4.1", "nx": "22.4.5",
"prettier": "3.8.1", "prettier": "3.8.1",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.0", "prisma": "6.19.0",

58
test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json

@ -0,0 +1,58 @@
{
"meta": {
"date": "2026-02-07T02:09:15.272Z",
"version": "dev"
},
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2025-12-11T05:00:00.000Z",
"fee": 1,
"id": "cea33621-9f4b-4cea-9eb7-be38264888aa",
"quantity": 9,
"symbol": "JNUG",
"type": "BUY",
"unitPrice": 209.45
},
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2025-12-18T05:00:00.000Z",
"fee": 1,
"id": "53be2e35-a0af-476c-9e63-9b1a437114a4",
"quantity": 9,
"symbol": "JNUG",
"type": "SELL",
"unitPrice": 210
},
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2025-12-18T05:00:00.000Z",
"fee": 1,
"id": "6648eeeb-8ea5-46b6-9a30-f278a9ed477b",
"quantity": 10,
"symbol": "JNUG",
"type": "BUY",
"unitPrice": 204.11
},
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2025-12-28T05:00:00.000Z",
"fee": 1,
"id": "861e736d-0086-496c-8f85-31328479cf63",
"quantity": 10,
"symbol": "JNUG",
"type": "SELL",
"unitPrice": 208.01
}
],
"user": {
"settings": {
"currency": "USD",
"performanceCalculationType": "ROAI"
}
}
}
Loading…
Cancel
Save