Browse Source

Merge branch 'main' into task/remove-deprecated-fee-ratio-x-ray-rule

pull/6364/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
f421e241e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 27
      CHANGELOG.md
  2. 2
      apps/api/src/models/rule.ts
  3. 12
      apps/api/src/services/data-provider/data-provider.service.ts
  4. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  5. 12
      apps/client/src/app/pages/about/changelog/changelog-page.component.ts
  6. 12
      apps/client/src/app/pages/about/license/license-page.component.ts
  7. 12
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts
  8. 12
      apps/client/src/app/pages/admin/admin-page.component.ts
  9. 12
      apps/client/src/app/pages/demo/demo-page.component.ts
  10. 24
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  11. 8
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  12. 8
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts
  13. 8
      apps/client/src/app/pages/resources/resources-page.component.ts
  14. 8
      apps/client/src/locales/messages.zh.xlf
  15. 24
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  16. 2
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  17. 102
      libs/ui/src/lib/assistant/assistant.component.ts
  18. 2
      libs/ui/src/lib/assistant/assistant.html
  19. 90
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  20. 128
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  21. 8
      libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts
  22. 227
      package-lock.json
  23. 18
      package.json

27
CHANGELOG.md

@ -11,6 +11,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio)
### Fixed
- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page
## 2.243.0 - 2026-02-23
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.8` to `11.1.14`
### Fixed
- Fixed an issue when creating activities of type `FEE`, `INTEREST` or `LIABILITY`
## 2.242.0 - 2026-02-22
### Changed
- Changed the account field to optional in the create or update activity dialog
### Fixed
- Fixed a validation issue for valuables used in the create and import activity logic
- Fixed the page size for presets in the historical market data table of the admin control panel
## 2.241.0 - 2026-02-21
### Changed
@ -25,7 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue with `balanceInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `comment` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `interestInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `value` of the accounts in the value redaction interceptor for the impersonation mode

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

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue +
this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity)
.mul(currentValue.marketPrice)
.mul(currentValue.marketPrice ?? 0)
.toNumber(),
currentValue.currency,
baseCurrency

12
apps/api/src/services/data-provider/data-provider.service.ts

@ -244,11 +244,15 @@ export class DataProviderService implements OnModuleInit {
});
if (!assetProfiles[assetProfileIdentifier]) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
(assetProfile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
assetProfile.dataSource === dataSource &&
assetProfile.symbol === symbol
);
}
);
@ -257,7 +261,7 @@ export class DataProviderService implements OnModuleInit {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name
name: assetProfileInImport?.name ?? symbol
};
continue;

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

@ -379,7 +379,7 @@ export class GfAdminMarketDataComponent
this.pageSize =
this.activeFilters.length === 1 &&
this.activeFilters[0].type === 'PRESET_ID'
? undefined
? Number.MAX_SAFE_INTEGER
: DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) {

12
apps/client/src/app/pages/about/changelog/changelog-page.component.ts

@ -1,7 +1,6 @@
import { Component, OnDestroy } from '@angular/core';
import { Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
@Component({
imports: [MarkdownModule, NgxSkeletonLoaderModule],
@ -9,17 +8,10 @@ import { Subject } from 'rxjs';
styleUrls: ['./changelog-page.scss'],
templateUrl: './changelog-page.html'
})
export class GfChangelogPageComponent implements OnDestroy {
export class GfChangelogPageComponent {
public isLoading = true;
private unsubscribeSubject = new Subject<void>();
public onLoad() {
this.isLoading = false;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

12
apps/client/src/app/pages/about/license/license-page.component.ts

@ -1,6 +1,5 @@
import { Component, OnDestroy } from '@angular/core';
import { Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { Subject } from 'rxjs';
@Component({
imports: [MarkdownModule],
@ -8,11 +7,4 @@ import { Subject } from 'rxjs';
styleUrls: ['./license-page.scss'],
templateUrl: './license-page.html'
})
export class GfLicensePageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}
export class GfLicensePageComponent {}

12
apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts

@ -1,10 +1,9 @@
import { Component, OnDestroy } from '@angular/core';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { Subject } from 'rxjs';
const ossFriends = require('../../../../assets/oss-friends.json');
@ -14,17 +13,10 @@ const ossFriends = require('../../../../assets/oss-friends.json');
styleUrls: ['./oss-friends-page.scss'],
templateUrl: './oss-friends-page.html'
})
export class GfOpenSourceSoftwareFriendsPageComponent implements OnDestroy {
export class GfOpenSourceSoftwareFriendsPageComponent {
public ossFriends = ossFriends.data;
private unsubscribeSubject = new Subject<void>();
public constructor() {
addIcons({ arrowForwardOutline });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -1,7 +1,7 @@
import { TabConfiguration } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
@ -14,7 +14,6 @@ import {
settingsOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page has-tabs' },
@ -23,12 +22,10 @@ import { Subject } from 'rxjs';
styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html'
})
export class AdminPageComponent implements OnDestroy, OnInit {
export class AdminPageComponent implements OnInit {
public deviceType: string;
public tabs: TabConfiguration[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(private deviceService: DeviceDetectorService) {
addIcons({
flashOutline,
@ -74,9 +71,4 @@ export class AdminPageComponent implements OnDestroy, OnInit {
}
];
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

12
apps/client/src/app/pages/demo/demo-page.component.ts

@ -3,9 +3,8 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { Component, OnDestroy } from '@angular/core';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
@ -13,11 +12,9 @@ import { Subject } from 'rxjs';
standalone: true,
templateUrl: './demo-page.html'
})
export class GfDemoPageComponent implements OnDestroy {
export class GfDemoPageComponent {
public info: InfoItem;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dataService: DataService,
private notificationService: NotificationService,
@ -40,9 +37,4 @@ export class GfDemoPageComponent implements OnDestroy {
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -188,8 +188,7 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
!this.data.activity?.accountId &&
this.mode === 'create'
? this.data.accounts[0].id
: this.data.activity?.accountId,
Validators.required
: this.data.activity?.accountId
],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
@ -365,11 +364,6 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
(this.activityForm.get('dataSource').value === 'MANUAL' &&
type === 'BUY')
) {
this.activityForm
.get('accountId')
.removeValidators(Validators.required);
this.activityForm.get('accountId').updateValueAndValidity();
const currency =
this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value;
@ -397,11 +391,6 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.activityForm.get('updateAccountBalance').disable();
this.activityForm.get('updateAccountBalance').setValue(false);
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm
.get('accountId')
.removeValidators(Validators.required);
this.activityForm.get('accountId').updateValueAndValidity();
const currency =
this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId').value;
@ -447,8 +436,6 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
this.activityForm.get('updateAccountBalance').setValue(false);
}
} else {
this.activityForm.get('accountId').setValidators(Validators.required);
this.activityForm.get('accountId').updateValueAndValidity();
this.activityForm
.get('dataSource')
.setValidators(Validators.required);
@ -514,11 +501,12 @@ export class GfCreateOrUpdateActivityDialogComponent implements OnDestroy {
comment: this.activityForm.get('comment').value || null,
currency: this.activityForm.get('currency').value,
customCurrency: this.activityForm.get('currencyOfUnitPrice').value,
dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(
this.activityForm.get('type').value
)
? 'MANUAL'
: this.activityForm.get('dataSource').value,
date: this.activityForm.get('date').value,
dataSource:
this.activityForm.get('type').value === 'VALUABLE'
? 'MANUAL'
: this.activityForm.get('dataSource').value,
fee: this.activityForm.get('fee').value,
quantity: this.activityForm.get('quantity').value,
symbol:

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

@ -84,12 +84,8 @@
>
<mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId">
@if (
!activityForm.get('accountId').hasValidator(Validators.required) ||
(!activityForm.get('accountId').value && mode === 'update')
) {
<mat-option [value]="null" />
}
<mat-option [value]="null" />
@for (account of data.accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">

8
apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts

@ -9,6 +9,7 @@ import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject,
OnDestroy,
ViewChild
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -52,7 +53,7 @@ import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
styleUrls: ['./user-account-registration-dialog.scss'],
templateUrl: 'user-account-registration-dialog.html'
})
export class GfUserAccountRegistrationDialogComponent {
export class GfUserAccountRegistrationDialogComponent implements OnDestroy {
@ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string;
@ -95,4 +96,9 @@ export class GfUserAccountRegistrationDialogComponent {
public onChangeDislaimerChecked() {
this.isDisclaimerChecked = !this.isDisclaimerChecked;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -13,7 +13,6 @@ import {
readerOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page has-tabs' },
@ -47,8 +46,6 @@ export class ResourcesPageComponent implements OnInit {
}
];
private unsubscribeSubject = new Subject<void>();
public constructor(private deviceService: DeviceDetectorService) {
addIcons({ bookOutline, libraryOutline, newspaperOutline, readerOutline });
}
@ -56,9 +53,4 @@ export class ResourcesPageComponent implements OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

8
apps/client/src/locales/messages.zh.xlf

@ -7891,7 +7891,7 @@
</trans-unit>
<trans-unit id="rule.feeRatioInitialInvestment" datatype="html">
<source>Fee Ratio (legacy)</source>
<target state="new">费率</target>
<target state="translated">费率(旧版)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">152</context>
@ -7915,7 +7915,7 @@
</trans-unit>
<trans-unit id="rule.feeRatioTotalInvestmentVolume" datatype="html">
<source>Fee Ratio</source>
<target state="new">Fee Ratio</target>
<target state="translated">费率</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">161</context>
@ -7923,7 +7923,7 @@
</trans-unit>
<trans-unit id="rule.feeRatioTotalInvestmentVolume.false" datatype="html">
<source>The fees do exceed ${thresholdMax}% of your total investment volume (${feeRatio}%)</source>
<target state="new">The fees do exceed ${thresholdMax}% of your total investment volume (${feeRatio}%)</target>
<target state="translated">费用已超过您总投资金额的 ${thresholdMax}%(${feeRatio}%)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">163</context>
@ -7931,7 +7931,7 @@
</trans-unit>
<trans-unit id="rule.feeRatioTotalInvestmentVolume.true" datatype="html">
<source>The fees do not exceed ${thresholdMax}% of your total investment volume (${feeRatio}%)</source>
<target state="new">The fees do not exceed ${thresholdMax}% of your total investment volume (${feeRatio}%)</target>
<target state="translated">费用未超过您总投资金额的 ${thresholdMax}%(${feeRatio}%)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">167</context>

24
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -7,12 +7,12 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnChanges,
Output,
ViewChild
ViewChild,
inject,
output
} from '@angular/core';
import { Params, RouterModule } from '@angular/router';
@ -33,21 +33,23 @@ export class GfAssistantListItemComponent
implements FocusableOption, OnChanges
{
@HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus;
}
@Input() item: SearchResultItem;
@Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef;
@ViewChild('link') public linkElement: ElementRef<HTMLAnchorElement>;
public hasFocus = false;
public queryParams: Params;
public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
protected readonly clicked = output<void>();
private readonly changeDetectorRef = inject(ChangeDetectorRef);
@HostBinding('class.has-focus')
public get getHasFocus() {
return this.hasFocus;
}
public ngOnChanges() {
if (this.item?.mode === SearchMode.ACCOUNT) {
@ -65,7 +67,7 @@ export class GfAssistantListItemComponent
};
this.routerLink =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
internalRoutes.adminControl.subRoutes?.marketData.routerLink ?? [];
} else if (this.item?.mode === SearchMode.HOLDING) {
this.queryParams = {
dataSource: this.item.dataSource,

2
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -8,7 +8,7 @@
@if (item && isAsset(item)) {
<br />
<small class="text-muted"
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }}
>{{ item?.symbol ?? '' | gfSymbol }} · {{ item?.currency }}
@if (item?.assetSubClassString) {
· {{ item.assetSubClassString }}
}

102
libs/ui/src/lib/assistant/assistant.component.ts

@ -12,16 +12,15 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChild,
ViewChildren
ViewChildren,
output
} from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -86,37 +85,7 @@ import {
templateUrl: './assistant.html'
})
export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown(
event: KeyboardEvent
) {
if (!this.isOpen) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.onKeydown(event);
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
} else if (event.key === 'Enter') {
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.click();
event.stopPropagation();
}
}
}
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
@Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean;
@ -124,21 +93,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@Input() hasPermissionToChangeFilters: boolean;
@Input() user: User;
@Output() closed = new EventEmitter<void>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@Output() filtersChanged = new EventEmitter<Filter[]>();
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
@ViewChild('search', { static: true }) searchElement: ElementRef;
@ViewChild('search', { static: true })
searchElement: ElementRef<HTMLInputElement>;
@ViewChildren(GfAssistantListItemComponent)
assistantListItems: QueryList<GfAssistantListItemComponent>;
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = [];
public dateRangeFormControl = new FormControl<string>(undefined);
public dateRangeFormControl = new FormControl<string | null>(null);
public dateRangeOptions: DateRangeOption[] = [];
public holdings: PortfolioPosition[] = [];
public isLoading = {
@ -166,6 +130,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
};
public tags: Filter[] = [];
protected readonly closed = output<void>();
protected readonly dateRangeChanged = output<DateRange>();
protected readonly filtersChanged = output<Filter[]>();
private readonly PRESELECTION_DELAY = 100;
private filterTypes: Filter['type'][] = [
@ -188,6 +156,37 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
addIcons({ closeCircleOutline, closeOutline, searchOutline });
}
@HostListener('document:keydown', ['$event'])
public onKeydown(event: KeyboardEvent) {
if (!this.isOpen) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.onKeydown(event);
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
} else if (event.key === 'Enter') {
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.click();
event.stopPropagation();
}
}
}
public ngOnInit() {
this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return {
@ -482,7 +481,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetSubClass }) => {
return !['CASH'].includes(assetSubClass);
return assetSubClass && !['CASH'].includes(assetSubClass);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
@ -499,23 +498,23 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filtersChanged.emit([
{
id: filterValue?.account,
id: filterValue?.account ?? '',
type: 'ACCOUNT'
},
{
id: filterValue?.assetClass,
id: filterValue?.assetClass ?? '',
type: 'ASSET_CLASS'
},
{
id: filterValue?.holding?.dataSource,
id: filterValue?.holding?.dataSource ?? '',
type: 'DATA_SOURCE'
},
{
id: filterValue?.holding?.symbol,
id: filterValue?.holding?.symbol ?? '',
type: 'SYMBOL'
},
{
id: filterValue?.tag,
id: filterValue?.tag ?? '',
type: 'TAG'
}
]);
@ -541,7 +540,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterTypes.map((type) => {
return {
type,
id: null
id: ''
};
})
);
@ -673,7 +672,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass),
assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.ASSET_PROFILE as const
};
}
@ -705,7 +704,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass),
assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.HOLDING as const
};
}
@ -755,6 +754,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
const symbol = this.user?.settings?.['filters.symbol'];
const selectedHolding = this.holdings.find((holding) => {
return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier({
dataSource: holding.dataSource,
symbol: holding.symbol

2
libs/ui/src/lib/assistant/assistant.html

@ -186,7 +186,7 @@
<div class="p-3">
<gf-portfolio-filter-form
#portfolioFilterForm
[accounts]="user?.accounts"
[accounts]="user?.accounts ?? []"
[assetClasses]="assetClasses"
[formControl]="portfolioFilterFormControl"
[holdings]="holdings"

90
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -4,13 +4,16 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
DoCheck,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
ViewChild,
inject,
input,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroupDirective,
@ -21,15 +24,14 @@ import {
import {
MatAutocomplete,
MatAutocompleteModule,
MatAutocompleteSelectedEvent
MatOption
} from '@angular/material/autocomplete';
import {
MatFormFieldControl,
MatFormFieldModule
} from '@angular/material/form-field';
import { MatInput, MatInputModule } from '@angular/material/input';
import { Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
import { map, startWith } from 'rxjs/operators';
import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
@ -58,21 +60,19 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
templateUrl: 'currency-selector.component.html'
})
export class GfCurrencySelectorComponent
extends AbstractMatFormField<string>
implements DoCheck, OnDestroy, OnInit
extends AbstractMatFormField<string | null>
implements DoCheck, OnInit
{
@Input() private currencies: string[] = [];
@Input() private formControlName: string;
@ViewChild(MatInput) private input: MatInput;
@ViewChild('currencyAutocomplete')
public currencyAutocomplete: MatAutocomplete;
public control = new FormControl();
public readonly control = new FormControl<string | null>(null);
public readonly currencies = input.required<string[]>();
public filteredCurrencies: string[] = [];
public readonly formControlName = input.required<string>();
private unsubscribeSubject = new Subject<void>();
private readonly destroyRef = inject(DestroyRef);
private readonly input = viewChild.required(MatInput);
public constructor(
public readonly _elementRef: ElementRef,
@ -86,6 +86,19 @@ export class GfCurrencySelectorComponent
this.controlType = 'currency-selector';
}
public get empty() {
return this.input().empty;
}
public set value(value: string | null) {
this.control.setValue(value);
super.value = value;
}
public focus() {
this.input().focus();
}
public ngOnInit() {
if (this.disabled) {
this.control.disable();
@ -94,17 +107,18 @@ export class GfCurrencySelectorComponent
const formGroup = this.formGroupDirective.form;
if (formGroup) {
const control = formGroup.get(this.formControlName);
const control = formGroup.get(this.formControlName());
if (control) {
this.value = this.currencies.find((value) => {
return value === control.value;
});
this.value =
this.currencies().find((value) => {
return value === control.value;
}) ?? null;
}
}
this.control.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (super.value) {
super.value = null;
@ -113,10 +127,10 @@ export class GfCurrencySelectorComponent
this.control.valueChanges
.pipe(
takeUntil(this.unsubscribeSubject),
takeUntilDestroyed(this.destroyRef),
startWith(''),
map((value) => {
return value ? this.filter(value) : this.currencies.slice();
return value ? this.filter(value) : this.currencies().slice();
})
)
.subscribe((values) => {
@ -124,42 +138,22 @@ export class GfCurrencySelectorComponent
});
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.errorState = !!(this.ngControl.invalid && this.ngControl.touched);
this.stateChanges.next();
}
}
public onUpdateCurrency(event: MatAutocompleteSelectedEvent) {
super.value = event.option.value;
}
public set value(value: string) {
this.control.setValue(value);
super.value = value;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
public onUpdateCurrency({ option }: { option: MatOption<string> }) {
super.value = option.value;
}
private filter(value: string) {
const filterValue = value?.toLowerCase();
const filterValue = value.toLowerCase();
return this.currencies.filter((currency) => {
return this.currencies().filter((currency) => {
return currency.toLowerCase().startsWith(filterValue);
});
}
@ -168,7 +162,7 @@ export class GfCurrencySelectorComponent
const requiredCheck = super.required ? !super.value : false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
this.ngControl.control?.setErrors({ invalidData: true });
}
}
}

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

@ -13,13 +13,13 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
ViewChild,
output
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormControl,
@ -55,9 +55,9 @@ import {
startOfMonth,
sub
} from 'date-fns';
import { isNumber } from 'lodash';
import { isNil, isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, debounceTime, takeUntil } from 'rxjs';
import { debounceTime } from 'rxjs';
import { FireCalculatorService } from './fire-calculator.service';
@ -90,32 +90,31 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
@Input() retirementDate: Date;
@Input() savingsRate = 0;
@Output() annualInterestRateChanged = new EventEmitter<number>();
@Output() calculationCompleted =
new EventEmitter<FireCalculationCompleteEvent>();
@Output() projectedTotalAmountChanged = new EventEmitter<number>();
@Output() retirementDateChanged = new EventEmitter<Date>();
@Output() savingsRateChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public calculatorForm = this.formBuilder.group({
annualInterestRate: new FormControl<number>(undefined),
paymentPerPeriod: new FormControl<number>(undefined),
principalInvestmentAmount: new FormControl<number>(undefined),
projectedTotalAmount: new FormControl<number>(undefined),
retirementDate: new FormControl<Date>(undefined)
annualInterestRate: new FormControl<number | null>(null),
paymentPerPeriod: new FormControl<number | null>(null),
principalInvestmentAmount: new FormControl<number | null>(null),
projectedTotalAmount: new FormControl<number | null>(null),
retirementDate: new FormControl<Date | null>(null)
});
public chart: Chart<'bar'>;
public isLoading = true;
public minDate = addDays(new Date(), 1);
public periodsToRetire = 0;
protected readonly annualInterestRateChanged = output<number>();
protected readonly calculationCompleted =
output<FireCalculationCompleteEvent>();
protected readonly projectedTotalAmountChanged = output<number>();
protected readonly retirementDateChanged = output<Date>();
protected readonly savingsRateChanged = output<number>();
private readonly CONTRIBUTION_PERIOD = 12;
private readonly DEFAULT_RETIREMENT_DATE = startOfMonth(
addYears(new Date(), 10)
);
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@ -131,46 +130,56 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
);
this.calculatorForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed())
.subscribe(() => {
this.initialize();
});
this.calculatorForm.valueChanges
.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe(() => {
const { projectedTotalAmount, retirementDate } =
this.calculatorForm.getRawValue();
this.calculationCompleted.emit({
projectedTotalAmount,
retirementDate
});
if (projectedTotalAmount !== null && retirementDate !== null) {
this.calculationCompleted.emit({
projectedTotalAmount,
retirementDate
});
}
});
this.calculatorForm
.get('annualInterestRate')
.valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe((annualInterestRate) => {
this.annualInterestRateChanged.emit(annualInterestRate);
if (annualInterestRate !== null) {
this.annualInterestRateChanged.emit(annualInterestRate);
}
});
this.calculatorForm
.get('paymentPerPeriod')
.valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe((savingsRate) => {
this.savingsRateChanged.emit(savingsRate);
if (savingsRate !== null) {
this.savingsRateChanged.emit(savingsRate);
}
});
this.calculatorForm
.get('projectedTotalAmount')
.valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe((projectedTotalAmount) => {
this.projectedTotalAmountChanged.emit(projectedTotalAmount);
if (projectedTotalAmount !== null) {
this.projectedTotalAmountChanged.emit(projectedTotalAmount);
}
});
this.calculatorForm
.get('retirementDate')
.valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed())
.subscribe((retirementDate) => {
this.retirementDateChanged.emit(retirementDate);
if (retirementDate !== null) {
this.retirementDateChanged.emit(retirementDate);
}
});
}
@ -196,11 +205,11 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.calculatorForm.patchValue(
{
annualInterestRate:
this.calculatorForm.get('annualInterestRate').value,
this.calculatorForm.get('annualInterestRate')?.value,
paymentPerPeriod: this.getPMT(),
principalInvestmentAmount: this.calculatorForm.get(
'principalInvestmentAmount'
).value,
)?.value,
projectedTotalAmount:
Math.round(this.getProjectedTotalAmount()) || 0,
retirementDate:
@ -210,7 +219,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
emitEvent: false
}
);
this.calculatorForm.get('principalInvestmentAmount').disable();
this.calculatorForm.get('principalInvestmentAmount')?.disable();
this.changeDetectorRef.markForCheck();
});
@ -219,42 +228,43 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm
.get('annualInterestRate')
.enable({ emitEvent: false });
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
?.enable({ emitEvent: false });
this.calculatorForm.get('paymentPerPeriod')?.enable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.enable({ emitEvent: false });
?.enable({ emitEvent: false });
} else {
this.calculatorForm
.get('annualInterestRate')
.disable({ emitEvent: false });
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
?.disable({ emitEvent: false });
this.calculatorForm
.get('paymentPerPeriod')
?.disable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.disable({ emitEvent: false });
?.disable({ emitEvent: false });
}
this.calculatorForm.get('retirementDate').disable({ emitEvent: false });
this.calculatorForm.get('retirementDate')?.disable({ emitEvent: false });
}
public setMonthAndYear(
normalizedMonthAndYear: Date,
datepicker: MatDatepicker<Date>
) {
const retirementDate = this.calculatorForm.get('retirementDate').value;
const retirementDate =
this.calculatorForm.get('retirementDate')?.value ??
this.DEFAULT_RETIREMENT_DATE;
const newRetirementDate = setMonth(
setYear(retirementDate, normalizedMonthAndYear.getFullYear()),
normalizedMonthAndYear.getMonth()
);
this.calculatorForm.get('retirementDate').setValue(newRetirementDate);
this.calculatorForm.get('retirementDate')?.setValue(newRetirementDate);
datepicker.close();
}
public ngOnDestroy() {
this.chart?.destroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initialize() {
@ -288,8 +298,6 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
return `Total: ${new Intl.NumberFormat(this.locale, {
currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code',
style: 'currency'
}).format(totalAmount)}`;
@ -426,12 +434,16 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
}
private getPeriodsToRetire(): number {
if (this.calculatorForm.get('projectedTotalAmount').value) {
const projectedTotalAmount = this.calculatorForm.get(
'projectedTotalAmount'
)?.value;
if (projectedTotalAmount) {
let periods = this.fireCalculatorService.calculatePeriodsToRetire({
P: this.getP(),
PMT: this.getPMT(),
r: this.getR(),
totalAmount: this.calculatorForm.get('projectedTotalAmount').value
totalAmount: projectedTotalAmount
});
if (periods === Infinity) {
@ -453,12 +465,16 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
}
private getPMT() {
return this.calculatorForm.get('paymentPerPeriod').value;
return this.calculatorForm.get('paymentPerPeriod')?.value ?? 0;
}
private getProjectedTotalAmount() {
if (this.calculatorForm.get('projectedTotalAmount').value) {
return this.calculatorForm.get('projectedTotalAmount').value;
const projectedTotalAmount = this.calculatorForm.get(
'projectedTotalAmount'
)?.value;
if (!isNil(projectedTotalAmount)) {
return projectedTotalAmount;
}
const { totalAmount } =
@ -473,12 +489,12 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
}
private getR() {
return this.calculatorForm.get('annualInterestRate').value / 100;
return (this.calculatorForm.get('annualInterestRate')?.value ?? 0) / 100;
}
private getRetirementDate(): Date {
if (this.periodsToRetire === Number.MAX_SAFE_INTEGER) {
return undefined;
return this.DEFAULT_RETIREMENT_DATE;
}
const monthsToRetire = this.periodsToRetire % 12;

8
libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts

@ -1,8 +1,8 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export interface PortfolioFilterFormValue {
account: string;
assetClass: string;
holding: PortfolioPosition;
tag: string;
account: string | null;
assetClass: string | null;
holding: PortfolioPosition | null;
tag: string | null;
}

227
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.241.0",
"version": "2.243.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.241.0",
"version": "2.243.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -27,15 +27,15 @@
"@ionic/angular": "8.7.8",
"@keyv/redis": "4.4.0",
"@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.0.1",
"@nestjs/common": "11.1.8",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.8",
"@nestjs/cache-manager": "3.1.0",
"@nestjs/common": "11.1.14",
"@nestjs/config": "4.0.3",
"@nestjs/core": "11.1.14",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/jwt": "11.0.1",
"@nestjs/jwt": "11.0.2",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.8",
"@nestjs/schedule": "6.0.1",
"@nestjs/platform-express": "11.1.14",
"@nestjs/schedule": "6.1.1",
"@nestjs/serve-static": "5.0.4",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.19.0",
@ -107,7 +107,7 @@
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0",
"@nestjs/schematics": "11.0.9",
"@nestjs/testing": "11.1.8",
"@nestjs/testing": "11.1.14",
"@nx/angular": "22.4.5",
"@nx/eslint-plugin": "22.4.5",
"@nx/jest": "22.4.5",
@ -3617,6 +3617,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@borewit/text-codec": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
"integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz",
@ -7531,9 +7541,9 @@
}
},
"node_modules/@nestjs/cache-manager": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.0.1.tgz",
"integrity": "sha512-4UxTnR0fsmKL5YDalU2eLFVnL+OBebWUpX+hEduKGncrVKH4PPNoiRn1kXyOCjmzb0UvWgqubpssNouc8e0MCw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.0.tgz",
"integrity": "sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -7544,12 +7554,12 @@
}
},
"node_modules/@nestjs/common": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.8.tgz",
"integrity": "sha512-bbsOqwld/GdBfiRNc4nnjyWWENDEicq4SH+R5AuYatvf++vf1x5JIsHB1i1KtfZMD3eRte0D4K9WXuAYil6XAg==",
"version": "11.1.14",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz",
"integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==",
"license": "MIT",
"dependencies": {
"file-type": "21.0.0",
"file-type": "21.3.0",
"iterare": "1.2.1",
"load-esm": "1.0.3",
"tslib": "2.8.1",
@ -7575,57 +7585,24 @@
}
},
"node_modules/@nestjs/config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz",
"integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz",
"integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==",
"license": "MIT",
"dependencies": {
"dotenv": "16.4.7",
"dotenv-expand": "12.0.1",
"lodash": "4.17.21"
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"lodash": "4.17.23"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/config/node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/dotenv-expand": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz",
"integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/@nestjs/core": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.8.tgz",
"integrity": "sha512-7riWfmTmMhCJHZ5ZiaG+crj4t85IPCq/wLRuOUSigBYyFT2JZj0lVHtAdf4Davp9ouNI8GINBDt9h9b5Gz9nTw==",
"version": "11.1.14",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.14.tgz",
"integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -7677,13 +7654,13 @@
}
},
"node_modules/@nestjs/jwt": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz",
"integrity": "sha512-HXSsc7SAnCnjA98TsZqrE7trGtHDnYXWp4Ffy6LwSmck1QvbGYdMzBquXofX5l6tIRpeY4Qidl2Ti2CVG77Pdw==",
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.2"
"jsonwebtoken": "9.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
@ -7700,13 +7677,13 @@
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.8.tgz",
"integrity": "sha512-rL6pZH9BW7BnL5X2eWbJMtt86uloAKjFgyY5+L2UkizgfEp7rgAs0+Z1z0BcW2Pgu5+q8O7RKPNyHJ/9ZNz/ZQ==",
"version": "11.1.14",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz",
"integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==",
"license": "MIT",
"dependencies": {
"cors": "2.8.5",
"express": "5.1.0",
"cors": "2.8.6",
"express": "5.2.1",
"multer": "2.0.2",
"path-to-regexp": "8.3.0",
"tslib": "2.8.1"
@ -7721,12 +7698,12 @@
}
},
"node_modules/@nestjs/schedule": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz",
"integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
"license": "MIT",
"dependencies": {
"cron": "4.3.3"
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
@ -7910,9 +7887,9 @@
}
},
"node_modules/@nestjs/testing": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.8.tgz",
"integrity": "sha512-E6K+0UTKztcPxJzLnQa7S34lFjZbrj3Z1r7c5y5WDrL1m5HD1H4AeyBhicHgdaFmxjLAva2bq0sYKy/S7cdeYA==",
"version": "11.1.14",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.14.tgz",
"integrity": "sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12408,14 +12385,13 @@
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
"integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fflate": "^0.8.2",
"token-types": "^6.0.0"
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
@ -16984,9 +16960,9 @@
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@ -16994,6 +16970,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/corser": {
@ -17073,9 +17053,9 @@
"license": "MIT"
},
"node_modules/cron": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz",
"integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
@ -17083,6 +17063,10 @@
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cron-parser": {
@ -19852,18 +19836,19 @@
"license": "Apache-2.0"
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@ -20129,12 +20114,6 @@
"filenamify-url": "2.1.2"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@ -20175,14 +20154,14 @@
}
},
"node_modules/file-type": {
"version": "21.0.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz",
"integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==",
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
"integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.2.7",
"strtok3": "^10.2.2",
"token-types": "^6.0.0",
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
@ -24592,12 +24571,12 @@
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
@ -24613,27 +24592,6 @@
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken/node_modules/jws": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -32900,11 +32858,12 @@
}
},
"node_modules/token-types": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.4.tgz",
"integrity": "sha512-MD9MjpVNhVyH4fyd5rKphjvt/1qj+PtQUz65aFqAZA6XniWAuSFRjLk3e2VALEFlh9OwBpXUN7rfeqSnT/Fmkw==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
@ -33690,9 +33649,9 @@
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz",
"integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"

18
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.241.0",
"version": "2.243.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -72,15 +72,15 @@
"@ionic/angular": "8.7.8",
"@keyv/redis": "4.4.0",
"@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.0.1",
"@nestjs/common": "11.1.8",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.8",
"@nestjs/cache-manager": "3.1.0",
"@nestjs/common": "11.1.14",
"@nestjs/config": "4.0.3",
"@nestjs/core": "11.1.14",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/jwt": "11.0.1",
"@nestjs/jwt": "11.0.2",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.8",
"@nestjs/schedule": "6.0.1",
"@nestjs/platform-express": "11.1.14",
"@nestjs/schedule": "6.1.1",
"@nestjs/serve-static": "5.0.4",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.19.0",
@ -152,7 +152,7 @@
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0",
"@nestjs/schematics": "11.0.9",
"@nestjs/testing": "11.1.8",
"@nestjs/testing": "11.1.14",
"@nx/angular": "22.4.5",
"@nx/eslint-plugin": "22.4.5",
"@nx/jest": "22.4.5",

Loading…
Cancel
Save