Browse Source

Merge branch 'feature/asset-profiles-read-endpoint' of https://github.com/Sjohn21/ghostfolio into pr/7056

pull/7056/head
Thomas Kaul 4 weeks ago
parent
commit
5c6c92338f
  1. 47
      CHANGELOG.md
  2. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 20
      apps/api/src/app/user/user.service.ts
  4. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  5. 1
      apps/api/src/services/configuration/configuration.service.ts
  6. 1
      apps/api/src/services/interfaces/environment.interface.ts
  7. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  8. 3
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  9. 3
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  10. 23
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  11. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  12. 8
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  13. 13
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  14. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  15. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  16. 3
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  17. 4
      apps/client/src/app/components/admin-users/admin-users.component.ts
  18. 6
      apps/client/src/app/components/admin-users/admin-users.html
  19. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  20. 213
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  21. 3
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  22. 4
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  23. 5
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  24. 30
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  25. 262
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  26. 4
      apps/client/src/app/components/rules/rules.component.html
  27. 2
      apps/client/src/app/pages/about/overview/about-overview-page.html
  28. 63
      apps/client/src/app/pages/admin/admin-page.component.ts
  29. 2
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  30. 2
      apps/client/src/app/pages/faq/saas/saas-page.html
  31. 2
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  32. 5
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  33. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  34. 11
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  35. 4
      apps/client/src/app/pages/portfolio/fire/fire-page.scss
  36. 14
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  37. 10
      apps/client/src/app/pages/pricing/pricing-page.html
  38. 8
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  39. 2
      apps/client/src/locales/messages.de.xlf
  40. 22
      apps/client/src/locales/messages.es.xlf
  41. 3
      apps/client/src/main.ts
  42. 8
      apps/client/src/styles/theme.scss
  43. 4
      libs/common/src/lib/config.ts
  44. 20
      libs/common/src/lib/helper.ts
  45. 8
      libs/common/src/lib/interfaces/index.ts
  46. 6
      libs/common/src/lib/interfaces/line-chart-item.interface.ts
  47. 3
      libs/common/src/lib/interfaces/referral-partner.interface.ts
  48. 16
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  49. 2
      libs/common/src/lib/interfaces/user.interface.ts
  50. 79
      libs/common/src/lib/personal-finance-tools.ts
  51. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  52. 12
      libs/ui/src/lib/activities-table/activities-table.component.html
  53. 7
      libs/ui/src/lib/chart/chart.registry.ts
  54. 4
      libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
  55. 6
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts
  56. 9
      libs/ui/src/lib/page-tabs/interfaces/interfaces.ts
  57. 49
      libs/ui/src/lib/page-tabs/page-tabs.component.html
  58. 43
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  59. 3
      libs/ui/src/lib/page-tabs/page-tabs.component.ts
  60. 5
      libs/ui/src/lib/portfolio-proportion-chart/interfaces/interfaces.ts
  61. 18
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  62. 25
      libs/ui/src/lib/services/data.service.ts
  63. 2
      libs/ui/src/lib/world-map-chart/world-map-chart.component.ts
  64. 68
      package-lock.json
  65. 12
      package.json

47
CHANGELOG.md

