Browse Source

Merge branch 'main' into bugfix/wrong-tag-filtering

pull/7838/head
Lyndros 3 weeks ago
parent
commit
f56ff3f450
  1. 11
      CHANGELOG.md
  2. 136
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 35
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  5. 4
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  6. 66
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  7. 8
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  8. 15
      apps/client/src/app/components/home-watchlist/home-watchlist.service.ts
  9. 114
      apps/client/src/app/components/home-watchlist/watchlist-dialog-host/watchlist-dialog-host.component.ts
  10. 21
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  11. 11
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  12. 4
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  13. 9
      apps/client/src/app/pages/home/home-page.routes.ts
  14. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  15. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  16. 5
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  17. 7
      apps/client/src/assets/oss-friends.json
  18. 7
      libs/common/src/lib/routes/routes.ts
  19. 4
      libs/ui/src/lib/assistant/assistant.component.ts
  20. 2
      libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts
  21. 2
      libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html
  22. 54
      package-lock.json
  23. 10
      package.json

11
CHANGELOG.md

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 3.69.0 - 2026-09-07
### Fixed ### Fixed
@ -13,8 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the loading state of the activity count in the portfolio summary
- Migrated the create dialog of the watchlist to a dedicated route
- Changed the holdings endpoint to return active and closed holdings by default and reuse a single snapshot for both types
- Upgraded `countries-and-timezones` from version `3.9.0` to `3.10.0`
- Upgraded `bull-board` from version `9.0.1` to `9.5.0`
- Upgraded `zod` from version `4.4.3` to `4.5.4` - Upgraded `zod` from version `4.4.3` to `4.5.4`
### Fixed
- Resolved an issue when opening an asset profile from the watchlist
## 3.68.0 - 2026-09-06 ## 3.68.0 - 2026-09-06
### Added ### Added

