Browse Source

Merge remote-tracking branch 'upstream/main' into refactor/portfolio-filter-component

pull/5618/head
Germán Martín 4 weeks ago
parent
commit
982a01a7e6
  1. 11
      CHANGELOG.md
  2. 18
      apps/api/src/app/admin/admin.controller.ts
  3. 14
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  4. 1
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  5. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  6. 133
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  7. 5
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  8. 9
      apps/client/src/app/directives/file-drop/file-drop.module.ts
  9. 6
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  10. 471
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  11. 63
      libs/ui/src/lib/assistant/assistant.component.ts
  12. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  13. 4
      package-lock.json
  14. 2
      package.json

11
CHANGELOG.md

@ -7,16 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
## 2.206.0 - 2025-10-04
### Changed
- Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page
- Improved the usability of the assistant by preselecting the first search result
- Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog
- Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint
- Refactored the _Open Startup_ (`/open`) page to standalone
- Refactored the file drop directive to standalone
- Refactored the symbol pipe to standalone
### Fixed
- Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service
- Added the missing `CommonModule` import in the import activities dialog
## 2.205.0 - 2025-10-01

18
apps/api/src/app/admin/admin.controller.ts

@ -6,6 +6,7 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man
import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -22,6 +23,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
DateRange,
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
@ -161,9 +163,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange, new Date());
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return;
}

14
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -94,17 +94,21 @@ export class DataGatheringService {
});
}
public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
public async gatherSymbol({ dataSource, date, symbol }: IDataGatheringItem) {
await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
})
.map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH

1
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -5,6 +5,7 @@ import {
export interface IRuleSettingsDialogParams {
categoryName: string;
locale: string;
rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
}

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

@ -1,4 +1,5 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
@ -17,6 +18,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
imports: [
CommonModule,
FormsModule,
GfValueComponent,
MatButtonModule,
MatDialogModule,
MatSliderModule

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

@ -5,28 +5,30 @@
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
) {
<div class="w-100">
<h6 class="mb-0">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold range</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMin }}
}
-
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMax | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMax }}
}
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMin"
/>
<span class="mx-1">-</span>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
[max]="data.rule.configuration.threshold.max"
@ -36,13 +38,12 @@
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
} @else {
@ -50,22 +51,23 @@
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
>
<h6 class="mb-0">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Min</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMin }}
}
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMin"
/>
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMin"
@ -75,35 +77,35 @@
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
<div
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
>
<h6 class="mb-0">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Max</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMax | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMax }}
}
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMax"
@ -113,13 +115,12 @@
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
}

