Browse Source

Merge remote-tracking branch 'origin/main' into feature/enable-strict-null-checks-in-ui

pull/6264/head
KenTandrian 6 days ago
parent
commit
42b3ab98ba
  1. 12
      CHANGELOG.md
  2. 6
      apps/api/src/app/account/account.controller.ts
  3. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 2
      apps/api/src/app/subscription/subscription.service.ts
  5. 91
      apps/api/src/helper/object.helper.spec.ts
  6. 67
      apps/api/src/helper/object.helper.ts
  7. 42
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  8. 19
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  9. 38
      apps/client/src/app/components/admin-users/admin-users.component.ts
  10. 5
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  11. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  12. 23
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  13. 1
      apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts
  14. 25
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  15. 40
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  16. 10
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  17. 2
      apps/client/src/app/pages/accounts/accounts-page.html
  18. 8
      libs/common/src/lib/chart-helper.ts
  19. 52
      libs/common/src/lib/config.ts
  20. 4
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  21. 14
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  22. 8
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  23. 5
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  24. 23
      libs/ui/src/lib/line-chart/line-chart.component.ts
  25. 29
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  26. 17
      libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts
  27. 30
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  28. 41
      package-lock.json
  29. 8
      package.json

12
CHANGELOG.md

@ -7,14 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Upgraded `stripe` from version `20.1.0` to `20.3.0`
## 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
- Upgraded `yahoo-finance2` from version `3.11.2` to `3.13.0`
## 2.234.0 - 2026-01-30 ## 2.234.0 - 2026-01-30

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
}); });
} }

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',

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

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

91
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': {
@ -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: {
@ -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,
@ -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,
@ -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,
@ -1831,7 +1798,7 @@ describe('redactAttributes', () => {
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,
@ -1882,7 +1849,7 @@ describe('redactAttributes', () => {
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,
@ -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,
@ -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,
@ -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,
@ -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,
@ -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,
@ -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,
@ -2964,7 +2931,7 @@ describe('redactAttributes', () => {
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,

67
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))
: object;
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 { } else {
// If the attribute is not present on the current object, return value;
// 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({
object: data,
options: [
{
valueMap, valueMap,
attribute: 'dataSource' object: data,
} paths: [
'activities[*].SymbolProfile.dataSource',
'benchmarks[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
] ]
}); });
} }

38
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,19 +139,10 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
]; ];
} }
this.route.paramMap
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const userId = params.get('userId');
if (userId) {
this.openUserDetailDialog(userId);
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
.subscribe((state) => { takeUntil(this.unsubscribeSubject),
tap((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -164,6 +155,15 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
permissions.impersonateAllUsers permissions.impersonateAllUsers
); );
} }
}),
switchMap(() => this.route.paramMap)
)
.subscribe((params) => {
const userId = params.get('userId');
if (userId) {
this.openUserDetailDialog(userId);
}
}); });
addIcons({ addIcons({
@ -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) => {
if (data?.action === 'delete' && data?.userId) {
this.onDeleteUser(data.userId);
} else {
this.router.navigate( this.router.navigate(
internalRoutes.adminControl.subRoutes.users.routerLink internalRoutes.adminControl.subRoutes.users.routerLink
); );
}
}); });
} }
} }

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

@ -14,6 +14,7 @@ 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';
@ -96,6 +97,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
registerChartConfiguration();
addIcons({ arrowForwardOutline }); addIcons({ arrowForwardOutline });
} }
@ -154,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(); this.getTooltipPluginConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {

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

@ -380,10 +380,10 @@
[deviceType]="data.deviceType" [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"
/> />

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

@ -14,6 +14,7 @@ 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 {
@ -34,12 +35,15 @@ import {
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
type ScriptableLineSegmentContext,
TimeScale, TimeScale,
Tooltip, Tooltip,
type TooltipOptions 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';
@ -80,6 +84,8 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
TimeScale, TimeScale,
Tooltip Tooltip
); );
registerChartConfiguration();
} }
public ngOnChanges() { public ngOnChanges() {
@ -154,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(); 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();
@ -301,7 +304,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}; };
} }
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>

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;
} }
} }

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

@ -4,6 +4,7 @@
<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" [deviceType]="deviceType"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -14,7 +15,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()"

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