136
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -355,8 +355,13 @@ describe('PortfolioService', () => {
describe('getDetails', () => { describe('getDetails', () => {
const setUpCashOnlyPortfolio = ({ const setUpCashOnlyPortfolio = ({
baseCurrency = 'CHF', baseCurrency = 'CHF',
emergencyFund emergencyFund,
}: { baseCurrency?: string; emergencyFund?: number } = {}) => { quantity = 2000
}: {
baseCurrency?: string;
emergencyFund?: number;
quantity?: number;
} = {}) => {
const cashAccount: AccountWithBalance = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
@ -421,7 +426,7 @@ describe('PortfolioService', () => {
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {}, netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {}, netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000), quantity: new Big(quantity),
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
timeWeightedInvestment: new Big(0), timeWeightedInvestment: new Big(0),
@ -493,6 +498,131 @@ describe('PortfolioService', () => {
expect(holdings[0].assetProfile.symbol).toBe('USD'); expect(holdings[0].assetProfile.symbol).toBe('USD');
expect(holdings[0].valueInBaseCurrency).toBe(1000); expect(holdings[0].valueInBaseCurrency).toBe(1000);
}); });
it('should include closed holdings when all holdings are requested', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
const { holdings } = await portfolioService.getDetails({
filters: [],
includeAllHoldings: true,
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(0);
});
it.each([
{ holdingType: 'ACTIVE', quantity: 2000 },
{ holdingType: 'CLOSED', quantity: 0 }
])(
'should return $holdingType holdings when the holding type is specified',
async ({ holdingType, quantity }) => {
setUpCashOnlyPortfolio({ quantity });
const { holdings } = await portfolioService.getDetails({
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(quantity);
}
);
it('should remove the holding type only from the snapshot filters', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
await portfolioService.getDetails({
filters: [
{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' },
{ id: 'CLOSED', type: 'HOLDING_TYPE' }
],
userId: userDummyData.id
});
expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith(
expect.objectContaining({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }]
})
);
expect(
activitiesService.getActivitiesForPortfolioCalculator
).toHaveBeenCalledWith({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }],
userCurrency: 'CHF',
userId: userDummyData.id
});
});
});
describe('getHoldings', () => {
const activeHolding = {
assetProfile: {
isin: 'US0378331005',
name: 'Apple',
symbol: 'AAPL'
},
quantity: 1
};
const closedHolding = {
assetProfile: {
isin: 'US5949181045',
name: 'Microsoft',
symbol: 'MSFT'
},
quantity: 0
};
beforeEach(() => {
jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({
holdings: [activeHolding, closedHolding]
} as unknown as Awaited<ReturnType<typeof portfolioService.getDetails>>);
});
it('should request all holdings when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
userId: userDummyData.id
});
expect(holdings).toEqual([activeHolding, closedHolding]);
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: undefined,
includeAllHoldings: true,
userId: userDummyData.id
});
});
it('should find a closed holding when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: 'Microsoft', type: 'SEARCH_QUERY' }],
userId: userDummyData.id
});
expect(holdings).toEqual([closedHolding]);
});
it.each(['ACTIVE', 'CLOSED'])(
'should not request all holdings when the holding type is %s',
async (holdingType) => {
await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
includeAllHoldings: false,
userId: userDummyData.id
});
}
);
}); });
describe('getHolding', () => { describe('getHolding', () => {

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

@ -477,12 +477,12 @@ export class PortfolioService {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
}) { }) {
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const {
filters, HOLDING_TYPE: [filterByHoldingType] = [],
({ type }) => { SEARCH_QUERY: [filterBySearchQuery] = []
} = groupBy(filters, ({ type }) => {
return type; return type;
} });
);
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY'; return type !== 'SEARCH_QUERY';
@ -491,7 +491,8 @@ export class PortfolioService {
let { holdings } = await this.getDetails({ let { holdings } = await this.getDetails({
dateRange, dateRange,
userId, userId,
filters: filtersWithoutSearchQueryFilter filters: filtersWithoutSearchQueryFilter,
includeAllHoldings: !filterByHoldingType
}); });
if (filterBySearchQuery) { if (filterBySearchQuery) {
@ -592,6 +593,7 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
includeAllHoldings = false,
user: userFromCaller, user: userFromCaller,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
@ -600,6 +602,7 @@ export class PortfolioService {
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
includeAllHoldings?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
@ -614,19 +617,23 @@ export class PortfolioService {
(user.settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const portfolioSnapshotFilters = filters?.filter(({ type }) => {
return type !== 'HOLDING_TYPE';
});
const { activities } = const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency, userCurrency,
userId userId,
filters: portfolioSnapshotFilters
}); });
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
filters,
userId, userId,
calculationType: this.getUserPerformanceCalculationType(user), calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency currency: userCurrency,
filters: portfolioSnapshotFilters
}); });
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
@ -706,13 +713,13 @@ export class PortfolioService {
tags, tags,
valueInBaseCurrency valueInBaseCurrency
} of positions) { } of positions) {
if (isFilteredByClosedHoldings === true) { if (!includeAllHoldings) {
if (!quantity.eq(0)) { if (isFilteredByClosedHoldings && !quantity.eq(0)) {
// Ignore positions with a quantity // Ignore positions with a quantity
continue; continue;
} }
} else {
if (quantity.eq(0)) { if (!isFilteredByClosedHoldings && quantity.eq(0)) {
// Ignore positions without any quantity // Ignore positions without any quantity
continue; continue;
} }

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

@ -393,8 +393,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
.fetchPortfolioHoldings({ .fetchPortfolioHoldings({
filters: [ filters: [
{ {
type: 'ACCOUNT', id: this.data.accountId,
id: this.data.accountId type: 'ACCOUNT'
},
{
id: 'ACTIVE',
type: 'HOLDING_TYPE'
} }
] ]
}) })

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

@ -153,9 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
private fetchHoldings() { private fetchHoldings() {
const filters = this.userService.getFilters(); const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') { filters.push({ id: this.holdingType, type: 'HOLDING_TYPE' });
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
return this.dataService.fetchPortfolioHoldings({ return this.dataService.fetchPortfolioHoldings({
filters, filters,

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

@ -6,6 +6,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -23,12 +24,10 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; import { HomeWatchlistService } from './home-watchlist.service';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,6 +37,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
RouterModule RouterModule
], ],
providers: [HomeWatchlistService],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-watchlist', selector: 'gf-home-watchlist',
styleUrls: ['./home-watchlist.scss'], styleUrls: ['./home-watchlist.scss'],
@ -45,6 +45,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE; protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly internalRoutes = internalRoutes;
protected hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
@ -59,18 +60,14 @@ export class GfHomeWatchlistComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog); private readonly homeWatchlistService = inject(HomeWatchlistService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.route.queryParams this.homeWatchlistService.refresh$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe(() => {
if (params['createWatchlistItemDialog']) { this.loadWatchlistData();
this.openCreateWatchlistItemDialog();
}
}); });
this.userService.stateChanged this.userService.stateChanged
@ -130,49 +127,4 @@ export class GfHomeWatchlistComponent implements OnInit {
} }
}); });
} }
private openCreateWatchlistItemDialog() {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
if (
!hasPermission(user?.permissions, permissions.createWatchlistItem) ||
!hasScope(user?.scopes, scopes.watchlistCreate)
) {
this.router.navigate(['.'], { relativeTo: this.route });
return;
}
const dialogRef = this.dialog.open<
GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams
>(GfCreateWatchlistItemDialogComponent, {
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.loadWatchlistData()
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
} }

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

@ -22,5 +22,11 @@
</div> </div>
</div> </div>
@if (hasPermissionToCreateWatchlistItem) { @if (hasPermissionToCreateWatchlistItem) {
<gf-fab [queryParams]="{ createWatchlistItemDialog: true }" /> <gf-fab
[routerLink]="
internalRoutes.home.subRoutes.watchlist.subRoutes.create.routerLink
"
/>
} }
<router-outlet />

15
apps/client/src/app/components/home-watchlist/home-watchlist.service.ts

@ -0,0 +1,15 @@
import { Service } from '@angular/core';
import { Subject } from 'rxjs';
@Service({ autoProvided: false })
export class HomeWatchlistService {
private readonly refreshSubject = new Subject<void>();
public get refresh$() {
return this.refreshSubject.asObservable();
}
public triggerRefresh() {
this.refreshSubject.next();
}
}

114
apps/client/src/app/components/home-watchlist/watchlist-dialog-host/watchlist-dialog-host.component.ts

@ -0,0 +1,114 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } 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 { hasScope, scopes } from '@ghostfolio/common/scopes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from '../create-watchlist-item-dialog/create-watchlist-item-dialog.component';
import { CreateWatchlistItemDialogParams } from '../create-watchlist-item-dialog/interfaces/interfaces';
import { HomeWatchlistService } from '../home-watchlist.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-watchlist-dialog-host',
template: ''
})
export class GfWatchlistDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateWatchlistItemDialogComponent>;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly homeWatchlistService = inject(HomeWatchlistService);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
if (
!hasPermission(user?.permissions, permissions.createWatchlistItem) ||
!hasScope(user?.scopes, scopes.watchlistCreate)
) {
this.navigateBack();
return;
}
this.openDialog(user);
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
}
private navigateBack() {
void this.router.navigate(
internalRoutes.home.subRoutes.watchlist.routerLink
);
}
private openDialog(user: User) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
const dialogRef = this.dialog.open<
GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams
>(GfCreateWatchlistItemDialogComponent, {
data: {
deviceType,
locale: user?.settings?.locale ?? DEFAULT_LOCALE
},
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => {
if (!dataSource || !symbol) {
this.navigateBack();
return;
}
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.navigateBack();
},
next: () => {
this.homeWatchlistService.triggerRefresh();
this.navigateBack();
}
});
});
}
}