5
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,10 +1,9 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[gfFileDrop]',
standalone: false
selector: '[gfFileDrop]'
})
export class FileDropDirective {
export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) {

9
apps/client/src/app/directives/file-drop/file-drop.module.ts

@ -1,9 +0,0 @@
import { NgModule } from '@angular/core';
import { FileDropDirective } from './file-drop.directive';
@NgModule({
declarations: [FileDropDirective],
exports: [FileDropDirective]
})
export class GfFileDropModule {}

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

@ -4,7 +4,7 @@ import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive';
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
@ -15,6 +15,7 @@ import {
StepperOrientation,
StepperSelectionEvent
} from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -59,10 +60,11 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
GfActivitiesTableComponent,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfFileDropModule,
GfFileDropDirective,
GfSymbolPipe,
IonIcon,
MatButtonModule,

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

@ -0,0 +1,471 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NotificationService } from 'apps/client/src/app/core/notification/notification.service';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
import { GfEntityLogoComponent } from '../entity-logo';
import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component';
import { GfValueComponent } from '../value';
import { GfActivitiesTableComponent } from './activities-table.component';
const activities: Activity[] = [
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2025-04-09T13:47:33.133Z'),
currency: 'USD',
date: new Date('2025-04-09T13:45:45.504Z'),
fee: 1,
id: 'a76968ff-80a4-4453-81ed-c3627dea3919',
isDraft: false,
quantity: 115,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 103.543,
updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 1,
unitPriceInAssetProfileCurrency: 103.543,
value: 11907.445,
valueInBaseCurrency: 11907.445
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-08-07T13:40:39.103Z'),
currency: 'USD',
date: new Date('2024-08-07T13:38:06.289Z'),
fee: 2.97,
id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec',
isDraft: false,
quantity: 105,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 110.24,
updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 2.97,
feeInBaseCurrency: 2.97,
unitPriceInAssetProfileCurrency: 110.24,
value: 11575.2,
valueInBaseCurrency: 11575.2
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
date: new Date('2024-03-12T15:14:38.597Z'),
fee: 45.29,
id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2',
isDraft: false,
quantity: 167,
symbolProfileId: '888d4123-db9a-42f3-9775-01b1ae6f9092',
type: 'BUY',
unitPrice: 41.0596,
updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
comment: null,
countries: [],
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
cusip: '463918102',
dataSource: 'YAHOO',
figi: 'BBG01KYQ6PV3',
figiComposite: 'BBG01KYQ6PV3',
figiShareClass: 'BBG01KYQ6QS5',
holdings: [],
id: '888d4123-db9a-42f3-9775-01b1ae6f9092',
isActive: true,
isin: 'CA4639181029',
name: 'iShares Bitcoin Trust',
updatedAt: new Date('2025-09-29T03:14:07.742Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'IBIT',
symbolMapping: {},
url: 'https://www.ishares.com',
userId: null,
activitiesCount: 6,
dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 45.29,
feeInBaseCurrency: 45.29,
unitPriceInAssetProfileCurrency: 41.0596,
value: 6856.9532,
valueInBaseCurrency: 6856.9532
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-02-23T15:53:46.907Z'),
currency: 'USD',
date: new Date('2024-02-23T15:53:15.745Z'),
fee: 3,
id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a',
isDraft: false,
quantity: 81,
symbolProfileId: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
type: 'BUY',
unitPrice: 67.995,
updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
comment: 'No data',
countries: [],
createdAt: new Date('2022-04-13T20:05:47.301Z'),
currency: 'USD',
cusip: '92206C565',
dataSource: 'YAHOO',
figi: 'BBG00LWSF7T3',
figiComposite: 'BBG00LWSF7T3',
figiShareClass: 'BBG00LWSF8K0',
holdings: [],
id: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
isActive: true,
isin: 'US92206C5655',
name: 'Vanguard Total World Bond ETF',
updatedAt: new Date('2025-10-02T06:02:56.314Z'),
sectors: [],
symbol: 'BNDW',
symbolMapping: {},
url: 'https://vanguard.com',
userId: null,
activitiesCount: 38,
dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z')
},
tags: [],
feeInAssetProfileCurrency: 3,
feeInBaseCurrency: 3,
unitPriceInAssetProfileCurrency: 67.995,
value: 5507.595,
valueInBaseCurrency: 5507.595
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2023-01-11T14:35:22.325Z'),
currency: 'USD',
date: new Date('2023-01-11T14:34:55.174Z'),
fee: 7.38,
id: '3fe87b3f-78de-407a-bc02-4189b221051f',
isDraft: false,
quantity: 55,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 89.48,
updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 7.38,
feeInBaseCurrency: 7.38,
unitPriceInAssetProfileCurrency: 89.48,
value: 4921.4,
valueInBaseCurrency: 4921.4
}
];
const dataSource = new MatTableDataSource<Activity>(activities);
export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
moduleMetadata({
imports: [
CommonModule,
GfActivityTypeComponent,
GfEntityLogoComponent,
GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
RouterModule.forChild([])
],
providers: [NotificationService]
})
]
} as Meta<GfActivitiesTableComponent>;
type Story = StoryObj<GfActivitiesTableComponent>;
export const Loading: Story = {
args: {
baseCurrency: 'USD',
dataSource: undefined,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 0
}
};
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};
export const Pagination: Story = {
args: {
baseCurrency: 'USD',
dataSource: new MatTableDataSource<Activity>(
Array.from({ length: 50 }).map((_, i) => ({
...(activities[i % activities.length] as Activity),
date: new Date(2025, 5, (i % 28) + 1),
id: `${i}`
}))
),
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 50
}
};

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

@ -168,6 +168,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
};
public tags: Filter[] = [];
private readonly PRESELECTION_DELAY = 100;
private filterTypes: Filter['type'][] = [
'ACCOUNT',
'ASSET_CLASS',
@ -175,7 +177,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
'SYMBOL',
'TAG'
];
private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private preselectionTimeout: ReturnType<typeof setTimeout>;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -342,6 +346,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.subscribe({
next: (searchResults) => {
this.searchResults = searchResults;
this.preselectFirstItem();
this.changeDetectorRef.markForCheck();
},
error: (error) => {
@ -597,6 +604,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
public ngOnDestroy() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
@ -607,6 +618,58 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
});
}
private getFirstSearchResultItem() {
if (this.searchResults.quickLinks?.length > 0) {
return this.searchResults.quickLinks[0];
}
if (this.searchResults.accounts?.length > 0) {
return this.searchResults.accounts[0];
}
if (this.searchResults.holdings?.length > 0) {
return this.searchResults.holdings[0];
}
if (this.searchResults.assetProfiles?.length > 0) {
return this.searchResults.assetProfiles[0];
}
return null;
}
private preselectFirstItem() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.preselectionTimeout = setTimeout(() => {
if (!this.isOpen || !this.searchFormControl.value) {
return;
}
const firstItem = this.getFirstSearchResultItem();
if (!firstItem) {
return;
}
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.setFirstItemActive();
const currentFocusedItem = this.getCurrentAssistantListItem();
if (currentFocusedItem) {
currentFocusedItem.focus();
}
this.changeDetectorRef.markForCheck();
}, this.PRESELECTION_DELAY);
}
private searchAccounts(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService
.fetchAccounts({

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

@ -1,4 +1,3 @@
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { getLocale } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -34,7 +33,6 @@ import { GfValueComponent } from '../value/value.component';
imports: [
CommonModule,
GfEntityLogoComponent,
GfSymbolPipe,
GfValueComponent,
MatButtonModule,
MatDialogModule,

4
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.205.0",
"version": "2.206.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.205.0",
"version": "2.206.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

2
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.205.0",
"version": "2.206.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save