@ -54,15 +54,17 @@ export function getTooltipOptions<T extends ChartType>({
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. // @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context) => { label: (context) => {
let label = (context.dataset as ControllerDatasetOptions).label ?? ''; let label = (context.dataset as ControllerDatasetOptions).label ?? '';
if (label) { if (label) {
label += ': '; label += ': ';
} }
const yPoint = (context.parsed as Point).y; const yPoint = (context.parsed as Point).y;
if (yPoint !== null) { if (yPoint !== null) {
if (currency) { if (currency) {
label += `${yPoint.toLocaleString(locale, { label += `${yPoint.toLocaleString(locale, {
@ -75,10 +77,12 @@ export function getTooltipOptions<T extends ChartType>({
label += yPoint.toFixed(2); label += yPoint.toFixed(2);
} }
} }
return label; return label;
}, },
title: (contexts) => { title: (contexts) => {
const xPoint = (contexts[0].parsed as Point).x; const xPoint = (contexts[0].parsed as Point).x;
if (groupBy && xPoint !== null) { if (groupBy && xPoint !== null) {
return formatGroupedDate({ groupBy, date: xPoint }); return formatGroupedDate({ groupBy, date: xPoint });
} }
@ -105,6 +109,7 @@ 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
@ -132,6 +137,7 @@ export function getVerticalHoverLinePlugin<T extends 'line' | 'bar'>(
const xValue = active[0].element.x; const xValue = active[0].element.x;
const context = chartCanvas.nativeElement.getContext('2d'); const context = chartCanvas.nativeElement.getContext('2d');
if (context) { if (context) {
context.lineWidth = width; context.lineWidth = width;
context.strokeStyle = color; context.strokeStyle = color;

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 = [
{ {

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

@ -115,7 +115,7 @@
></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"
@ -129,7 +129,7 @@
{{ element.transactionCount }} {{ element.transactionCount }}
</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>

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

@ -115,10 +115,10 @@ export const Loading: Story = {
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 +127,39 @@ export const Loading: Story = {
export const Default: Story = { export const Default: Story = {
args: { args: {
accounts, accounts,
activitiesCount: 12,
baseCurrency: 'USD', baseCurrency: 'USD',
deviceType: 'desktop', 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', 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
} }
}; };

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

@ -55,20 +55,20 @@ import { Subject, Subscription } from 'rxjs';
}) })
export class GfAccountsTableComponent implements OnChanges, OnDestroy { export class GfAccountsTableComponent implements OnChanges, OnDestroy {
@Input() accounts: Account[]; @Input() accounts: Account[];
@Input() activitiesCount: number;
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showActivitiesCount = true;
@Input() showAllocationInPercentage: boolean; @Input() showAllocationInPercentage: boolean;
@Input() showBalance = true; @Input() showBalance = true;
@Input() showFooter = true; @Input() showFooter = true;
@Input() showTransactions = true;
@Input() showValue = true; @Input() showValue = true;
@Input() showValueInBaseCurrency = true; @Input() showValueInBaseCurrency = true;
@Input() totalBalanceInBaseCurrency: number; @Input() totalBalanceInBaseCurrency: number;
@Input() totalValueInBaseCurrency: number; @Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<Account>(); @Output() accountToUpdate = new EventEmitter<Account>();
@ -101,8 +101,8 @@ export class GfAccountsTableComponent implements OnChanges, OnDestroy {
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['status', 'account', 'platform']; this.displayedColumns = ['status', 'account', 'platform'];
if (this.showTransactions) { if (this.showActivitiesCount) {
this.displayedColumns.push('transactions'); this.displayedColumns.push('activitiesCount');
} }
if (this.showBalance) { if (this.showBalance) {

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

@ -290,8 +290,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
callbacks: { callbacks: {
footer: (items) => { footer: (items) => {
const totalAmount = items.reduce( const totalAmount = items.reduce(
// @ts-ignore (a, b) => a + (b.parsed.y ?? 0),
(a, b) => a + b.parsed.y,
0 0
); );
@ -313,8 +312,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);

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

@ -25,7 +25,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { import {
type AnimationSpec, type AnimationsSpec,
Chart, Chart,
Filler, Filler,
LinearScale, LinearScale,
@ -39,6 +39,8 @@ import {
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],
@ -85,6 +87,8 @@ export class GfLineChartComponent
TimeScale, TimeScale,
Tooltip Tooltip
); );
registerChartConfiguration();
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -131,8 +135,10 @@ export class GfLineChartComponent
0, 0,
0, 0,
0, 0,
// @ts-ignore ((this.chartCanvas.nativeElement.parentNode as HTMLElement)
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5 .offsetHeight *
4) /
5
); );
if (gradient && this.showGradient) { if (gradient && this.showGradient) {
@ -175,12 +181,12 @@ export class GfLineChartComponent
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.animations = this.isAnimated
? animations
: undefined;
this.chart.options.plugins ??= {}; this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration(); this.getTooltipPluginConfiguration();
this.chart.options.animations = this.isAnimated
? animations
: undefined;
this.chart.update(); this.chart.update();
} else { } else {
@ -296,7 +302,7 @@ export class GfLineChartComponent
}: { }: {
axis: 'x' | 'y'; axis: 'x' | 'y';
labels: string[]; labels: string[];
}): AnimationSpec<'line'> { }): Partial<AnimationsSpec<'line'>[string]> {
const delayBetweenPoints = this.ANIMATION_DURATION / labels.length; const delayBetweenPoints = this.ANIMATION_DURATION / labels.length;
return { return {
@ -306,8 +312,7 @@ export class GfLineChartComponent
} }
context[`${axis}Started`] = true; context[`${axis}Started`] = true;
// @ts-ignore return context.dataIndex * delayBetweenPoints;
return context.index * delayBetweenPoints;
}, },
duration: delayBetweenPoints, duration: delayBetweenPoints,
easing: 'linear', easing: 'linear',

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

@ -29,9 +29,7 @@ import {
type ChartDataset, type ChartDataset,
DoughnutController, DoughnutController,
LinearScale, LinearScale,
type Plugin,
Tooltip, Tooltip,
type TooltipItem,
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels';
@ -357,23 +355,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;
// @ts-ignore const symbol = chart.data.labels?.[dataIndex] as string;
const symbol: string = event.chart.data.labels[dataIndex];
const dataSource = this.data[symbol].dataSource!; const dataSource = this.data[symbol].dataSource;
if (dataSource) {
this.proportionChartClicked.emit({ dataSource, symbol }); this.proportionChartClicked.emit({ dataSource, symbol });
}
} catch {} } catch {}
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
// @ts-ignore (event.native?.target as HTMLElement).style.cursor =
event.native.target.style.cursor = chartElement[0] chartElement[0] ? this.cursor : 'default';
? this.cursor
: 'default';
} }
}, },
plugins: { plugins: {
@ -407,7 +404,7 @@ export class GfPortfolioProportionChartComponent
tooltip: this.getTooltipPluginConfiguration(data) tooltip: this.getTooltipPluginConfiguration(data)
} }
}, },
plugins: [ChartDataLabels as Plugin<'doughnut'>], plugins: [ChartDataLabels],
type: 'doughnut' type: 'doughnut'
}); });
} }
@ -442,13 +439,15 @@ export class GfPortfolioProportionChartComponent
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
// @ts-ignore // @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context: TooltipItem<'doughnut'>) => { label: (context: TooltipItem<'doughnut'>) => {
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`;
@ -456,9 +455,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;
} }
@ -471,6 +471,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, {

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

@ -1,9 +1,7 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { import { ScriptableContext, TooltipItem } from 'chart.js';
TreemapDataPoint, import { TreemapDataPoint } from 'chartjs-chart-treemap';
TreemapScriptableContext
} from 'chartjs-chart-treemap';
export interface GetColorParams { export interface GetColorParams {
annualizedNetPerformancePercent: number; annualizedNetPerformancePercent: number;
@ -11,8 +9,13 @@ export interface GetColorParams {
positiveNetPerformancePercentsRange: { max: number; min: number }; positiveNetPerformancePercentsRange: { max: number; min: number };
} }
export interface GfTreemapChartTooltipContext extends TreemapScriptableContext { interface GfTreemapDataPoint extends TreemapDataPoint {
raw: TreemapDataPoint & {
_data: PortfolioPosition; _data: PortfolioPosition;
}; }
export interface GfTreemapScriptableContext extends ScriptableContext<'treemap'> {
raw: GfTreemapDataPoint;
}
export interface GfTreemapTooltipItem extends TooltipItem<'treemap'> {
raw: GfTreemapDataPoint;
} }

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

@ -35,9 +35,10 @@ 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 { import type {
GetColorParams, GetColorParams,
GfTreemapChartTooltipContext GfTreemapScriptableContext,
GfTreemapTooltipItem
} from './interfaces/interfaces'; } from './interfaces/interfaces';
const { gray, green, red } = OpenColor; const { gray, green, red } = OpenColor;
@ -204,7 +205,7 @@ export class GfTreemapChartComponent
const data: ChartData<'treemap'> = { const data: ChartData<'treemap'> = {
datasets: [ datasets: [
{ {
backgroundColor: (context: GfTreemapChartTooltipContext) => { backgroundColor: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -235,7 +236,7 @@ export class GfTreemapChartComponent
key: 'allocationInPercentage', key: 'allocationInPercentage',
labels: { labels: {
align: 'left', align: 'left',
color: (context: GfTreemapChartTooltipContext) => { color: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -264,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 }: GfTreemapChartTooltipContext) => { formatter: ({ raw }: GfTreemapScriptableContext) => {
// Round to 4 decimal places // Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect = let netPerformancePercentWithCurrencyEffect =
Math.round( Math.round(
@ -289,7 +290,7 @@ export class GfTreemapChartComponent
position: 'top' position: 'top'
}, },
spacing: 1, spacing: 1,
// @ts-ignore // @ts-expect-error: should be PortfolioPosition[]
tree: this.holdings tree: this.holdings
} }
] ]
@ -308,17 +309,16 @@ export class GfTreemapChartComponent
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(
// @ts-ignore chart.data.datasets[datasetIndex].tree,
event.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;
@ -328,10 +328,8 @@ export class GfTreemapChartComponent
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
// @ts-ignore (event.native?.target as HTMLElement).style.cursor =
event.native.target.style.cursor = chartElement[0] chartElement[0] ? this.cursor : 'default';
? this.cursor
: 'default';
} }
}, },
plugins: { plugins: {
@ -353,9 +351,9 @@ export class GfTreemapChartComponent
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: {
// @ts-ignore label: ({ raw }: GfTreemapTooltipItem) => {
label: ({ raw }: GfTreemapChartTooltipContext) => {
const allocationInPercentage = `${(raw._data.allocationInPercentage * 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 =

41
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.234.0", "version": "2.235.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.234.0", "version": "2.235.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -62,6 +62,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",
@ -84,11 +85,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.27.0",
"yahoo-finance2": "3.11.2", "yahoo-finance2": "3.13.0",
"zone.js": "0.16.0" "zone.js": "0.16.0"
}, },
"devDependencies": { "devDependencies": {
@ -122,6 +123,7 @@
"@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",
@ -12936,6 +12938,13 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/fast-redact": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/fast-redact/-/fast-redact-3.0.4.tgz",
"integrity": "sha512-tgGJaXucrCH4Yx2l/AI6e/JQksZhKGIQsVwBMTh+nxUhQDv5tXScTs5DHTw+qSKDXnHL2dTAh1e2rd5pcFQyNQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@ -19940,6 +19949,15 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-safe-stringify": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@ -32087,13 +32105,10 @@
} }
}, },
"node_modules/stripe": { "node_modules/stripe": {
"version": "20.1.0", "version": "20.3.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.0.tgz", "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.0.tgz",
"integrity": "sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==", "integrity": "sha512-DYzcmV1MfYhycr1GwjCjeQVYk9Gu8dpxyTlu7qeDCsuguug7oUTxPsUQuZeSf/OPzK7pofqobvOKVqAwlpgf/Q==",
"license": "MIT", "license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": { "engines": {
"node": ">=16" "node": ">=16"
}, },
@ -35361,9 +35376,9 @@
} }
}, },
"node_modules/yahoo-finance2": { "node_modules/yahoo-finance2": {
"version": "3.11.2", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.11.2.tgz", "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.13.0.tgz",
"integrity": "sha512-SIvMXjrOktBRD8m+qXAGCK+vR1vwBKuMgCnvmbxv29+t6LTDu0vAUxNYfbigsMRTmBzS4F9TQwbYF90g3Om4HA==", "integrity": "sha512-czBj2q/MD68YEsB7aXNnGhJvWxYZn01O5r/i7VYiQV2m2sWwhca6tKgjwf/LT7zHHEVxhKNiGLB46glLnmq9Ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@deno/shim-deno": "~0.18.0", "@deno/shim-deno": "~0.18.0",

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.234.0", "version": "2.235.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",
@ -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.27.0",
"yahoo-finance2": "3.11.2", "yahoo-finance2": "3.13.0",
"zone.js": "0.16.0" "zone.js": "0.16.0"
}, },
"devDependencies": { "devDependencies": {
@ -166,6 +167,7 @@
"@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",

Loading…
Cancel
Save