21
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -9,10 +9,17 @@
/> />
</div> </div>
</div> </div>
<div @if (isLoading) {
class="flex-nowrap px-3 py-1 row" <div class="flex-nowrap px-3 py-1 row">
[hidden]="summary?.activityCount === null" <div class="d-flex flex-grow-1 indent-1 text-truncate">
> <ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '1.5rem', width: '7.5rem' }"
/>
</div>
</div>
} @else {
<div class="flex-nowrap px-3 py-1 row">
<div class="d-flex flex-grow-1 indent-1 text-truncate"> <div class="d-flex flex-grow-1 indent-1 text-truncate">
{{ summary?.activityCount }} {{ summary?.activityCount }}
<ng-container i18n>{summary?.activityCount, plural, <ng-container i18n>{summary?.activityCount, plural,
@ -28,6 +35,7 @@
</span> </span>
</div> </div>
</div> </div>
}
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
@ -127,11 +135,16 @@
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-1 text-truncate"> <div class="flex-grow-1 indent-1 text-truncate">
<ng-container i18n>Net Performance</ng-container> <ng-container i18n>Net Performance</ng-container>
@if (
user?.settings?.performanceCalculationType ===
PerformanceCalculationType.ROAI
) {
<abbr <abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted" class="d-none d-sm-inline-block initialism ml-2 text-muted"
title="Return on Average Investment" title="Return on Average Investment"
>(ROAI)</abbr >(ROAI)</abbr
> >
}
</div> </div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value

11
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -1,6 +1,7 @@
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper'; import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces'; import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -23,10 +24,16 @@ import {
informationCircleOutline informationCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfValueComponent, IonIcon, MatTooltipModule], imports: [
GfValueComponent,
IonIcon,
MatTooltipModule,
NgxSkeletonLoaderModule
],
selector: 'gf-portfolio-summary', selector: 'gf-portfolio-summary',
styleUrls: ['./portfolio-summary.component.scss'], styleUrls: ['./portfolio-summary.component.scss'],
templateUrl: './portfolio-summary.component.html' templateUrl: './portfolio-summary.component.html'
@ -48,6 +55,8 @@ export class GfPortfolioSummaryComponent implements OnChanges {
'BUY_AND_SELL_ACTIVITIES_TOOLTIP' 'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
); );
protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected isCashExpanded = false; protected isCashExpanded = false;
protected isHoldingsExpanded = false; protected isHoldingsExpanded = false;
protected precision = 2; protected precision = 2;

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

@ -367,7 +367,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private loadHoldings() { private loadHoldings() {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings); this.holdings = getHoldingsForFilter(holdings);

9
apps/client/src/app/pages/home/home-page.routes.ts

@ -2,6 +2,7 @@ import { GfHomeHoldingsComponent } from '@ghostfolio/client/components/home-hold
import { GfHomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { GfHomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component';
import { GfHomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; import { GfHomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component';
import { GfHomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { GfHomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { GfWatchlistDialogHostComponent } from '@ghostfolio/client/components/home-watchlist/watchlist-dialog-host/watchlist-dialog-host.component';
import { GfMarketsComponent } from '@ghostfolio/client/components/markets/markets.component'; import { GfMarketsComponent } from '@ghostfolio/client/components/markets/markets.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -34,6 +35,14 @@ export const routes: Routes = [
title: internalRoutes.home.subRoutes.markets.title title: internalRoutes.home.subRoutes.markets.title
}, },
{ {
children: [
{
component: GfWatchlistDialogHostComponent,
path: internalRoutes.home.subRoutes.watchlist.subRoutes.create.path,
title:
internalRoutes.home.subRoutes.watchlist.subRoutes.create.title
}
],
path: internalRoutes.home.subRoutes.watchlist.path, path: internalRoutes.home.subRoutes.watchlist.path,
component: GfHomeWatchlistComponent, component: GfHomeWatchlistComponent,
title: internalRoutes.home.subRoutes.watchlist.title title: internalRoutes.home.subRoutes.watchlist.title

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

@ -153,7 +153,9 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.defaultLookupItems = holdings this.defaultLookupItems = holdings

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -144,6 +144,10 @@ export class GfImportActivitiesDialogComponent {
{ {
id: AssetClass.FIXED_INCOME, id: AssetClass.FIXED_INCOME,
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
},
{
id: 'ACTIVE',
type: 'HOLDING_TYPE'
} }
], ],
range: DEFAULT_DATE_RANGE range: DEFAULT_DATE_RANGE

5
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -377,7 +377,10 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchPortfolioHoldings({ .fetchPortfolioHoldings({
filters: this.userService.getFilters(), filters: [
...this.userService.getFilters(),
{ id: 'ACTIVE', type: 'HOLDING_TYPE' }
],
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

7
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{ {
"createdAt": "2026-05-26T00:00:00.000Z", "createdAt": "2026-09-07T00:00:00.000Z",
"data": [ "data": [
{ {
"name": "Activepieces", "name": "Activepieces",
@ -76,11 +76,6 @@
"description": "Onyx is the open-source AI chat connected to your docs, apps, and people.", "description": "Onyx is the open-source AI chat connected to your docs, apps, and people.",
"href": "https://onyx.app" "href": "https://onyx.app"
}, },
{
"name": "OpenBB",
"description": "Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
"href": "https://openbb.co"
},
{ {
"name": "OpenStatus", "name": "OpenStatus",
"description": "Open-source monitoring platform with beautiful status pages", "description": "Open-source monitoring platform with beautiful status pages",

7
libs/common/src/lib/routes/routes.ts

@ -156,6 +156,13 @@ export const internalRoutes = {
watchlist: { watchlist: {
path: 'watchlist', path: 'watchlist',
routerLink: ['/home', 'watchlist'], routerLink: ['/home', 'watchlist'],
subRoutes: {
create: {
path: 'create',
routerLink: ['/home', 'watchlist', 'create'],
title: $localize`Add to Watchlist`
}
},
title: $localize`Watchlist` title: $localize`Watchlist`
} }
}, },

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

@ -479,7 +479,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.setIsOpen(true); this.setIsOpen(true);
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings); this.holdings = getHoldingsForFilter(holdings);

2
libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts

@ -44,7 +44,7 @@ import { BenchmarkDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'benchmark-detail-dialog.html' templateUrl: 'benchmark-detail-dialog.html'
}) })
export class GfBenchmarkDetailDialogComponent implements OnInit { export class GfBenchmarkDetailDialogComponent implements OnInit {
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile?: AdminMarketDataDetails['assetProfile'];
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isLoading = true; public isLoading = true;
public value: number; public value: number;

2
libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
position="center" position="center"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="assetProfile.name ?? assetProfile.symbol ?? ''" [title]="assetProfile?.name ?? assetProfile?.symbol ?? ''"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
/> />

54
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.68.0", "version": "3.69.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.68.0", "version": "3.69.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "22.1.2", "@angular/platform-browser-dynamic": "22.1.2",
"@angular/router": "22.1.2", "@angular/router": "22.1.2",
"@angular/service-worker": "22.1.2", "@angular/service-worker": "22.1.2",
"@bull-board/api": "9.0.1", "@bull-board/api": "9.5.0",
"@bull-board/express": "9.0.1", "@bull-board/express": "9.5.0",
"@bull-board/nestjs": "9.0.1", "@bull-board/nestjs": "9.5.0",
"@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.7", "@internationalized/number": "3.6.7",
@ -67,7 +67,7 @@
"class-validator": "0.15.1", "class-validator": "0.15.1",
"color": "5.0.3", "color": "5.0.3",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"countries-and-timezones": "3.9.0", "countries-and-timezones": "3.10.0",
"countries-list": "3.4.1", "countries-list": "3.4.1",
"countup.js": "2.10.1", "countup.js": "2.10.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@ -6166,17 +6166,17 @@
"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": "9.0.1", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-9.5.0.tgz",
"integrity": "sha512-QZ+SDOlD1MAePNqy8qfs+7zExZyJCW/+nEncGE6Mt/+MlxT9mqvyZWmPUm38QZvIBJGSKaA9i9ZElR9wDbbPhw==", "integrity": "sha512-fmOyOy1lZiLZFyDJjI2osZrpTMO+JSgmGNhDf3MrmOf3oF084o6w0wpGefs0OqEfyUT1F5/1arE5AwmEUuxZ+A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "9.0.1", "@bull-board/ui": "9.5.0",
"bull": "^4.16.5", "bull": "^4.16.5",
"bullmq": "^5.79.2 || ^6.0.0" "bullmq": "^5.56.0 || ^6.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"bull": { "bull": {
@ -6188,13 +6188,13 @@
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "9.0.1", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-9.5.0.tgz",
"integrity": "sha512-eblNP2imr639K9KG0n1sBaJp6lw6pu4/j7q3W+bydxtw83v4wobCcuVUxuhQkRaLTrIUMR/Snulc7+/vtxCshw==", "integrity": "sha512-EKeXQzeb1zF9ra0hfYfPArU1JlWKl2vyL4Rb3gQW0XzU7SkEFgYm3727gDrQZdU8dvc5g/FLEqc0dr6UkDUHhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "9.0.1", "@bull-board/api": "9.5.0",
"@bull-board/ui": "9.0.1", "@bull-board/ui": "9.5.0",
"ejs": "^6.0.1", "ejs": "^6.0.1",
"express": "^5.2.1" "express": "^5.2.1"
} }
@ -6212,12 +6212,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "9.0.1", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-9.5.0.tgz",
"integrity": "sha512-pVcMZyuZ/X8SLjoayW8aREM1f4RFdI9WlwvHmT9RwlPhRnFLTnGMbnC5LOk+cYR/9WTB/Oeo1SDnzblIR99JtQ==", "integrity": "sha512-0P/Ru3cbOnf6GwnDGv4iPJ/NEjbcaicdpYMISpp6K7JZ24RVkCnmoUIKt8KbQdjZGqfH2EWmyIRC2WhiUE3T+g==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^9.0.1", "@bull-board/api": "^9.5.0",
"@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",
@ -6226,12 +6226,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "9.0.1", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-9.5.0.tgz",
"integrity": "sha512-yGg0CMAFCA49IM1zPS+JFvUJW/whftaLtRyMWiVtvyLRd53uX0QW/uTaPcHy0V4bApp6U0FTa9+L9rk/49ciwg==", "integrity": "sha512-qrq0e906M8Ps/mV3bQXmj7EXJWLaFs5mNz+oBoLR/7EShBi5YooROzQ/SEFFedg/L9h6QWDWJ9Ph0AH7NrG9uw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "9.0.1" "@bull-board/api": "9.5.0"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {
@ -19044,9 +19044,9 @@
} }
}, },
"node_modules/countries-and-timezones": { "node_modules/countries-and-timezones": {
"version": "3.9.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.9.0.tgz", "resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.10.0.tgz",
"integrity": "sha512-3upkVtUfZgdp5xOQI/Deg1ye73ae6bygJMO4UvtUi6wLjwcj7cBqx2PzSZ1VL8i5iveBbjhsQp2Judx7kPKivA==", "integrity": "sha512-HQ1GtXWNZy0r5MnZMLF9xmBmFNmtDVBeKQgX/5DY+RVmcZTODA+EPN/JLPCYRNP86nHG0QQrb1yJwGzXeqCCXw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.x", "node": ">=8.x",

10
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.68.0", "version": "3.69.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": "22.1.2", "@angular/platform-browser-dynamic": "22.1.2",
"@angular/router": "22.1.2", "@angular/router": "22.1.2",
"@angular/service-worker": "22.1.2", "@angular/service-worker": "22.1.2",
"@bull-board/api": "9.0.1", "@bull-board/api": "9.5.0",
"@bull-board/express": "9.0.1", "@bull-board/express": "9.5.0",
"@bull-board/nestjs": "9.0.1", "@bull-board/nestjs": "9.5.0",
"@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.7", "@internationalized/number": "3.6.7",
@ -111,7 +111,7 @@
"class-validator": "0.15.1", "class-validator": "0.15.1",
"color": "5.0.3", "color": "5.0.3",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"countries-and-timezones": "3.9.0", "countries-and-timezones": "3.10.0",
"countries-list": "3.4.1", "countries-list": "3.4.1",
"countup.js": "2.10.1", "countup.js": "2.10.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",

Loading…
Cancel
Save