@ -10,6 +10,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` - Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
### Fixed
- Fixed an issue with the localization of the country names
## 3.12.0 - 2026-06-17
### Changed
- Improved the styling of the checkboxes to consistently use the primary color in their states
- Improved the account name display in the accounts table
- Improved the name display in the activities table
- Improved the last activity display in the users table of the admin control panel
- Improved the registration display in the users table of the admin control panel
- Improved the user id display in the users table of the admin control panel
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `svgmap` from version `2.19.3` to `2.21.0`
### Fixed
- Fixed a chart error on interaction by registering the annotation plugin early
- Fixed an issue on the allocations page where clicking an account in the _By Account_ chart did not open the detail dialog
- Restricted the maximum height of the import activities dialog
- Fixed the dark mode styling of the safe withdrawal rate selector in the _FIRE_ section (experimental)
## 3.11.0 - 2026-06-14
### Added
- Added support for a click handler in the page tabs component
### Changed
- Improved the styling of the tabs across various dialogs
- Improved the styling of the page tabs component on desktop
- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental)
- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control
- Improved the language localization for Spanish (`es`)
- Upgraded `bull-board` from version `7.1.5` to `7.2.1`
- Upgraded `date-fns` from version `4.1.0` to `4.4.0`
### Fixed
- Improved the loading state when customizing the rule thresholds on the _X-ray_ page
## 3.10.0 - 2026-06-13 ## 3.10.0 - 2026-06-13

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

@ -957,6 +957,18 @@ export class PortfolioService {
marketPriceMin, marketPriceMin,
SymbolProfile, SymbolProfile,
tags, tags,
assetProfile: {
assetClass: SymbolProfile.assetClass,
assetSubClass: SymbolProfile.assetSubClass,
countries: SymbolProfile.countries,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
isin: SymbolProfile.isin,
name: SymbolProfile.name,
sectors: SymbolProfile.sectors,
symbol: SymbolProfile.symbol,
userId: SymbolProfile.userId
},
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),

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

@ -28,14 +28,16 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS
locale as defaultLocale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
User as IUser, User as IUser,
ReferralPartner,
SystemMessage, SystemMessage,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -100,7 +102,7 @@ export class UserService {
public async getUser({ public async getUser({
impersonationUserId, impersonationUserId,
locale = defaultLocale, locale = DEFAULT_LOCALE,
user user
}: { }: {
impersonationUserId: string; impersonationUserId: string;
@ -153,6 +155,17 @@ export class UserService {
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
(settings.settings as UserSettings)?.baseCurrency; (settings.settings as UserSettings)?.baseCurrency;
let referralPartners: ReferralPartner[];
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
) {
referralPartners = await this.propertyService.getByKey<ReferralPartner[]>(
PROPERTY_REFERRAL_PARTNERS
);
}
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
const systemMessageProperty = const systemMessageProperty =
@ -179,6 +192,7 @@ export class UserService {
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
referralPartners,
subscription, subscription,
systemMessage, systemMessage,
tags, tags,

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

@ -64,6 +64,7 @@ export class TransformDataSourceInResponseInterceptor<
paths: [ paths: [
'activities[*].dataSource', 'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource', 'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource', 'benchmarks[*].dataSource',
'errors[*].dataSource', 'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',

1
apps/api/src/services/configuration/configuration.service.ts

@ -30,7 +30,6 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;

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

@ -23,8 +23,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: DATA_GATHERING_QUEUE, name: DATA_GATHERING_QUEUE,
options: { options: {
displayName: 'Data Gathering', displayName: 'Data Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

3
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: { options: {
displayName: 'Portfolio Snapshot Computation', displayName: 'Portfolio Snapshot Computation'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

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

@ -20,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
adapter: BullAdapter, adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE, name: STATISTICS_GATHERING_QUEUE,
options: { options: {
displayName: 'Statistics Gathering', displayName: 'Statistics Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}) })
] ]

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

@ -1,8 +1,5 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -10,7 +7,6 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces'; import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit {
'actions' 'actions'
]; ];
protected hasPermissionToAccessBullBoard = false;
protected isLoading = false; protected isLoading = false;
protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString( this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
} }
}); });
@ -193,18 +182,6 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
protected onViewData(aData: AdminJobs['jobs'][0]['data']) { protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')

9
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -1,15 +1,6 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status"> <mat-select formControlName="status">

8
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_COLOR_SCHEME, DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE, DEFAULT_LOCALE,
locale DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
@ -429,7 +429,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
colorScheme: colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams, } satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -464,7 +464,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams, } satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });

13
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -239,7 +239,18 @@
[disabled]="!selection.hasValue()" [disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()" (click)="onDeleteAssetProfiles()"
> >
<ng-container i18n>Delete Profiles</ng-container> <ng-container i18n
>Delete
{{
selection.selected.length > 1
? selection.selected.length
: ''
}}
{selection.selected.length, plural,
=1 {Profile}
other {Profiles}
}</ng-container
>
</button> </button>
</mat-menu> </mat-menu>
</th> </th>

12
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -32,6 +32,8 @@ export class AdminMarketDataService {
public deleteAssetProfiles( public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
) { ) {
const assetProfileCount = aAssetProfileIdentifiers.length;
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
const deleteRequests = aAssetProfileIdentifiers.map( const deleteRequests = aAssetProfileIdentifiers.map(
@ -44,7 +46,10 @@ export class AdminMarketDataService {
.pipe( .pipe(
catchError(() => { catchError(() => {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not delete profiles.` title:
assetProfileCount === 1
? $localize`Oops! Could not delete the asset profile.`
: $localize`Oops! Could not delete the asset profiles.`
}); });
return EMPTY; return EMPTY;
@ -56,7 +61,10 @@ export class AdminMarketDataService {
.subscribe(); .subscribe();
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?` title:
assetProfileCount === 1
? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
}); });
} }
} }

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

@ -373,7 +373,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) { ) {
for (const { code, weight } of this.assetProfile.countries) { for (const { code, weight } of this.assetProfile.countries) {
this.countries[code] = { this.countries[code] = {
name: getCountryName({ code, locale: this.data.locale }), name: getCountryName({ code }),
value: weight value: weight
}; };
} }

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

@ -271,8 +271,7 @@
[locale]="data.locale" [locale]="data.locale"
[value]=" [value]="
getCountryName({ getCountryName({
code: assetProfile?.countries[0].code, code: assetProfile?.countries[0].code
locale: data.locale
}) })
" "
>Country</gf-value >Country</gf-value

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

@ -5,7 +5,7 @@ import {
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component'; import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
getDateFnsLocale, getDateFnsLocale,
@ -317,7 +317,7 @@ export class GfAdminUsersComponent implements OnInit {
currentUserId: this.user?.id, currentUserId: this.user?.id,
deviceType: this.deviceType(), deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription, hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale ?? locale, locale: this.user?.settings?.locale ?? DEFAULT_LOCALE,
userId: aUserId userId: aUserId
} satisfies UserDetailDialogParams, } satisfies UserDetailDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '60vh', height: this.deviceType() === 'mobile' ? '98vh' : '60vh',

6
apps/client/src/app/components/admin-users/admin-users.html

@ -16,7 +16,7 @@
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2"
mat-cell mat-cell
> >
<div class="d-flex align-items-center"> <div class="align-items-center d-flex text-nowrap">
<span <span
class="d-none d-sm-inline-block text-monospace" class="d-none d-sm-inline-block text-monospace"
[class.text-line-through]="element.role === 'INACTIVE'" [class.text-line-through]="element.role === 'INACTIVE'"
@ -77,7 +77,7 @@
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell mat-cell
> >
{{ formatDistanceToNow(element.createdAt) }} {{ formatDistanceToNow(element.createdAt) }}
@ -183,7 +183,7 @@
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell mat-cell
> >
{{ formatDistanceToNow(element.lastActivity) }} {{ formatDistanceToNow(element.lastActivity) }}

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

@ -44,7 +44,6 @@ import {
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 { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons'; import { arrowForwardOutline } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -86,7 +85,6 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
LinearScale, LinearScale,
LineController, LineController,
LineElement, LineElement,

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

@ -16,6 +16,7 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
LineChartItem, LineChartItem,
NullableLineChartItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -39,11 +40,16 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Inject, OnInit,
OnInit inject
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { import {
@ -71,6 +77,7 @@ import {
swapVerticalOutline, swapVerticalOutline,
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@ -106,75 +113,78 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnInit { export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number; protected accounts: Account[];
public accounts: Account[]; protected activitiesCount: number;
public assetClass: string; protected assetClass: string;
public assetSubClass: string; protected assetSubClass: string;
public averagePrice: number; protected averagePrice: number;
public averagePricePrecision = 2; protected averagePricePrecision = 2;
public benchmarkDataItems: LineChartItem[]; protected benchmarkDataItems: NullableLineChartItem[];
public benchmarkLabel = $localize`Average Unit Price`; protected readonly benchmarkLabel = $localize`Average Unit Price`;
public countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public dataProviderInfo: DataProviderInfo; protected dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>; protected dataSource: MatTableDataSource<Activity>;
public dateOfFirstActivity: string; protected dateOfFirstActivity: Date;
public dividendInBaseCurrency: number; protected dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2; protected dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; protected dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; protected feeInBaseCurrency: number;
public getCountryName = getCountryName; protected readonly getCountryName = getCountryName;
public hasPermissionToCreateOwnTag: boolean; protected hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; protected hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
public holdingForm: FormGroup; protected holdingForm: FormGroup<{
public investmentInBaseCurrencyWithCurrencyEffect: number; tags: FormControl<Tag[] | null>;
public investmentInBaseCurrencyWithCurrencyEffectPrecision = 2; }>;
public isUUID = isUUID; protected investmentInBaseCurrencyWithCurrencyEffect: number;
public marketDataItems: MarketData[] = []; protected investmentInBaseCurrencyWithCurrencyEffectPrecision = 2;
public marketPrice: number; protected readonly isUUID = isUUID;
public marketPriceMax: number; protected marketDataItems: MarketData[] = [];
public marketPriceMaxPrecision = 2; protected marketPrice: number;
public marketPriceMin: number; protected marketPriceMax: number;
public marketPriceMinPrecision = 2; protected marketPriceMaxPrecision = 2;
public marketPricePrecision = 2; protected marketPriceMin: number;
public netPerformance: number; protected marketPriceMinPrecision = 2;
public netPerformancePrecision = 2; protected marketPricePrecision = 2;
public netPerformancePercent: number; protected netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercentWithCurrencyEffect: number; protected netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformancePercentWithCurrencyEffectPrecision = 2; protected netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number; protected netPerformanceWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffectPrecision = 2; protected pageIndex = 0;
public pageIndex = 0; protected readonly pageSize = DEFAULT_PAGE_SIZE;
public pageSize = DEFAULT_PAGE_SIZE; protected quantity: number;
public quantity: number; protected quantityPrecision = 2;
public quantityPrecision = 2; protected reportDataGlitchMail: string;
public reportDataGlitchMail: string; protected readonly routerLinkAdminControlMarketData =
public routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink; internalRoutes.adminControl.subRoutes.marketData.routerLink;
public sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public sortColumn = 'date'; protected sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile; protected SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; protected tagsAvailable: Tag[];
public tagsAvailable: Tag[]; protected readonly translate = translate;
public translate = translate; protected user: User;
public user: User; protected value: number;
public value: number;
protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
public constructor( protected readonly dialogRef = inject(
private changeDetectorRef: ChangeDetectorRef, MatDialogRef<GfHoldingDetailDialogComponent>
private dataService: DataService, );
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, private tags: Tag[];
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private router: Router, private readonly dataService = inject(DataService);
private userService: UserService private readonly destroyRef = inject(DestroyRef);
) { private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
addIcons({ addIcons({
arrowDownCircleOutline, arrowDownCircleOutline,
createOutline, createOutline,
@ -190,12 +200,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
const filters = this.getActivityFilters(); const filters = this.getActivityFilters();
this.holdingForm = this.formBuilder.group({ this.holdingForm = this.formBuilder.group({
tags: [] as string[] tags: new FormControl<Tag[]>([])
}); });
this.holdingForm this.holdingForm.controls.tags.valueChanges
.get('tags') .pipe(takeUntilDestroyed(this.destroyRef))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
@ -268,8 +277,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
marketPrice, marketPrice,
marketPriceMax, marketPriceMax,
marketPriceMin, marketPriceMin,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
quantity, quantity,
@ -290,7 +297,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dateOfFirstActivity = dateOfFirstActivity;
if (dateOfFirstActivity) {
this.dateOfFirstActivity = dateOfFirstActivity;
}
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
if ( if (
@ -318,12 +329,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date, date,
value: averagePrice value: isNumber(averagePrice) ? averagePrice : null
}); });
return { return {
date, date,
value: marketPrice value: marketPrice ?? 0
}; };
} }
); );
@ -365,17 +376,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.marketPricePrecision = 0; this.marketPricePrecision = 0;
} }
this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
@ -438,10 +438,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.countries?.length > 0) { if (SymbolProfile?.countries?.length > 0) {
for (const country of SymbolProfile.countries) { for (const country of SymbolProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {
name: getCountryName({ name: getCountryName({ code: country.code }),
code: country.code,
locale: this.data.locale
}),
value: country.weight value: country.weight
}; };
} }
@ -456,16 +453,16 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
} }
if (isToday(parseISO(this.dateOfFirstActivity))) { if (isToday(this.dateOfFirstActivity)) {
// Add average price // Add average price
this.historicalDataItems.push({ this.historicalDataItems.push({
date: this.dateOfFirstActivity, date: this.dateOfFirstActivity.toISOString(),
value: this.averagePrice value: this.averagePrice
}); });
// Add benchmark 1 // Add benchmark 1
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date: this.dateOfFirstActivity, date: this.dateOfFirstActivity.toISOString(),
value: averagePrice value: averagePrice
}); });
@ -496,7 +493,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.benchmarkDataItems[0]?.value === undefined && this.benchmarkDataItems[0]?.value === undefined &&
isSameMonth(parseISO(this.dateOfFirstActivity), new Date()) isSameMonth(this.dateOfFirstActivity, new Date())
) { ) {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
@ -526,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
hasPermission(this.user.permissions, permissions.createOwnTag) && hasPermission(this.user.permissions, permissions.createOwnTag) &&
this.user?.settings?.isExperimentalFeatures; (this.user?.settings?.isExperimentalFeatures ?? false);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
@ -541,13 +538,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onChangePage(page: PageEvent) { protected onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex; this.pageIndex = page.pageIndex;
this.fetchActivities(); this.fetchActivities();
} }
public onCloneActivity(aActivity: Activity) { protected onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -558,22 +555,22 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onCloseHolding() { protected onCloseHolding() {
const today = new Date(); const today = new Date();
const activity: CreateOrderDto = { const activity: CreateOrderDto = {
accountId: this.accounts.length === 1 ? this.accounts[0].id : null, accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined,
comment: null, comment: undefined,
currency: this.SymbolProfile.currency, currency: this.SymbolProfile?.currency ?? '',
dataSource: this.SymbolProfile.dataSource, dataSource: this.SymbolProfile?.dataSource,
date: today.toISOString(), date: today.toISOString(),
fee: 0, fee: 0,
quantity: this.quantity, quantity: this.quantity,
symbol: this.SymbolProfile.symbol, symbol: this.SymbolProfile?.symbol ?? '',
tags: this.tags.map(({ id }) => { tags: this.tags.map(({ id }) => {
return id; return id;
}), }),
@ -593,7 +590,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onExport() { protected onExport() {
const activityIds = this.dataSource.data.map(({ id }) => { const activityIds = this.dataSource.data.map(({ id }) => {
return id; return id;
}); });
@ -613,13 +610,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onMarketDataChanged(withRefresh = false) { protected onMarketDataChanged(withRefresh = false) {
if (withRefresh) { if (withRefresh) {
this.fetchMarketData(); this.fetchMarketData();
} }
} }
public onUpdateActivity(aActivity: Activity) { protected onUpdateActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {

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

@ -274,8 +274,7 @@
[locale]="data.locale" [locale]="data.locale"
[value]=" [value]="
getCountryName({ getCountryName({
code: SymbolProfile.countries[0].code, code: SymbolProfile.countries[0].code
locale: data.locale
}) })
" "
>Country</gf-value >Country</gf-value

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

@ -1,6 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -149,7 +149,7 @@ export class GfHomeWatchlistComponent implements OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? defaultLocale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
}, },
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });

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

@ -40,9 +40,7 @@ import {
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin, { import { type AnnotationOptions } from 'chartjs-plugin-annotation';
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';
@ -74,7 +72,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
BarController, BarController,
BarElement, BarElement,
LinearScale, LinearScale,

30
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,8 +1,7 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -14,22 +13,37 @@ import { MatSliderModule } from '@angular/material/slider';
import { RuleSettingsDialogParams } from './interfaces/interfaces'; import { RuleSettingsDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSliderModule MatSliderModule,
ReactiveFormsModule
], ],
selector: 'gf-rule-settings-dialog', selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'], styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'
}) })
export class GfRuleSettingsDialogComponent { export class GfRuleSettingsDialogComponent {
public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; public settingsForm: FormGroup;
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>,
) {} private formBuilder: FormBuilder
) {
this.settingsForm = this.formBuilder.group({
thresholdMax: [this.data.settings.thresholdMax],
thresholdMin: [this.data.settings.thresholdMin]
});
}
public onSubmit() {
this.dialogRef.close({
...this.data.settings,
thresholdMax: this.settingsForm.get('thresholdMax')?.value,
thresholdMin: this.settingsForm.get('thresholdMin')?.value
});
}
} }

262
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,132 +1,142 @@
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> <div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content> <form
@if ( class="d-flex flex-column h-100"
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax [formGroup]="settingsForm"
) { (ngSubmit)="onSubmit()"
<div class="w-100"> >
<h6 class="d-flex mb-0"> <div class="py-3" mat-dialog-content>
<ng-container i18n>Threshold range</ng-container>: @if (
<gf-value data.rule.configuration.thresholdMin &&
class="ml-1" data.rule.configuration.thresholdMax
[isPercent]="data.rule.configuration.threshold.unit === '%'" ) {
[locale]="data.locale" <div class="w-100">
[precision]="2" <h6 class="d-flex mb-0">
[value]="data.settings.thresholdMin" <ng-container i18n>Threshold range</ng-container>:
/> <gf-value
<span class="mx-1">-</span> class="ml-1"
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMin').value"
[value]="data.settings.thresholdMax" />
/> <span class="mx-1">-</span>
</h6> <gf-value
<div class="align-items-center d-flex w-100"> [isPercent]="data.rule.configuration.threshold.unit === '%'"
<gf-value [locale]="data.locale"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [precision]="2"
[locale]="data.locale" [value]="settingsForm.get('thresholdMax').value"
[precision]="2" />
[value]="data.rule.configuration.threshold.min" </h6>
/> <div class="align-items-center d-flex w-100">
<mat-slider <gf-value
class="flex-grow-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[max]="data.rule.configuration.threshold.max" [locale]="data.locale"
[min]="data.rule.configuration.threshold.min" [precision]="2"
[step]="data.rule.configuration.threshold.step" [value]="data.rule.configuration.threshold.min"
> />
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> <mat-slider
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> class="flex-grow-1"
</mat-slider> [max]="data.rule.configuration.threshold.max"
<gf-value [min]="data.rule.configuration.threshold.min"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [step]="data.rule.configuration.threshold.step"
[locale]="data.locale" >
[precision]="2" <input formControlName="thresholdMin" matSliderStartThumb />
[value]="data.rule.configuration.threshold.max" <input formControlName="thresholdMax" matSliderEndThumb />
/> </mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div> </div>
</div> } @else {
} @else { <div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin">
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> <h6 class="d-flex mb-0">
<h6 class="d-flex mb-0"> <ng-container i18n>Threshold Min</ng-container>:
<ng-container i18n>Threshold Min</ng-container>: <gf-value
<gf-value class="ml-1"
class="ml-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMin').value"
[value]="data.settings.thresholdMin" />
/> </h6>
</h6> <div class="align-items-center d-flex w-100">
<div class="align-items-center d-flex w-100"> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.min"
[value]="data.rule.configuration.threshold.min" />
/> <mat-slider
<mat-slider class="flex-grow-1"
class="flex-grow-1" name="thresholdMin"
name="thresholdMin" [max]="data.rule.configuration.threshold.max"
[max]="data.rule.configuration.threshold.max" [min]="data.rule.configuration.threshold.min"
[min]="data.rule.configuration.threshold.min" [step]="data.rule.configuration.threshold.step"
[step]="data.rule.configuration.threshold.step" >
> <input formControlName="thresholdMin" matSliderThumb />
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> </mat-slider>
</mat-slider> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.max"
[value]="data.rule.configuration.threshold.max" />
/> </div>
</div> </div>
</div> <div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax">
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> <h6 class="d-flex mb-0">
<h6 class="d-flex mb-0"> <ng-container i18n>Threshold Max</ng-container>:
<ng-container i18n>Threshold Max</ng-container>: <gf-value
<gf-value class="ml-1"
class="ml-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMax').value"
[value]="data.settings.thresholdMax" />
/> </h6>
</h6> <div class="align-items-center d-flex w-100">
<div class="align-items-center d-flex w-100"> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.min"
[value]="data.rule.configuration.threshold.min" />
/> <mat-slider
<mat-slider class="flex-grow-1"
class="flex-grow-1" name="thresholdMax"
name="thresholdMax" [max]="data.rule.configuration.threshold.max"
[max]="data.rule.configuration.threshold.max" [min]="data.rule.configuration.threshold.min"
[min]="data.rule.configuration.threshold.min" [step]="data.rule.configuration.threshold.step"
[step]="data.rule.configuration.threshold.step" >
> <input formControlName="thresholdMax" matSliderThumb />
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> </mat-slider>
</mat-slider> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.max"
[value]="data.rule.configuration.threshold.max" />
/> </div>
</div> </div>
</div> }
} </div>
</div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button mat-button type="button" (click)="dialogRef.close()">
<button <ng-container i18n>Close</ng-container>
color="primary" </button>
mat-flat-button <button
(click)="dialogRef.close(data.settings)" color="primary"
> mat-flat-button
<ng-container i18n>Save</ng-container> type="submit"
</button> [disabled]="!settingsForm.dirty"
</div> >
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

4
apps/client/src/app/components/rules/rules.component.html

@ -3,9 +3,7 @@
<div class="col"> <div class="col">
@if (isLoading) { @if (isLoading) {
<gf-rule [isLoading]="true" /> <gf-rule [isLoading]="true" />
} } @else if (rules) {
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) { @for (rule of rules; track rule.key) {
<gf-rule <gf-rule
[categoryName]="categoryName" [categoryName]="categoryName"

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

@ -94,7 +94,7 @@
<ng-container i18n>or start a discussion at</ng-container> <ng-container i18n>or start a discussion at</ng-container>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
i18n-title i18n-title
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a

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

@ -1,10 +1,19 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE
} from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { import {
GfPageTabsComponent, GfPageTabsComponent,
TabConfiguration TabConfiguration
} from '@ghostfolio/ui/page-tabs'; } from '@ghostfolio/ui/page-tabs';
import { Component, OnInit } from '@angular/core'; import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
flashOutline, flashOutline,
@ -21,10 +30,23 @@ import {
styleUrls: ['./admin-page.scss'], styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html' templateUrl: './admin-page.html'
}) })
export class AdminPageComponent implements OnInit { export class AdminPageComponent {
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
private user: User;
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.userService.stateChanged
.pipe(takeUntilDestroyed())
.subscribe((state) => {
this.user = state?.user;
this.initializeTabs();
});
addIcons({ addIcons({
flashOutline, flashOutline,
peopleOutline, peopleOutline,
@ -34,7 +56,12 @@ export class AdminPageComponent implements OnInit {
}); });
} }
public ngOnInit() { private initializeTabs() {
const hasPermissionToAccessBullBoard = hasPermission(
this.user?.permissions,
permissions.accessAdminControlBullBoard
);
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
@ -51,11 +78,19 @@ export class AdminPageComponent implements OnInit {
label: internalRoutes.adminControl.subRoutes.marketData.title, label: internalRoutes.adminControl.subRoutes.marketData.title,
routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink
}, },
{ hasPermissionToAccessBullBoard
iconName: 'flash-outline', ? {
label: internalRoutes.adminControl.subRoutes.jobs.title, iconName: 'flash-outline',
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink label: $localize`Job Queue`,
}, onClick: () => {
this.onOpenBullBoard();
}
}
: {
iconName: 'flash-outline',
label: internalRoutes.adminControl.subRoutes.jobs.title,
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink
},
{ {
iconName: 'people-outline', iconName: 'people-outline',
label: internalRoutes.adminControl.subRoutes.users.title, label: internalRoutes.adminControl.subRoutes.users.title,
@ -63,4 +98,16 @@ export class AdminPageComponent implements OnInit {
} }
]; ];
} }
private onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
} }

2
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -193,7 +193,7 @@
} }
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

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

@ -186,7 +186,7 @@
} }
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

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

@ -294,7 +294,7 @@
> >
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

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

@ -1,6 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
@ -120,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag); hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user.settings.locale ?? defaultLocale; this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create'; this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -2,8 +2,6 @@
display: block; display: block;
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset;
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
} }

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

@ -185,10 +185,10 @@ export class GfAllocationsPageComponent implements OnInit {
this.initialize(); this.initialize();
} }
public onAccountChartClicked({ symbol }: AssetProfileIdentifier) { public onAccountChartClicked({ accountId }: { accountId: string }) {
if (symbol && symbol !== UNKNOWN_KEY) { if (accountId && accountId !== UNKNOWN_KEY) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true } queryParams: { accountId, accountDetailDialog: true }
}); });
} }
} }
@ -399,10 +399,7 @@ export class GfAllocationsPageComponent implements OnInit {
: position.valueInPercentage); : position.valueInPercentage);
} else { } else {
this.countries[code] = { this.countries[code] = {
name: getCountryName({ name: getCountryName({ code }),
code,
locale: this.user?.settings?.locale
}),
value: value:
weight * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)

4
apps/client/src/app/pages/portfolio/fire/fire-page.scss

@ -29,5 +29,9 @@
@include select-arrow(variables.$light-primary-text); @include select-arrow(variables.$light-primary-text);
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
option {
color: rgb(var(--dark-primary-text));
}
} }
} }

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

@ -68,20 +68,6 @@ export class GfPricingPageComponent implements OnInit {
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM' 'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
); );
protected readonly referralBrokers = [
'Alpian',
'DEGIRO',
'finpension',
'frankly',
'Interactive Brokers',
'Mintos',
'Monefit SmartSaver',
'Revolut',
'Swissquote',
'VIAC',
'Zak'
] as const;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink; protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkRegister = publicRoutes.register.routerLink; protected readonly routerLinkRegister = publicRoutes.register.routerLink;
protected user: User; protected user: User;

10
apps/client/src/app/pages/pricing/pricing-page.html

@ -305,23 +305,23 @@
</div> </div>
</div> </div>
</div> </div>
@if (user?.subscription?.type === 'Basic') { @if (user?.referralPartners?.length) {
<div class="row"> <div class="row">
<div class="col mt-3"> <div class="col mt-3">
<p> <p>
<ng-container i18n>If you plan to open an account at</ng-container> <ng-container i18n>If you plan to open an account at</ng-container>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>
@for ( @for (
broker of referralBrokers; partner of user.referralPartners;
track broker; track partner.name;
let i = $index; let i = $index;
let last = $last let last = $last
) { ) {
<i>{{ broker }}</i> <i>{{ partner.name }}</i>
@if (last) { @if (last) {
<span>, </span> <span>, </span>
} @else { } @else {
@if (i === referralBrokers.length - 2) { @if (i === user.referralPartners.length - 2) {
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container> <ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>

8
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -33,7 +33,6 @@ export class GfProductPageComponent implements OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const locale = document.documentElement.lang;
const { subscriptionOffer } = this.dataService.fetchInfo(); const { subscriptionOffer } = this.dataService.fetchInfo();
this.price = subscriptionOffer?.price; this.price = subscriptionOffer?.price;
@ -57,7 +56,7 @@ export class GfProductPageComponent implements OnInit {
'Türkçe' 'Türkçe'
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: getCountryName({ locale, code: 'CH' }), origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`], regions: [$localize`Global`],
slogan: 'Open Source Wealth Management', slogan: 'Open Source Wealth Management',
useAnonymously: true useAnonymously: true
@ -70,10 +69,7 @@ export class GfProductPageComponent implements OnInit {
}; };
if (this.product2.origin) { if (this.product2.origin) {
this.product2.origin = getCountryName({ this.product2.origin = getCountryName({ code: this.product2.origin });
locale,
code: this.product2.origin
});
} }
if (this.product2.regions) { if (this.product2.regions) {

2
apps/client/src/locales/messages.de.xlf

@ -2143,7 +2143,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7500665368930738879" datatype="html"> <trans-unit id="7500665368930738879" datatype="html">
<source>or start a discussion at</source> <source>or start a discussion at</source>
<target state="translated">oder beginne eine Diskussion unter</target> <target state="translated">oder beginne eine Diskussion bei</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">94</context> <context context-type="linenumber">94</context>

22
apps/client/src/locales/messages.es.xlf

@ -872,7 +872,7 @@
</trans-unit> </trans-unit>
<trans-unit id="273949409065292025" datatype="html"> <trans-unit id="273949409065292025" datatype="html">
<source>Energy</source> <source>Energy</source>
<target state="new">Energy</target> <target state="translated">Energía</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">90</context> <context context-type="linenumber">90</context>
@ -1208,7 +1208,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4432485267082955422" datatype="html"> <trans-unit id="4432485267082955422" datatype="html">
<source>Consumer Defensive</source> <source>Consumer Defensive</source>
<target state="new">Consumer Defensive</target> <target state="translated">Consumo Básico</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
@ -1276,7 +1276,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3848479214898655176" datatype="html"> <trans-unit id="3848479214898655176" datatype="html">
<source>Utilities</source> <source>Utilities</source>
<target state="new">Utilities</target> <target state="translated">Servicios Públicos</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">97</context> <context context-type="linenumber">97</context>
@ -2004,7 +2004,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2691624743109891676" datatype="html"> <trans-unit id="2691624743109891676" datatype="html">
<source>Consumer Cyclical</source> <source>Consumer Cyclical</source>
<target state="new">Consumer Cyclical</target> <target state="translated">Consumo Discrecional</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
@ -2948,7 +2948,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8432027249343784512" datatype="html"> <trans-unit id="8432027249343784512" datatype="html">
<source>Real Estate</source> <source>Real Estate</source>
<target state="translated">Propiedad inmobiliaria</target> <target state="translated">Inmobiliario</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">52</context> <context context-type="linenumber">52</context>
@ -3108,7 +3108,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8340410730497371637" datatype="html"> <trans-unit id="8340410730497371637" datatype="html">
<source>Communication Services</source> <source>Communication Services</source>
<target state="new">Communication Services</target> <target state="translated">Servicios de Comunicación</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">87</context>
@ -4316,7 +4316,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5765523585863707029" datatype="html"> <trans-unit id="5765523585863707029" datatype="html">
<source>Technology</source> <source>Technology</source>
<target state="new">Technology</target> <target state="translated">Tecnología</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">96</context>
@ -4964,7 +4964,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6945194064393203435" datatype="html"> <trans-unit id="6945194064393203435" datatype="html">
<source>Basic Materials</source> <source>Basic Materials</source>
<target state="new">Basic Materials</target> <target state="translated">Materiales Básicos</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">86</context> <context context-type="linenumber">86</context>
@ -5690,7 +5690,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4581276194280068721" datatype="html"> <trans-unit id="4581276194280068721" datatype="html">
<source>Industrials</source> <source>Industrials</source>
<target state="new">Industrials</target> <target state="translated">Industriales</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">93</context> <context context-type="linenumber">93</context>
@ -5806,7 +5806,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7194017842579795231" datatype="html"> <trans-unit id="7194017842579795231" datatype="html">
<source>Healthcare</source> <source>Healthcare</source>
<target state="new">Healthcare</target> <target state="translated">Salud</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">92</context> <context context-type="linenumber">92</context>
@ -6951,7 +6951,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7521745345796915891" datatype="html"> <trans-unit id="7521745345796915891" datatype="html">
<source>Financial Services</source> <source>Financial Services</source>
<target state="new">Financial Services</target> <target state="translated">Servicios Financieros</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">91</context>

3
apps/client/src/main.ts

@ -1,5 +1,6 @@
import { InfoResponse } from '@ghostfolio/common/interfaces'; import { InfoResponse } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment'; import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { GfNotificationModule } from '@ghostfolio/ui/notifications'; import { GfNotificationModule } from '@ghostfolio/ui/notifications';
@ -58,6 +59,8 @@ import { environment } from './environments/environment';
enableProdMode(); enableProdMode();
} }
registerChartConfiguration();
await bootstrapApplication(GfAppComponent, { await bootstrapApplication(GfAppComponent, {
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,

8
apps/client/src/styles/theme.scss

@ -312,7 +312,10 @@ $gf-typography: (
@include mat.checkbox-overrides( @include mat.checkbox-overrides(
( (
selected-icon-color: var(--gf-theme-primary-500) selected-focus-icon-color: var(--gf-theme-primary-500),
selected-hover-icon-color: var(--gf-theme-primary-500),
selected-icon-color: var(--gf-theme-primary-500),
selected-pressed-icon-color: var(--gf-theme-primary-500)
) )
); );
@ -392,8 +395,11 @@ $gf-typography: (
@include mat.tabs-overrides( @include mat.tabs-overrides(
( (
active-focus-indicator-color: var(--gf-theme-primary-500),
active-focus-label-text-color: var(--gf-theme-primary-500), active-focus-label-text-color: var(--gf-theme-primary-500),
active-hover-indicator-color: var(--gf-theme-primary-500),
active-hover-label-text-color: var(--gf-theme-primary-500), active-hover-label-text-color: var(--gf-theme-primary-500),
active-indicator-color: var(--gf-theme-primary-500),
active-label-text-color: var(--gf-theme-primary-500), active-label-text-color: var(--gf-theme-primary-500),
active-ripple-color: var(--gf-theme-primary-500), active-ripple-color: var(--gf-theme-primary-500),
inactive-ripple-color: var(--gf-theme-primary-500) inactive-ripple-color: var(--gf-theme-primary-500)

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

@ -13,8 +13,6 @@ export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPr
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;
export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`;
export const locale = 'en-US';
export const primaryColorHex = '#36cfcc'; export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = { export const primaryColorRgb = {
r: 54, r: 54,
@ -85,6 +83,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_DATE_RANGE: DateRange = 'max'; export const DEFAULT_DATE_RANGE: DateRange = 'max';
export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_HOST = '0.0.0.0';
export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_PAGE_SIZE = 50; export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333; export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
@ -253,6 +252,7 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';

20
libs/common/src/lib/helper.ts

@ -36,12 +36,12 @@ import { get, isNil, isString } from 'lodash';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LOCALE,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbol, ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix, ghostfolioScraperApiSymbolPrefix
locale
} from './config'; } from './config';
import { import {
AssetProfileItem, AssetProfileItem,
@ -258,15 +258,13 @@ export function getCurrencyFromSymbol(aSymbol = '') {
return aSymbol.replace(DEFAULT_CURRENCY, ''); return aSymbol.replace(DEFAULT_CURRENCY, '');
} }
export function getCountryName({ export function getCountryName({ code }: { code: string }): string {
code,
locale = getLocale()
}: {
code: string;
locale?: string;
}): string {
try { try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code; return (
new Intl.DisplayNames([document.documentElement.lang || DEFAULT_LOCALE], {
type: 'region'
}).of(code) ?? code
);
} catch { } catch {
return code; return code;
} }
@ -340,7 +338,7 @@ export function getEmojiFlag(aCountryCode: string) {
} }
export function getLocale() { export function getLocale() {
return navigator.language ?? locale; return navigator.language ?? DEFAULT_LOCALE;
} }
export function getLowercase(object: object, path: string) { export function getLowercase(object: object, path: string) {

8
libs/common/src/lib/interfaces/index.ts

@ -22,7 +22,10 @@ import type { HoldingWithParents } from './holding-with-parents.interface';
import type { Holding } from './holding.interface'; import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface'; import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface'; import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface'; import type {
LineChartItem,
NullableLineChartItem
} from './line-chart-item.interface';
import type { LookupItem } from './lookup-item.interface'; import type { LookupItem } from './lookup-item.interface';
import type { MarketData } from './market-data.interface'; import type { MarketData } from './market-data.interface';
import type { PortfolioChart } from './portfolio-chart.interface'; import type { PortfolioChart } from './portfolio-chart.interface';
@ -32,6 +35,7 @@ import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface'; import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioSummary } from './portfolio-summary.interface'; import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Product } from './product'; import type { Product } from './product';
import type { ReferralPartner } from './referral-partner.interface';
import type { AccessTokenResponse } from './responses/access-token-response.interface'; import type { AccessTokenResponse } from './responses/access-token-response.interface';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { AccountResponse } from './responses/account-response.interface'; import type { AccountResponse } from './responses/account-response.interface';
@ -156,6 +160,7 @@ export {
MarketData, MarketData,
MarketDataDetailsResponse, MarketDataDetailsResponse,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
NullableLineChartItem,
OAuthResponse, OAuthResponse,
PlatformsResponse, PlatformsResponse,
PortfolioChart, PortfolioChart,
@ -175,6 +180,7 @@ export {
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
PublicPortfolioResponse, PublicPortfolioResponse,
QuotesResponse, QuotesResponse,
ReferralPartner,
ResponseError, ResponseError,
RuleSettings, RuleSettings,
ScraperConfiguration, ScraperConfiguration,

6
libs/common/src/lib/interfaces/line-chart-item.interface.ts

@ -1,4 +1,6 @@
export interface LineChartItem { export interface LineChartItem<T = number> {
date: string; date: string;
value: number; value: T;
} }
export type NullableLineChartItem = LineChartItem<number | null>;

3
libs/common/src/lib/interfaces/referral-partner.interface.ts

@ -0,0 +1,3 @@
export interface ReferralPartner {
name: string;
}

16
libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts

@ -9,6 +9,19 @@ import { Tag } from '@prisma/client';
export interface PortfolioHoldingResponse { export interface PortfolioHoldingResponse {
activitiesCount: number; activitiesCount: number;
assetProfile: Pick<
EnhancedSymbolProfile,
| 'assetClass'
| 'assetSubClass'
| 'countries'
| 'currency'
| 'dataSource'
| 'isin'
| 'name'
| 'sectors'
| 'symbol'
| 'userId'
>;
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dateOfFirstActivity: string; dateOfFirstActivity: string;
@ -31,7 +44,10 @@ export interface PortfolioHoldingResponse {
netPerformanceWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number;
performances: Benchmark['performances']; performances: Benchmark['performances'];
quantity: number; quantity: number;
/* @deprecated */
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[]; tags: Tag[];
value: number; value: number;
} }

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

@ -3,6 +3,7 @@ import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Access, Tag } from '@prisma/client'; import { Access, Tag } from '@prisma/client';
import { ReferralPartner } from './referral-partner.interface';
import { SubscriptionOffer } from './subscription-offer.interface'; import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface'; import { SystemMessage } from './system-message.interface';
import { UserSettings } from './user-settings.interface'; import { UserSettings } from './user-settings.interface';
@ -15,6 +16,7 @@ export interface User {
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
id: string; id: string;
permissions: string[]; permissions: string[];
referralPartners?: ReferralPartner[];
settings: UserSettings; settings: UserSettings;
systemMessage?: SystemMessage; systemMessage?: SystemMessage;
subscription: { subscription: {

79
libs/common/src/lib/personal-finance-tools.ts

@ -1,6 +1,17 @@
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
export const personalFinanceTools: Product[] = [ export const personalFinanceTools: Product[] = [
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'acemoney',
name: 'AceMoney',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$44.95',
slogan: 'Lifetime Personal Finance Control, One Single Payment.',
url: 'https://www.mechcad.net'
},
{ {
founded: 2023, founded: 2023,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -361,6 +372,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Your financial superpower', slogan: 'Your financial superpower',
url: 'https://www.etops.com' url: 'https://www.etops.com'
}, },
{
founded: 2015,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'everydollar',
name: 'EveryDollar',
origin: 'US',
slogan: 'Plan, track, find more margin',
url: 'https://www.ramseysolutions.com/money/everydollar'
},
{ {
founded: 2020, founded: 2020,
hasFreePlan: true, hasFreePlan: true,
@ -497,6 +518,17 @@ export const personalFinanceTools: Product[] = [
slogan: 'Take control over your investments', slogan: 'Take control over your investments',
url: 'https://www.folishare.com' url: 'https://www.folishare.com'
}, },
{
founded: 1993,
hasSelfHostingAbility: true,
key: 'fund-manager',
name: 'Fund Manager',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$99',
slogan: 'Powerful portfolio management software',
url: 'https://www.fundmanagersoftware.com'
},
{ {
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -528,6 +560,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Portfolio Tracker, Analysis & Community', slogan: 'Portfolio Tracker, Analysis & Community',
url: 'https://www.getquin.com' url: 'https://www.getquin.com'
}, },
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'goodbudget',
name: 'Goodbudget',
origin: 'US',
slogan: 'Budget with a why',
url: 'https://goodbudget.com'
},
{ {
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -665,6 +706,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Sustainability insights for wealth managers', slogan: 'Sustainability insights for wealth managers',
url: 'https://leafs.ch' url: 'https://leafs.ch'
}, },
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'lunch-money',
name: 'Lunch Money',
origin: 'CA',
slogan: 'Delightfully simple personal finance and budgeting',
url: 'https://lunchmoney.app'
},
{ {
founded: 2018, founded: 2018,
hasFreePlan: false, hasFreePlan: false,
@ -725,6 +776,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'The smartest way to track your crypto', slogan: 'The smartest way to track your crypto',
url: 'https://www.merlincrypto.com' url: 'https://www.merlincrypto.com'
}, },
{
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'mezzi',
name: 'Mezzi',
origin: 'US',
pricingPerYear: '$48',
slogan: 'Self-manage your wealth. Get fiduciary advice.',
url: 'https://www.mezzi.com'
},
{ {
founded: 1991, founded: 1991,
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
@ -776,6 +837,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Have total control of your financial life', slogan: 'Have total control of your financial life',
url: 'https://www.moneyspire.com' url: 'https://www.moneyspire.com'
}, },
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'moneywell',
name: 'MoneyWell',
origin: 'US',
slogan: 'Personal Budgeting Software for Mac and iOS',
url: 'https://moneywell.app'
},
{ {
key: 'moneywiz', key: 'moneywiz',
name: 'MoneyWiz', name: 'MoneyWiz',
@ -1109,6 +1179,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Stock Portfolio Tracker', slogan: 'Stock Portfolio Tracker',
url: 'https://simpleportfolio.app' url: 'https://simpleportfolio.app'
}, },
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'simplifi',
name: 'Simplifi by Quicken',
origin: 'US',
url: 'https://www.quicken.com/products/simplifi'
},
{ {
founded: 2014, founded: 2014,
hasFreePlan: true, hasFreePlan: true,

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

@ -49,7 +49,7 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name"> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.platform?.url) { @if (element.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="d-inline d-sm-none mr-1" class="d-inline d-sm-none mr-1"

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

@ -161,13 +161,11 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="align-items-center d-flex line-height-1"> <div class="align-items-center d-flex line-height-1 text-nowrap">
<div> <span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span> @if (element.isDraft) {
@if (element.isDraft) { <span class="badge badge-secondary ml-1" i18n>Draft</span>
<span class="badge badge-secondary ml-1" i18n>Draft</span> }
}
</div>
</div> </div>
@if ( @if (
element.SymbolProfile?.dataSource !== 'MANUAL' && element.SymbolProfile?.dataSource !== 'MANUAL' &&

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

@ -1,6 +1,7 @@
import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper'; import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper';
import { Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js'; import { Chart, Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
interface VerticalHoverLinePluginOptions { interface VerticalHoverLinePluginOptions {
color?: string; color?: string;
@ -23,6 +24,10 @@ export function registerChartConfiguration() {
return; return;
} }
// Register the annotation plugin early so that every chart is initialized
// with its state and does not crash on interaction
Chart.register(annotationPlugin);
Tooltip.positioners.top = function (_elements, eventPosition) { Tooltip.positioners.top = function (_elements, eventPosition) {
return getTooltipPositionerMapTop(this.chart, eventPosition); return getTooltipPositionerMapTop(this.chart, eventPosition);
}; };

4
libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts

@ -1,4 +1,4 @@
import { locale } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -47,7 +47,7 @@ export const Simple: Story = {
annualInterestRate: 5, annualInterestRate: 5,
currency: 'USD', currency: 'USD',
fireWealth: 50000, fireWealth: 50000,
locale: locale, locale: DEFAULT_LOCALE,
savingsRate: 1000 savingsRate: 1000
} }
}; };

6
libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts

@ -68,7 +68,7 @@ export class GfHistoricalMarketDataEditorComponent
@Input() currency: string; @Input() currency: string;
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string; @Input() dateOfFirstActivity: Date;
@Input() symbol: string; @Input() symbol: string;
@Input() user: User; @Input() user: User;
@ -124,7 +124,7 @@ export class GfHistoricalMarketDataEditorComponent
public ngOnChanges() { public ngOnChanges() {
if (this.dateOfFirstActivity) { if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity); let date = this.dateOfFirstActivity;
const missingMarketData: { date: Date; marketPrice?: number }[] = []; const missingMarketData: { date: Date; marketPrice?: number }[] = [];
@ -174,7 +174,7 @@ export class GfHistoricalMarketDataEditorComponent
const dates = Object.keys(this.marketDataByMonth).sort(); const dates = Object.keys(this.marketDataByMonth).sort();
const startDateString = first(dates); const startDateString = first(dates);
const startDate = min([ const startDate = min([
parseISO(this.dateOfFirstActivity), this.dateOfFirstActivity,
...(startDateString ? [parseISO(startDateString)] : []) ...(startDateString ? [parseISO(startDateString)] : [])
]); ]);
const endDateString = last(dates); const endDateString = last(dates);

9
libs/ui/src/lib/page-tabs/interfaces/interfaces.ts

@ -1,6 +1,11 @@
export interface TabConfiguration { interface BaseTabConfiguration {
iconName: string; iconName: string;
label: string; label: string;
routerLink: string[];
showCondition?: boolean; showCondition?: boolean;
} }
export type TabConfiguration = BaseTabConfiguration &
(
| { onClick: () => void; routerLink?: never }
| { onClick?: never; routerLink: string[] }
);

49
libs/ui/src/lib/page-tabs/page-tabs.component.html

@ -10,21 +10,40 @@
> >
@for (tab of tabs(); track tab) { @for (tab of tabs(); track tab) {
@if (tab.showCondition !== false) { @if (tab.showCondition !== false) {
<a @if (tab.onClick) {
#rla="routerLinkActive" <button
class="no-min-width px-3" class="no-min-width overflow-hidden px-2"
mat-tab-link mat-tab-link
routerLinkActive type="button"
[active]="rla.isActive" (click)="tab.onClick()"
[routerLink]="tab.routerLink" >
[routerLinkActiveOptions]="{ exact: true }" <ng-container
> *ngTemplateOutlet="tabContent; context: { $implicit: tab }"
<ion-icon />
[name]="tab.iconName" </button>
[size]="deviceType === 'mobile' ? 'large' : 'small'" } @else {
/> <a
<div class="d-none d-sm-block ml-2" [innerHTML]="tab.label"></div> #rla="routerLinkActive"
</a> class="no-min-width overflow-hidden px-2"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.routerLink"
[routerLinkActiveOptions]="{ exact: true }"
>
<ng-container
*ngTemplateOutlet="tabContent; context: { $implicit: tab }"
/>
</a>
}
} }
} }
</nav> </nav>
<ng-template #tabContent let-tab>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large' : 'small'"
/>
<div class="d-none d-sm-block ml-1" [innerHTML]="tab.label"></div>
</ng-template>

43
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -37,7 +37,12 @@
@include mat.tabs-overrides( @include mat.tabs-overrides(
( (
container-height: 2rem active-focus-label-text-color: rgba(var(--palette-foreground-base), 1),
active-hover-label-text-color: rgba(var(--palette-foreground-base), 1),
active-label-text-color: rgba(var(--palette-foreground-base), 1),
active-ripple-color: rgba(var(--palette-foreground-base), 1),
container-height: 2rem,
inactive-ripple-color: rgba(var(--palette-foreground-base), 1)
) )
); );
@ -51,7 +56,15 @@
flex-direction: column; flex-direction: column;
.mat-mdc-tab-link { .mat-mdc-tab-link {
border-radius: 0.25rem;
font-weight: 400;
justify-content: flex-start; justify-content: flex-start;
margin: 0 0.5rem 0.1rem 0.5rem;
&.mdc-tab--active {
background-color: rgba(var(--palette-foreground-base), 0.05);
font-weight: 500;
}
} }
} }
} }
@ -61,8 +74,32 @@
:host-context(.theme-dark) { :host-context(.theme-dark) {
@media (min-width: 576px) { @media (min-width: 576px) {
.mat-mdc-tab-header { @include mat.tabs-overrides(
background-color: rgba(var(--palette-foreground-base-dark), 0.02); (
active-focus-label-text-color: rgba(
var(--palette-foreground-base-dark),
1
),
active-hover-label-text-color: rgba(
var(--palette-foreground-base-dark),
1
),
active-label-text-color: rgba(var(--palette-foreground-base-dark), 1),
active-ripple-color: rgba(var(--palette-foreground-base-dark), 1),
inactive-ripple-color: rgba(var(--palette-foreground-base-dark), 1)
)
);
::ng-deep {
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
.mat-mdc-tab-link {
&.mdc-tab--active {
background-color: rgba(var(--palette-foreground-base-dark), 0.05);
}
}
}
} }
} }
} }

3
libs/ui/src/lib/page-tabs/page-tabs.component.ts

@ -1,3 +1,4 @@
import { NgTemplateOutlet } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -13,7 +14,7 @@ import { TabConfiguration } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon, MatTabsModule, RouterModule], imports: [IonIcon, MatTabsModule, NgTemplateOutlet, RouterModule],
selector: 'gf-page-tabs', selector: 'gf-page-tabs',
styleUrls: ['./page-tabs.component.scss'], styleUrls: ['./page-tabs.component.scss'],
templateUrl: './page-tabs.component.html' templateUrl: './page-tabs.component.html'

5
libs/ui/src/lib/portfolio-proportion-chart/interfaces/interfaces.ts

@ -0,0 +1,5 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export type PortfolioProportionChartClickEvent =
| AssetProfileIdentifier
| { accountId: string };

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

@ -1,10 +1,7 @@
import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper'; import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper';
import { import { PortfolioPosition } from '@ghostfolio/common/interfaces';
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { import {
@ -36,6 +33,8 @@ import Color from 'color';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color'; import OpenColor from 'open-color';
import { PortfolioProportionChartClickEvent } from './interfaces/interfaces';
const { const {
blue, blue,
cyan, cyan,
@ -80,7 +79,8 @@ export class GfPortfolioProportionChartComponent
public chart: Chart<'doughnut'>; public chart: Chart<'doughnut'>;
public isLoading = true; public isLoading = true;
protected readonly proportionChartClicked = output<AssetProfileIdentifier>(); protected readonly proportionChartClicked =
output<PortfolioProportionChartClickEvent>();
private readonly OTHER_KEY = 'OTHER'; private readonly OTHER_KEY = 'OTHER';
@ -355,11 +355,11 @@ export class GfPortfolioProportionChartComponent
const dataIndex = activeElements[0].index; const dataIndex = activeElements[0].index;
const symbol = chart.data.labels?.[dataIndex] as string; const symbol = chart.data.labels?.[dataIndex] as string;
const dataSource = this.data[symbol].dataSource; const dataSource = this.data[symbol]?.dataSource;
if (dataSource) { this.proportionChartClicked.emit(
this.proportionChartClicked.emit({ dataSource, symbol }); dataSource ? { dataSource, symbol } : { accountId: symbol }
} );
} catch {} } catch {}
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {

25
libs/ui/src/lib/services/data.service.ts

@ -445,10 +445,27 @@ export class DataService {
}: { }: {
dataSource: DataSource; dataSource: DataSource;
symbol: string; symbol: string;
}) { }): Observable<
return this.http.get<PortfolioHoldingResponse>( Omit<PortfolioHoldingResponse, 'dateOfFirstActivity'> & {
`/api/v1/portfolio/holding/${dataSource}/${symbol}` dateOfFirstActivity: Date | undefined;
); }
> {
return this.http
.get<PortfolioHoldingResponse>(
`/api/v1/portfolio/holding/${dataSource}/${symbol}`
)
.pipe(
map((response) => {
const dateOfFirstActivity = response.dateOfFirstActivity
? parseISO(response.dateOfFirstActivity)
: undefined;
return {
...response,
dateOfFirstActivity
};
})
);
} }
public fetchInfo(): InfoItem { public fetchInfo(): InfoItem {

2
libs/ui/src/lib/world-map-chart/world-map-chart.component.ts

@ -95,7 +95,7 @@ export class GfWorldMapChartComponent implements OnChanges, OnDestroy {
this.svgMapElement.options.countryNames = Object.keys( this.svgMapElement.options.countryNames = Object.keys(
this.svgMapElement.countries this.svgMapElement.countries
).reduce<{ [code: string]: string }>((names, code) => { ).reduce<{ [code: string]: string }>((names, code) => {
names[code] = getCountryName({ code, locale: this.locale }); names[code] = getCountryName({ code });
return names; return names;
}, {}); }, {});

68
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.10.0", "version": "3.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.10.0", "version": "3.12.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "7.1.5", "@bull-board/api": "7.2.1",
"@bull-board/express": "7.1.5", "@bull-board/express": "7.2.1",
"@bull-board/nestjs": "7.1.5", "@bull-board/nestjs": "7.2.1",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6", "@internationalized/number": "3.6.6",
@ -63,7 +63,7 @@
"countries-and-timezones": "3.9.0", "countries-and-timezones": "3.9.0",
"countries-list": "3.3.0", "countries-list": "3.3.0",
"countup.js": "2.10.0", "countup.js": "2.10.0",
"date-fns": "4.1.0", "date-fns": "4.4.0",
"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",
@ -91,7 +91,7 @@
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "21.0.1", "stripe": "21.0.1",
"svgmap": "2.19.3", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "7.24.4", "undici": "7.24.4",
@ -3523,33 +3523,33 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "7.1.5", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.2.1.tgz",
"integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==", "integrity": "sha512-ldRG4POJLHf6oDrbDA7AsbTKliBmV4eySlwdUAumiRDtfvtbRSdXGE4Md2uPDova1r/ck7ExEe1+pHEQAZElqw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "7.1.5" "@bull-board/ui": "7.2.1"
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "7.1.5", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.2.1.tgz",
"integrity": "sha512-kp4SzhVjZlykryiQwcOhJjDhiLbBnZoAMoSgEstzqQ0raLw+jERRC6ryJ0MIQO+SO+Jv9EjjxrXCR8O2YSP/eg==", "integrity": "sha512-tBr/xV5letzKYPRGRkilTQZmfoCoy3mCuUo4M2dDoDKOhbrF360mK5v9/rIcSgYSyI9c7BgEgrve80LhmexNxQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "7.1.5", "@bull-board/api": "7.2.1",
"@bull-board/ui": "7.1.5", "@bull-board/ui": "7.2.1",
"ejs": "^5.0.2", "ejs": "^6.0.1",
"express": "^5.2.1" "express": "^5.2.1"
} }
}, },
"node_modules/@bull-board/express/node_modules/ejs": { "node_modules/@bull-board/express/node_modules/ejs": {
"version": "5.0.2", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
@ -3559,12 +3559,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "7.1.5", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.2.1.tgz",
"integrity": "sha512-1y+HkjnDaZoSCXJRsiYfBNBVx+PX3I8x3Uv+SSJuSpt2vHifMRwFbChO3XDxeWXetT1eR+yqPVq6ub5eJwNOYQ==", "integrity": "sha512-Uq2Z3+0ORgHJSw4TDV1kBrHdksRnK8CZdda63hrStduPnvKHPBxIZUGNfBN/vL08UqizpNkjFmNyNXiHOgf0LQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^7.1.5", "@bull-board/api": "^7.2.1",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -3573,12 +3573,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "7.1.5", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.2.1.tgz",
"integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==", "integrity": "sha512-O4ykrXrl2UJNHnhJrCvJxrw1ar+DlUBgyZUeZ8Ci+Ne5Wbq6rBv1gfpQH54/eu3IFbLso0S/kjc6WUGb2HPqZw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "7.1.5" "@bull-board/api": "7.2.1"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {
@ -20491,9 +20491,9 @@
} }
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@ -36530,9 +36530,9 @@
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/svgmap": { "node_modules/svgmap": {
"version": "2.19.3", "version": "2.21.0",
"resolved": "https://registry.npmjs.org/svgmap/-/svgmap-2.19.3.tgz", "resolved": "https://registry.npmjs.org/svgmap/-/svgmap-2.21.0.tgz",
"integrity": "sha512-LjKVzKgANVMRiHqFQVz57FqqX7tVm+EskubySCWb5kvizdeDBanscPA/c4tmK/48VCvYxrR1ecBbqStcD6HYfQ==", "integrity": "sha512-KQNP4rkkYe3D4EgqSxX+QoRzl13oDaOt5Oqe/ClIJme/u/AaQXMU+SXgt7iL4rfqK+lRc+15dXp+y7+zQg5WiA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"svg-pan-zoom": "^3.6.2" "svg-pan-zoom": "^3.6.2"

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.10.0", "version": "3.12.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",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "7.1.5", "@bull-board/api": "7.2.1",
"@bull-board/express": "7.1.5", "@bull-board/express": "7.2.1",
"@bull-board/nestjs": "7.1.5", "@bull-board/nestjs": "7.2.1",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6", "@internationalized/number": "3.6.6",
@ -107,7 +107,7 @@
"countries-and-timezones": "3.9.0", "countries-and-timezones": "3.9.0",
"countries-list": "3.3.0", "countries-list": "3.3.0",
"countup.js": "2.10.0", "countup.js": "2.10.0",
"date-fns": "4.1.0", "date-fns": "4.4.0",
"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",
@ -135,7 +135,7 @@
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "21.0.1", "stripe": "21.0.1",
"svgmap": "2.19.3", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "7.24.4", "undici": "7.24.4",

Loading…
Cancel
Save